From 176bff90c4215e157b0368b0dba703ccc48a89b0 Mon Sep 17 00:00:00 2001 From: "Sterett H. Mercer" Date: Sat, 14 Feb 2026 12:32:46 -0800 Subject: [PATCH] What changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added new controller/config flag allow_spoke_spoke_cross_set (default FALSE) in R/adaptive_state.R. - Exposed the new flag in adaptive config docs: - R/adaptive_run.R - R/adaptive_rank.R - Regenerated docs: man/adaptive_rank_start.Rd, man/adaptive_rank_run_live.Rd, man/adaptive_rank.Rd - Implemented Phase B candidate branching in R/adaptive_round_candidates.R: - Default path unchanged: hub↔spoke only. - Enabled path: allows selected-spoke↔other-spoke cross-set candidates (still forbids within-set edges). - Updated linking predictive utility to support candidate endpoint sets beyond hub+single-spoke in R/ adaptive_select.R. - Updated step logging attribution in R/adaptive_step.R so cross-set rows retain link_spoke_id for selected spoke even on non-hub cross-set edges. - Tightened link_stop_eligible semantics in R/adaptive_btl_refit.R to require required gate eligibility/definedness (not just lag+diagnostics presence). Tests added/updated - tests/testthat/test-5049-linking-candidates-round-routing.R - Added enabled-policy spoke↔spoke candidate test. - Kept default hub↔spoke behavior test. - tests/testthat/test-5050-linking-refit-transforms.R - Added case where missing required gate definition forces link_stop_eligible = FALSE. - tests/testthat/test-5045-adaptive-helper-branches.R - Added config validation coverage for new flag (invalid type + valid TRUE branch). --- R/adaptive_btl_refit.R | 34 ++++-- R/adaptive_rank.R | 3 +- R/adaptive_round_candidates.R | 27 ++++- R/adaptive_run.R | 6 +- R/adaptive_select.R | 106 +++++++++--------- R/adaptive_state.R | 3 + R/adaptive_step.R | 6 + man/adaptive_rank.Rd | 3 +- man/adaptive_rank_run_live.Rd | 3 +- man/adaptive_rank_start.Rd | 3 +- .../test-5045-adaptive-helper-branches.R | 17 +++ ...st-5049-linking-candidates-round-routing.R | 37 ++++++ .../test-5050-linking-refit-transforms.R | 11 ++ 13 files changed, 187 insertions(+), 72 deletions(-) diff --git a/R/adaptive_btl_refit.R b/R/adaptive_btl_refit.R index de6257fa..007639d2 100644 --- a/R/adaptive_btl_refit.R +++ b/R/adaptive_btl_refit.R @@ -1785,29 +1785,41 @@ proxy_scores = spoke_scores ) - reliability_stop_pass <- isTRUE(stats_row$link_reliability_stop_pass %||% FALSE) - delta_sd_pass <- isTRUE(stats_row$delta_sd_pass %||% FALSE) - log_alpha_sd_pass <- is.na(stats_row$log_alpha_sd_pass %||% NA) || isTRUE(stats_row$log_alpha_sd_pass) + transform_mode <- as.character(stats_row$link_transform_mode %||% + .adaptive_link_transform_mode_for_spoke(controller, spoke_id)) + reliability_stop_pass <- as.logical(stats_row$link_reliability_stop_pass %||% NA) + delta_sd_pass <- as.logical(stats_row$delta_sd_pass %||% NA) + log_alpha_sd_pass <- as.logical(stats_row$log_alpha_sd_pass %||% NA) lag_eligible <- isTRUE(stats_row$lag_eligible %||% FALSE) - delta_change_pass <- isTRUE(stats_row$delta_change_pass %||% FALSE) - log_alpha_change_pass <- is.na(stats_row$log_alpha_change_pass %||% NA) || isTRUE(stats_row$log_alpha_change_pass) - rank_stability_pass <- isTRUE(stats_row$rank_stability_pass %||% FALSE) - link_stop_eligible <- isTRUE(lag_eligible) && !is.na(diagnostics_pass) + delta_change_pass <- as.logical(stats_row$delta_change_pass %||% NA) + log_alpha_change_pass <- as.logical(stats_row$log_alpha_change_pass %||% NA) + rank_stability_pass <- as.logical(stats_row$rank_stability_pass %||% NA) + required_defined <- !is.na(diagnostics_pass) && + !is.na(reliability_stop_pass) && + !is.na(delta_sd_pass) && + !is.na(delta_change_pass) && + !is.na(rank_stability_pass) + if (identical(transform_mode, "shift_scale")) { + required_defined <- isTRUE(required_defined) && + !is.na(log_alpha_sd_pass) && + !is.na(log_alpha_change_pass) + } + # Eligibility means all required stop gates are defined at this refit. + link_stop_eligible <- isTRUE(lag_eligible) && isTRUE(required_defined) link_stop_pass <- isTRUE(link_stop_eligible) && isTRUE(diagnostics_pass) && isTRUE(reliability_stop_pass) && isTRUE(delta_sd_pass) && - isTRUE(log_alpha_sd_pass) && + (isTRUE(log_alpha_sd_pass) || identical(transform_mode, "shift_only")) && isTRUE(delta_change_pass) && - isTRUE(log_alpha_change_pass) && + (isTRUE(log_alpha_change_pass) || identical(transform_mode, "shift_only")) && isTRUE(rank_stability_pass) rows[[idx]] <- list( refit_id = as.integer(refit_id), spoke_id = as.integer(spoke_id), hub_id = as.integer(hub_id), - link_transform_mode = as.character(stats_row$link_transform_mode %||% - .adaptive_link_transform_mode_for_spoke(controller, spoke_id)), + link_transform_mode = as.character(transform_mode), link_refit_mode = as.character(controller$link_refit_mode %||% NA_character_), hub_lock_mode = as.character(controller$hub_lock_mode %||% NA_character_), hub_lock_kappa = if (identical(as.character(controller$hub_lock_mode %||% NA_character_), "soft_lock")) { diff --git a/R/adaptive_rank.R b/R/adaptive_rank.R index ccc3ef27..28508482 100644 --- a/R/adaptive_rank.R +++ b/R/adaptive_rank.R @@ -334,7 +334,8 @@ make_adaptive_judge_llm <- function( #' `boundary_frac`, `p_star_override_margin`, and #' `star_override_budget_per_round`, linking controls (`run_mode`, `hub_id`, #' `link_transform_mode`, `link_refit_mode`, `shift_only_theta_treatment`, -#' `judge_param_mode`, `hub_lock_mode`, `hub_lock_kappa`), and Phase A controls +#' `judge_param_mode`, `hub_lock_mode`, `hub_lock_kappa`, +#' `allow_spoke_spoke_cross_set`), and Phase A controls #' (`phase_a_mode`, `phase_a_import_failure_policy`, #' `phase_a_required_reliability_min`, `phase_a_compatible_model_ids`, #' `phase_a_compatible_config_hashes`, `phase_a_artifacts`, diff --git a/R/adaptive_round_candidates.R b/R/adaptive_round_candidates.R index 75206d7b..98847647 100644 --- a/R/adaptive_round_candidates.R +++ b/R/adaptive_round_candidates.R @@ -480,8 +480,10 @@ generate_stage_candidates_from_state <- function(state, "Phase metadata and routing mode disagree: no active spoke could be selected for phase_b." ) } + allow_spoke_spoke <- isTRUE(controller$allow_spoke_spoke_cross_set %||% FALSE) hub_ids <- as.character(state$items$item_id[as.integer(state$items$set_id) == hub_id]) spoke_ids <- as.character(state$items$item_id[as.integer(state$items$set_id) == spoke_id]) + active_spoke_ids <- as.character(state$items$item_id[as.integer(state$items$set_id) %in% eligible_spokes]) if (length(hub_ids) < 1L) { rlang::abort( paste0( @@ -501,7 +503,11 @@ generate_stage_candidates_from_state <- function(state, ) ) } - active_ids <- unique(c(hub_ids, spoke_ids)) + active_ids <- if (isTRUE(allow_spoke_spoke)) { + unique(c(hub_ids, active_spoke_ids)) + } else { + unique(c(hub_ids, spoke_ids)) + } if (length(active_ids) < 2L) { return(tibble::tibble(i = character(), j = character())) } @@ -555,6 +561,7 @@ generate_stage_candidates_from_state <- function(state, link_spoke_id <- integer() coverage_bins_used <- integer() coverage_source <- character() + set_map <- stats::setNames(as.integer(state$items$set_id), as.character(state$items$item_id)) for (a in seq_len(length(ids) - 1L)) { i_id <- ids[[a]] @@ -564,9 +571,17 @@ generate_stage_candidates_from_state <- function(state, dist <- abs(as.integer(stratum_map[[i_id]]) - as.integer(stratum_map[[j_id]])) if (isTRUE(link_phase_b_active)) { + i_set <- as.integer(set_map[[i_id]] %||% NA_integer_) + j_set <- as.integer(set_map[[j_id]] %||% NA_integer_) + if (is.na(i_set) || is.na(j_set) || i_set == j_set) { + next + } i_hub <- i_id %in% hub_ids j_hub <- j_id %in% hub_ids - if (!isTRUE(xor(i_hub, j_hub))) { + if (!isTRUE(allow_spoke_spoke) && !isTRUE(xor(i_hub, j_hub))) { + next + } + if (isTRUE(allow_spoke_spoke) && !isTRUE(i_set == spoke_id || j_set == spoke_id)) { next } i_anchor <- i_id %in% hub_anchor_ids @@ -596,7 +611,13 @@ generate_stage_candidates_from_state <- function(state, j_vals <- c(j_vals, j_id) dist_vals <- c(dist_vals, as.integer(dist)) if (isTRUE(link_phase_b_active)) { - spoke_item <- if (i_id %in% spoke_ids) i_id else j_id + spoke_item <- if (as.integer(set_map[[i_id]] %||% NA_integer_) == spoke_id) { + i_id + } else if (as.integer(set_map[[j_id]] %||% NA_integer_) == spoke_id) { + j_id + } else { + NA_character_ + } spoke_bin <- as.integer(coverage$bin_map[[spoke_item]] %||% NA_integer_) priority <- as.integer(!is.na(spoke_bin) && spoke_bin %in% coverage$bins_undercovered) coverage_priority <- c(coverage_priority, priority) diff --git a/R/adaptive_run.R b/R/adaptive_run.R index e01a4ac5..2a653e38 100644 --- a/R/adaptive_run.R +++ b/R/adaptive_run.R @@ -597,7 +597,8 @@ #' `link_transform_escalation_refits_required`, #' `link_transform_escalation_is_one_way`, #' `spoke_quantile_coverage_bins`, -#' `spoke_quantile_coverage_min_per_bin_per_refit`, `multi_spoke_mode`, +#' `spoke_quantile_coverage_min_per_bin_per_refit`, +#' `allow_spoke_spoke_cross_set`, `multi_spoke_mode`, #' `min_cross_set_pairs_per_spoke_per_refit`, #' `phase_a_mode`, `phase_a_import_failure_policy`, #' `phase_a_required_reliability_min`, `phase_a_compatible_model_ids`, @@ -760,7 +761,8 @@ adaptive_rank_start <- function(items, #' `link_transform_escalation_refits_required`, #' `link_transform_escalation_is_one_way`, #' `spoke_quantile_coverage_bins`, -#' `spoke_quantile_coverage_min_per_bin_per_refit`, `multi_spoke_mode`, +#' `spoke_quantile_coverage_min_per_bin_per_refit`, +#' `allow_spoke_spoke_cross_set`, `multi_spoke_mode`, #' `min_cross_set_pairs_per_spoke_per_refit`, #' `phase_a_mode`, `phase_a_import_failure_policy`, #' `phase_a_required_reliability_min`, `phase_a_compatible_model_ids`, diff --git a/R/adaptive_select.R b/R/adaptive_select.R index 2ccd270b..329d0415 100644 --- a/R/adaptive_select.R +++ b/R/adaptive_select.R @@ -455,42 +455,65 @@ adaptive_defaults <- function(N) { current_map } -.adaptive_link_attach_predictive_utility <- function(candidates, state, controller, spoke_id) { - cand <- tibble::as_tibble(candidates) - if (nrow(cand) < 1L || is.na(spoke_id)) { - return(cand) +.adaptive_link_theta_global_map_for_items <- function(state, controller, item_ids) { + ids <- unique(as.character(item_ids)) + ids <- ids[!is.na(ids)] + if (length(ids) < 1L) { + return(stats::setNames(numeric(), character())) } hub_id <- as.integer(controller$hub_id %||% 1L) - transform_mode <- .adaptive_link_transform_mode_for_spoke(controller, spoke_id) + prefer_current_theta <- identical(as.character(controller$link_refit_mode %||% "shift_only"), "joint_refit") + set_by_item <- stats::setNames(as.integer(state$items$set_id), as.character(state$items$item_id)) + set_ids <- unique(as.integer(set_by_item[ids])) + set_ids <- set_ids[!is.na(set_ids)] + if (length(set_ids) < 1L) { + return(stats::setNames(numeric(), character())) + } + link_stats <- controller$link_refit_stats_by_spoke %||% list() - stats_row <- link_stats[[as.character(spoke_id)]] %||% list() - delta <- as.double(stats_row$delta_spoke_mean %||% 0) - if (!is.finite(delta)) { - delta <- 0 + theta_global <- stats::setNames(numeric(), character()) + for (set_id in set_ids) { + theta_map <- .adaptive_link_safe_theta_map( + state, + set_id = as.integer(set_id), + prefer_current = prefer_current_theta + ) + if (length(theta_map) < 1L) { + next + } + if (!identical(as.integer(set_id), hub_id)) { + stats_row <- link_stats[[as.character(set_id)]] %||% list() + transform_mode <- .adaptive_link_transform_mode_for_spoke(controller, as.integer(set_id)) + delta <- as.double(stats_row$delta_spoke_mean %||% 0) + if (!is.finite(delta)) { + delta <- 0 + } + log_alpha <- as.double(stats_row$log_alpha_spoke_mean %||% NA_real_) + alpha <- if (identical(transform_mode, "shift_scale") && is.finite(log_alpha)) exp(log_alpha) else 1 + theta_vals <- delta + alpha * as.double(theta_map) + names(theta_vals) <- names(theta_map) + theta_map <- theta_vals + } + theta_global <- c(theta_global, theta_map) } - log_alpha <- as.double(stats_row$log_alpha_spoke_mean %||% NA_real_) - alpha <- if (identical(transform_mode, "shift_scale") && is.finite(log_alpha)) exp(log_alpha) else 1 + theta_global[!duplicated(names(theta_global))] +} - prefer_current_theta <- identical(as.character(controller$link_refit_mode %||% "shift_only"), "joint_refit") - hub_theta <- .adaptive_link_safe_theta_map( - state, - set_id = hub_id, - prefer_current = prefer_current_theta - ) - spoke_theta <- .adaptive_link_safe_theta_map( - state, - set_id = spoke_id, - prefer_current = prefer_current_theta +.adaptive_link_attach_predictive_utility <- function(candidates, state, controller, spoke_id) { + cand <- tibble::as_tibble(candidates) + if (nrow(cand) < 1L || is.na(spoke_id)) { + return(cand) + } + theta_global <- .adaptive_link_theta_global_map_for_items( + state = state, + controller = controller, + item_ids = c(as.character(cand$i), as.character(cand$j)) ) - if (length(hub_theta) < 1L || length(spoke_theta) < 1L) { + if (length(theta_global) < 2L) { cand$link_p <- NA_real_ cand$link_u <- NA_real_ return(cand) } - spoke_theta_global <- delta + alpha * as.double(spoke_theta) - names(spoke_theta_global) <- names(spoke_theta) - theta_global <- c(hub_theta, spoke_theta_global) - theta_global <- theta_global[!duplicated(names(theta_global))] startup_gap <- .adaptive_link_phase_b_startup_gap_for_spoke(state, spoke_id = as.integer(spoke_id)) judge_params <- .adaptive_link_judge_params( @@ -530,35 +553,14 @@ adaptive_defaults <- function(N) { if (is.na(spoke_id) || is.na(A_id) || is.na(B_id)) { return(NA_real_) } - hub_id <- as.integer(controller$hub_id %||% 1L) - transform_mode <- .adaptive_link_transform_mode_for_spoke(controller, spoke_id) - link_stats <- controller$link_refit_stats_by_spoke %||% list() - stats_row <- link_stats[[as.character(spoke_id)]] %||% list() - delta <- as.double(stats_row$delta_spoke_mean %||% 0) - if (!is.finite(delta)) { - delta <- 0 - } - log_alpha <- as.double(stats_row$log_alpha_spoke_mean %||% NA_real_) - alpha <- if (identical(transform_mode, "shift_scale") && is.finite(log_alpha)) exp(log_alpha) else 1 - - prefer_current_theta <- identical(as.character(controller$link_refit_mode %||% "shift_only"), "joint_refit") - hub_theta <- .adaptive_link_safe_theta_map( - state, - set_id = hub_id, - prefer_current = prefer_current_theta - ) - spoke_theta <- .adaptive_link_safe_theta_map( - state, - set_id = spoke_id, - prefer_current = prefer_current_theta + theta_global <- .adaptive_link_theta_global_map_for_items( + state = state, + controller = controller, + item_ids = c(as.character(A_id), as.character(B_id)) ) - if (length(hub_theta) < 1L || length(spoke_theta) < 1L) { + if (length(theta_global) < 2L) { return(NA_real_) } - spoke_theta_global <- delta + alpha * as.double(spoke_theta) - names(spoke_theta_global) <- names(spoke_theta) - theta_global <- c(hub_theta, spoke_theta_global) - theta_global <- theta_global[!duplicated(names(theta_global))] startup_gap <- .adaptive_link_phase_b_startup_gap_for_spoke(state, spoke_id = as.integer(spoke_id)) judge_params <- .adaptive_link_judge_params( diff --git a/R/adaptive_state.R b/R/adaptive_state.R index f25ca642..b32e4712 100644 --- a/R/adaptive_state.R +++ b/R/adaptive_state.R @@ -107,6 +107,7 @@ link_transform_escalation_is_one_way = TRUE, spoke_quantile_coverage_bins = 3L, spoke_quantile_coverage_min_per_bin_per_refit = 1L, + allow_spoke_spoke_cross_set = FALSE, multi_spoke_mode = "independent", min_cross_set_pairs_per_spoke_per_refit = 5L, cross_set_utility = "linking_cross_set_p_times_1_minus_p", @@ -172,6 +173,7 @@ "link_transform_escalation_is_one_way", "spoke_quantile_coverage_bins", "spoke_quantile_coverage_min_per_bin_per_refit", + "allow_spoke_spoke_cross_set", "multi_spoke_mode", "min_cross_set_pairs_per_spoke_per_refit", "cross_set_utility", @@ -322,6 +324,7 @@ 1L, Inf ) + out$allow_spoke_spoke_cross_set <- read_logical("allow_spoke_spoke_cross_set") out$multi_spoke_mode <- read_choice("multi_spoke_mode", c("independent", "concurrent")) out$min_cross_set_pairs_per_spoke_per_refit <- read_integer( "min_cross_set_pairs_per_spoke_per_refit", diff --git a/R/adaptive_step.R b/R/adaptive_step.R index 42bc9cbb..a4d0a5e4 100644 --- a/R/adaptive_step.R +++ b/R/adaptive_step.R @@ -452,6 +452,12 @@ run_one_step <- function(state, judge, ...) { } else { NA_integer_ } + if (isTRUE(is_cross_set) && is.na(link_spoke_id)) { + selected_spoke_id <- as.integer(selection$link_spoke_id_selected %||% NA_integer_) + if (!is.na(selected_spoke_id) && selected_spoke_id %in% c(set_i, set_j)) { + link_spoke_id <- selected_spoke_id + } + } link_stats <- controller$link_refit_stats_by_spoke %||% list() spoke_key <- as.character(link_spoke_id) spoke_stats <- if (!is.na(link_spoke_id)) link_stats[[spoke_key]] %||% list() else list() diff --git a/man/adaptive_rank.Rd b/man/adaptive_rank.Rd index d219d114..0e208204 100644 --- a/man/adaptive_rank.Rd +++ b/man/adaptive_rank.Rd @@ -88,7 +88,8 @@ controller behavior. Supported fields: \code{boundary_frac}, \code{p_star_override_margin}, and \code{star_override_budget_per_round}, linking controls (\code{run_mode}, \code{hub_id}, \code{link_transform_mode}, \code{link_refit_mode}, \code{shift_only_theta_treatment}, -\code{judge_param_mode}, \code{hub_lock_mode}, \code{hub_lock_kappa}), and Phase A controls +\code{judge_param_mode}, \code{hub_lock_mode}, \code{hub_lock_kappa}, +\code{allow_spoke_spoke_cross_set}), and Phase A controls (\code{phase_a_mode}, \code{phase_a_import_failure_policy}, \code{phase_a_required_reliability_min}, \code{phase_a_compatible_model_ids}, \code{phase_a_compatible_config_hashes}, \code{phase_a_artifacts}, diff --git a/man/adaptive_rank_run_live.Rd b/man/adaptive_rank_run_live.Rd index 0cc9b3da..0e0bd360 100644 --- a/man/adaptive_rank_run_live.Rd +++ b/man/adaptive_rank_run_live.Rd @@ -51,7 +51,8 @@ behavior. Supported fields: \code{link_transform_escalation_refits_required}, \code{link_transform_escalation_is_one_way}, \code{spoke_quantile_coverage_bins}, -\code{spoke_quantile_coverage_min_per_bin_per_refit}, \code{multi_spoke_mode}, +\code{spoke_quantile_coverage_min_per_bin_per_refit}, +\code{allow_spoke_spoke_cross_set}, \code{multi_spoke_mode}, \code{min_cross_set_pairs_per_spoke_per_refit}, \code{phase_a_mode}, \code{phase_a_import_failure_policy}, \code{phase_a_required_reliability_min}, \code{phase_a_compatible_model_ids}, diff --git a/man/adaptive_rank_start.Rd b/man/adaptive_rank_start.Rd index aebaf48d..02749348 100644 --- a/man/adaptive_rank_start.Rd +++ b/man/adaptive_rank_start.Rd @@ -52,7 +52,8 @@ inputs and valid hub assignment.} \code{link_transform_escalation_refits_required}, \code{link_transform_escalation_is_one_way}, \code{spoke_quantile_coverage_bins}, -\code{spoke_quantile_coverage_min_per_bin_per_refit}, \code{multi_spoke_mode}, +\code{spoke_quantile_coverage_min_per_bin_per_refit}, +\code{allow_spoke_spoke_cross_set}, \code{multi_spoke_mode}, \code{min_cross_set_pairs_per_spoke_per_refit}, \code{phase_a_mode}, \code{phase_a_import_failure_policy}, \code{phase_a_required_reliability_min}, \code{phase_a_compatible_model_ids}, diff --git a/tests/testthat/test-5045-adaptive-helper-branches.R b/tests/testthat/test-5045-adaptive-helper-branches.R index c453a8c2..ad401084 100644 --- a/tests/testthat/test-5045-adaptive-helper-branches.R +++ b/tests/testthat/test-5045-adaptive-helper-branches.R @@ -571,6 +571,13 @@ test_that("adaptive state and trueskill validators cover additional edge branche ), "must be one of" ) + expect_error( + pairwiseLLM:::.adaptive_validate_controller_config( + list(allow_spoke_spoke_cross_set = "yes"), + 5L + ), + "must be TRUE or FALSE" + ) expect_error( pairwiseLLM:::.adaptive_validate_controller_config( list(run_mode = "link_multi_spoke"), @@ -608,6 +615,16 @@ test_that("adaptive state and trueskill validators cover additional edge branche set_ids = c(1L, 2L, 3L) ) expect_identical(cfg_link_ok$hub_id, 1L) + cfg_spoke_spoke <- pairwiseLLM:::.adaptive_validate_controller_config( + list( + run_mode = "link_multi_spoke", + hub_id = 1L, + allow_spoke_spoke_cross_set = TRUE + ), + 5L, + set_ids = c(1L, 2L, 3L) + ) + expect_true(isTRUE(cfg_spoke_spoke$allow_spoke_spoke_cross_set)) resolved_num <- pairwiseLLM:::.adaptive_controller_resolve(5L) expect_true(is.list(resolved_num)) diff --git a/tests/testthat/test-5049-linking-candidates-round-routing.R b/tests/testthat/test-5049-linking-candidates-round-routing.R index 598ee944..d8494271 100644 --- a/tests/testthat/test-5049-linking-candidates-round-routing.R +++ b/tests/testthat/test-5049-linking-candidates-round-routing.R @@ -45,6 +45,43 @@ test_that("linking candidates are hub-spoke only by default", { expect_true(all((set_i == 1L & set_j == 2L) | (set_i == 2L & set_j == 1L))) }) +test_that("linking candidates allow selected-spoke to other-spoke edges when enabled", { + items <- tibble::tibble( + item_id = as.character(1:9), + set_id = c(rep(1L, 3L), rep(2L, 3L), rep(3L, 3L)), + global_item_id = paste0("g", 1:9) + ) + state <- adaptive_rank_start( + items, + seed = 124L, + adaptive_config = list( + run_mode = "link_multi_spoke", + hub_id = 1L, + allow_spoke_spoke_cross_set = TRUE + ) + ) + state$warm_start_done <- TRUE + state$controller$current_link_spoke_id <- 2L + state <- mark_link_phase_b_ready(state) + + cand <- pairwiseLLM:::generate_stage_candidates_from_state( + state, + stage_name = "long_link", + fallback_name = "base", + C_max = 10000L, + seed = 2L + ) + set_map <- stats::setNames(items$set_id, items$item_id) + set_i <- as.integer(set_map[cand$i]) + set_j <- as.integer(set_map[cand$j]) + spoke_spoke <- (set_i == 2L & set_j == 3L) | (set_i == 3L & set_j == 2L) + + expect_true(nrow(cand) > 0L) + expect_true(all(set_i != set_j)) + expect_true(all(set_i == 2L | set_j == 2L)) + expect_true(any(spoke_spoke)) +}) + test_that("phase B routing invariants hold across anchor/long/mid/local stages", { items <- tibble::tibble( item_id = as.character(1:9), diff --git a/tests/testthat/test-5050-linking-refit-transforms.R b/tests/testthat/test-5050-linking-refit-transforms.R index 2747cc8f..9a1c87e6 100644 --- a/tests/testthat/test-5050-linking-refit-transforms.R +++ b/tests/testthat/test-5050-linking-refit-transforms.R @@ -840,6 +840,17 @@ test_that("link stop gating enforces diagnostics and lag eligibility", { row_diag <- rows_diag[rows_diag$spoke_id == 2L, , drop = FALSE] expect_true(isTRUE(row_diag$link_stop_eligible[[1L]])) expect_false(isTRUE(row_diag$link_stop_pass[[1L]])) + + state$controller$link_refit_stats_by_spoke[["2"]]$delta_change_pass <- NA + state$round_log$diagnostics_pass[[nrow(state$round_log)]] <- TRUE + rows_missing <- pairwiseLLM:::.adaptive_link_stage_refit_rows( + state = state, + refit_id = 1L, + refit_context = list(last_refit_step = 0L) + ) + row_missing <- rows_missing[rows_missing$spoke_id == 2L, , drop = FALSE] + expect_false(isTRUE(row_missing$link_stop_eligible[[1L]])) + expect_false(isTRUE(row_missing$link_stop_pass[[1L]])) }) test_that("runtime linking_identified uses active TS-BTL rank threshold and not lagged rank stability", {