308308 TRUE
309309}
310310
311+ # ' @keywords internal
312+ # ' @noRd
313+ .adaptive_stop_boundary_bootstrap <- function (state ) {
314+ out <- state
315+ out $ meta <- out $ meta %|| % list ()
316+ out $ meta $ stop_boundary_refit_id <- as.integer(out $ meta $ stop_boundary_refit_id %|| % NA_integer_ )
317+ out $ meta $ stop_boundary_step_id <- as.integer(out $ meta $ stop_boundary_step_id %|| % NA_integer_ )
318+ out $ meta $ pairs_committed_after_stop <- as.integer(out $ meta $ pairs_committed_after_stop %|| % 0L )
319+ if (! is.finite(out $ meta $ pairs_committed_after_stop ) ||
320+ is.na(out $ meta $ pairs_committed_after_stop ) ||
321+ out $ meta $ pairs_committed_after_stop < 0L ) {
322+ out $ meta $ pairs_committed_after_stop <- 0L
323+ }
324+
325+ boundary_step <- as.integer(out $ meta $ stop_boundary_step_id %|| % NA_integer_ )
326+ if (! is.na(boundary_step )) {
327+ step_log <- tibble :: as_tibble(out $ step_log %|| % tibble :: tibble())
328+ if (nrow(step_log ) > 0L && all(c(" step_id" , " pair_id" ) %in% names(step_log ))) {
329+ after_stop <- as.integer(step_log $ step_id ) > boundary_step & ! is.na(step_log $ pair_id )
330+ out $ meta $ pairs_committed_after_stop <- as.integer(sum(after_stop , na.rm = TRUE ))
331+ } else {
332+ out $ meta $ pairs_committed_after_stop <- 0L
333+ }
334+ }
335+
336+ out
337+ }
338+
339+ # ' @keywords internal
340+ # ' @noRd
341+ .adaptive_stop_boundary_budget_status <- function (state , controller = NULL ) {
342+ controller <- controller %|| % .adaptive_controller_resolve(state )
343+ max_pairs_after_stop <- as.integer(controller $ max_pairs_after_stop %|| % 0L )
344+ if (! is.finite(max_pairs_after_stop ) || is.na(max_pairs_after_stop ) || max_pairs_after_stop < 0L ) {
345+ max_pairs_after_stop <- 0L
346+ }
347+ boundary_step <- as.integer(state $ meta $ stop_boundary_step_id %|| % NA_integer_ )
348+ pairs_after_stop <- as.integer(state $ meta $ pairs_committed_after_stop %|| % 0L )
349+ if (! is.finite(pairs_after_stop ) || is.na(pairs_after_stop ) || pairs_after_stop < 0L ) {
350+ pairs_after_stop <- 0L
351+ }
352+ active <- ! is.na(boundary_step ) && ! isTRUE(state $ meta $ stop_decision %|| % FALSE )
353+ exhausted <- isTRUE(active ) && pairs_after_stop > = max_pairs_after_stop
354+ list (
355+ active = isTRUE(active ),
356+ exhausted = isTRUE(exhausted ),
357+ max_pairs_after_stop = as.integer(max_pairs_after_stop ),
358+ pairs_after_stop = as.integer(pairs_after_stop )
359+ )
360+ }
361+
311362# ' @keywords internal
312363# ' @noRd
313364.adaptive_link_stage_progress <- function (state , spoke_id , stage_quotas , stage_order , refit_id = NULL ) {
710761# ' \item{`link_identified_reliability_min`, `link_stop_reliability_min`,
711762# ' `link_rank_corr_min`, `delta_sd_max`, `delta_change_max`,
712763# ' `log_alpha_sd_max`, `log_alpha_change_max`, `cross_set_ppc_brier_max`,
713- # ' `ppc_calibration_id`, `probe_pairs_per_refit_per_spoke`,
764+ # ' `ppc_calibration_id`, `max_pairs_after_stop`,
765+ # ' `probe_pairs_per_refit_per_spoke`,
714766# ' `link_transform_escalation_refits_required`,
715767# ' `link_transform_escalation_is_one_way`,
716768# ' `spoke_quantile_coverage_bins`,
@@ -856,6 +908,11 @@ adaptive_rank_start <- function(items,
856908# ' \code{item_log}. Controller behavior can change after refits via
857909# ' identifiability-gated settings in \code{adaptive_config}; those controls
858910# ' affect pair routing and quotas, while BTL remains inference-only.
911+ # ' If \code{adaptive_config$max_pairs_after_stop > 0}, the run records a stop
912+ # ' boundary at the first refit with \code{stop_decision = TRUE} and allows at
913+ # ' most that many additional committed comparisons before deterministic
914+ # ' termination. Round logs record
915+ # ' \code{max_pairs_after_stop} and \code{pairs_committed_after_stop}.
859916# '
860917# ' @param state An adaptive state object created by [adaptive_rank_start()].
861918# ' @param judge A function called as `judge(A, B, state, ...)` that returns a
@@ -880,7 +937,8 @@ adaptive_rank_start <- function(items,
880937# ' `link_identified_reliability_min`, `link_stop_reliability_min`,
881938# ' `link_rank_corr_min`, `delta_sd_max`, `delta_change_max`,
882939# ' `log_alpha_sd_max`, `log_alpha_change_max`, `cross_set_ppc_brier_max`,
883- # ' `ppc_calibration_id`, `probe_pairs_per_refit_per_spoke`,
940+ # ' `ppc_calibration_id`, `max_pairs_after_stop`,
941+ # ' `probe_pairs_per_refit_per_spoke`,
884942# ' `link_transform_escalation_refits_required`,
885943# ' `link_transform_escalation_is_one_way`,
886944# ' `spoke_quantile_coverage_bins`,
@@ -1160,6 +1218,7 @@ adaptive_rank_run_live <- function(state,
11601218 state $ config $ persist_item_log <- isTRUE(persist_item_log )
11611219 }
11621220 state <- .adaptive_apply_controller_config(state , adaptive_config = adaptive_config )
1221+ state <- .adaptive_stop_boundary_bootstrap(state )
11631222 state $ controller <- .adaptive_controller_with_phase_scope(state , controller = .adaptive_controller_resolve(state ))
11641223 state <- .adaptive_phase_a_prepare(state )
11651224 state <- .adaptive_phase_a_finalize_if_ready(state )
@@ -1183,6 +1242,7 @@ adaptive_rank_run_live <- function(state,
11831242
11841243 remaining <- n_steps
11851244 while (remaining > 0L ) {
1245+ state <- .adaptive_stop_boundary_bootstrap(state )
11861246 state <- .adaptive_phase_a_prepare(state )
11871247 state <- .adaptive_phase_a_finalize_if_ready(state )
11881248 .adaptive_phase_a_gate_or_abort(state )
@@ -1194,6 +1254,15 @@ adaptive_rank_run_live <- function(state,
11941254 }
11951255 return (state )
11961256 }
1257+ budget_status <- .adaptive_stop_boundary_budget_status(state )
1258+ if (isTRUE(budget_status $ active ) && isTRUE(budget_status $ exhausted )) {
1259+ state $ meta $ stop_decision <- TRUE
1260+ state $ meta $ stop_reason <- " max_pairs_after_stop_exhausted"
1261+ if (! is.null(state $ config $ session_dir )) {
1262+ save_adaptive_session(state , session_dir = state $ config $ session_dir , overwrite = TRUE )
1263+ }
1264+ return (state )
1265+ }
11971266 state <- .adaptive_link_sync_warm_start(state )
11981267 state <- .adaptive_round_activate_if_ready(state )
11991268 state <- run_one_step(state , judge , ... )
@@ -1208,6 +1277,16 @@ adaptive_rank_run_live <- function(state,
12081277 } else {
12091278 state <- .adaptive_round_commit(state , step_row )
12101279 }
1280+ budget_status <- .adaptive_stop_boundary_budget_status(state )
1281+ if (isTRUE(budget_status $ active )) {
1282+ state $ meta $ pairs_committed_after_stop <- as.integer(
1283+ state $ meta $ pairs_committed_after_stop %|| % 0L
1284+ ) + 1L
1285+ budget_status <- .adaptive_stop_boundary_budget_status(state )
1286+ if (isTRUE(budget_status $ exhausted )) {
1287+ state $ meta $ stop_reason <- " max_pairs_after_stop_exhausted"
1288+ }
1289+ }
12111290 } else if (isTRUE(step_row $ candidate_starved [[1L ]]) &&
12121291 ! identical(step_row $ round_stage [[1L ]], " warm_start" )) {
12131292 starve <- .adaptive_round_starvation(state , step_row )
@@ -1313,12 +1392,31 @@ adaptive_rank_run_live <- function(state,
13131392 }
13141393 }
13151394 if (isTRUE(stop_decision )) {
1316- state $ meta $ stop_decision <- TRUE
1317- state $ meta $ stop_reason <- stop_reason
1318- if (! is.null(state $ config $ session_dir )) {
1319- save_adaptive_session(state , session_dir = state $ config $ session_dir , overwrite = TRUE )
1395+ round_row_tbl <- tibble :: as_tibble(round_row )
1396+ boundary_refit_id <- if (" refit_id" %in% names(round_row_tbl )) {
1397+ as.integer(round_row_tbl $ refit_id [[1L ]] %|| % NA_integer_ )
1398+ } else {
1399+ NA_integer_
1400+ }
1401+ boundary_step_id <- if (" step_id_at_refit" %in% names(round_row_tbl )) {
1402+ as.integer(round_row_tbl $ step_id_at_refit [[1L ]] %|| % NA_integer_ )
1403+ } else {
1404+ NA_integer_
1405+ }
1406+ if (is.na(as.integer(state $ meta $ stop_boundary_step_id %|| % NA_integer_ ))) {
1407+ state $ meta $ stop_boundary_refit_id <- boundary_refit_id
1408+ state $ meta $ stop_boundary_step_id <- boundary_step_id
1409+ state $ meta $ pairs_committed_after_stop <- 0L
1410+ }
1411+ budget_status <- .adaptive_stop_boundary_budget_status(state )
1412+ if (budget_status $ max_pairs_after_stop < = 0L ) {
1413+ state $ meta $ stop_decision <- TRUE
1414+ state $ meta $ stop_reason <- stop_reason
1415+ if (! is.null(state $ config $ session_dir )) {
1416+ save_adaptive_session(state , session_dir = state $ config $ session_dir , overwrite = TRUE )
1417+ }
1418+ return (state )
13201419 }
1321- return (state )
13221420 }
13231421 if (isTRUE(.adaptive_link_all_spokes_stopped(state ))) {
13241422 state $ meta $ stop_decision <- TRUE
0 commit comments