diff --git a/R/adaptive_btl_refit.R b/R/adaptive_btl_refit.R index b17c3e56..de6257fa 100644 --- a/R/adaptive_btl_refit.R +++ b/R/adaptive_btl_refit.R @@ -933,6 +933,8 @@ hub_theta, spoke_theta, transform_mode) { + # Linking transform parameters are estimated via MAP optimization with + # Hessian-based (Laplace-style) uncertainty approximation. use_scale <- identical(transform_mode, "shift_scale") edge_attrs <- attributes(cross_edges) refit_contract_ctx <- edge_attrs$refit_contract %||% list() @@ -967,6 +969,8 @@ ) empty$fit_contract <- list( contract_type = "link_refit", + estimation_method = "map_optim", + uncertainty_approximation = "hessian_laplace", link_refit_mode = as.character(link_refit_mode), link_transform_mode = as.character(transform_mode), parameters = if (isTRUE(use_scale)) c("delta_s", "log_alpha_s") else c("delta_s"), @@ -1191,6 +1195,8 @@ fit_contract <- list( contract_type = "link_refit", + estimation_method = "map_optim", + uncertainty_approximation = "hessian_laplace", link_refit_mode = as.character(link_refit_mode), link_transform_mode = as.character(transform_mode), parameters = if (isTRUE(joint_used)) { diff --git a/R/adaptive_round_candidates.R b/R/adaptive_round_candidates.R index 3834176f..75206d7b 100644 --- a/R/adaptive_round_candidates.R +++ b/R/adaptive_round_candidates.R @@ -628,7 +628,8 @@ generate_stage_candidates_from_state <- function(state, #' @keywords internal #' @noRd -.adaptive_linking_selection_order <- function(candidates) { +.adaptive_linking_selection_order <- function(candidates, + utility_mode = "linking_cross_set_p_times_1_minus_p") { cand <- tibble::as_tibble(candidates) if (nrow(cand) == 0L) { return(integer()) @@ -640,12 +641,21 @@ generate_stage_candidates_from_state <- function(state, idx <- coverage_idx } } - # Linking ordering priority is predictive cross-set utility; candidate - # generation/filtering invariants remain upstream in the canonical pipeline. - utility <- if ("link_u" %in% names(cand)) { - as.double(cand$link_u[idx]) + # Linking ordering priority is resolver-selected utility. If all values are + # non-finite, fall back deterministically to U0, then lexical tie-break. + utility_col <- .adaptive_resolve_selection_column(utility_mode) + utility <- if (!is.na(utility_col) && utility_col %in% names(cand)) { + as.double(cand[[utility_col]][idx]) } else { - as.double(cand$u0[idx]) + rep_len(NA_real_, length(idx)) + } + if (!any(is.finite(utility))) { + fallback <- if ("u0" %in% names(cand)) as.double(cand$u0[idx]) else rep_len(NA_real_, length(idx)) + if (!any(is.finite(fallback))) { + return(idx[order(cand$i[idx], cand$j[idx])]) + } + fallback[!is.finite(fallback)] <- -Inf + return(idx[order(-fallback, cand$i[idx], cand$j[idx])]) } utility[!is.finite(utility)] <- -Inf idx[order(-utility, cand$i[idx], cand$j[idx])] diff --git a/R/adaptive_run.R b/R/adaptive_run.R index 307bec4a..e01a4ac5 100644 --- a/R/adaptive_run.R +++ b/R/adaptive_run.R @@ -548,9 +548,10 @@ #' Within-set routing uses TrueSkill base utility #' \deqn{U_0 = p_{ij}(1 - p_{ij})} where \eqn{p_{ij}} is the current TrueSkill #' win probability for pair \eqn{\{i, j\}}. -#' In linking Phase B, pair choice remains TrueSkill-based and never uses BTL -#' posterior quantities. Model-implied predictive probabilities/utility are -#' logged for diagnostics only and do not affect selection. +#' In linking Phase B, eligible cross-set candidates are ranked by +#' \eqn{p_{hx}(1-p_{hx})} under the current linking transform and judge +#' parameters. Linking inference parameters are used for +#' inference/diagnostics/stopping, not as direct selection objectives. #' When \code{judge_param_mode = "phase_specific"}, the first Phase B startup #' step may use deterministic fallback from available within/shared judge #' estimates if link-specific estimates are not yet available; once link-specific @@ -597,7 +598,7 @@ #' `link_transform_escalation_is_one_way`, #' `spoke_quantile_coverage_bins`, #' `spoke_quantile_coverage_min_per_bin_per_refit`, `multi_spoke_mode`, -#' `min_cross_set_pairs_per_spoke_per_refit`, `cross_set_utility`, +#' `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`, #' `phase_a_compatible_config_hashes`, `phase_a_artifacts`, @@ -691,8 +692,10 @@ adaptive_rank_start <- function(items, #' Pair selection does not use BTL posterior draws. #' Within-set routing is TrueSkill-based with utility #' \deqn{U_0 = p_{ij}(1 - p_{ij})}. -#' Linking Phase B cross-set routing is also TrueSkill-based; model-implied -#' predictive probabilities/utility are recorded for diagnostics only. +#' Linking Phase B routing ranks eligible cross-set candidates by +#' \eqn{p_{hx}(1-p_{hx})} under the current linking transform and judge +#' parameters. Linking inference parameters remain inference-only +#' (diagnostics and stopping) and are not direct pair-selection objectives. #' When \code{judge_param_mode = "phase_specific"}, startup can use deterministic #' fallback from within/shared judge estimates only until link-specific estimates #' are expected, after which malformed link estimates abort. @@ -758,7 +761,7 @@ adaptive_rank_start <- function(items, #' `link_transform_escalation_is_one_way`, #' `spoke_quantile_coverage_bins`, #' `spoke_quantile_coverage_min_per_bin_per_refit`, `multi_spoke_mode`, -#' `min_cross_set_pairs_per_spoke_per_refit`, `cross_set_utility`, +#' `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`, #' `phase_a_compatible_config_hashes`, `phase_a_artifacts`, and diff --git a/R/adaptive_select.R b/R/adaptive_select.R index bed780cf..2ccd270b 100644 --- a/R/adaptive_select.R +++ b/R/adaptive_select.R @@ -349,6 +349,35 @@ adaptive_defaults <- function(N) { ) } +.adaptive_selection_mode_is_linking <- function(run_mode, is_cross_set = FALSE) { + as.character(run_mode %||% "within_set") %in% c("link_one_spoke", "link_multi_spoke") && + isTRUE(is_cross_set) +} + +.adaptive_selection_utility_mode <- function(run_mode, has_regularization = FALSE, is_cross_set = FALSE) { + if (.adaptive_selection_mode_is_linking(run_mode = run_mode, is_cross_set = is_cross_set)) { + return("linking_cross_set_p_times_1_minus_p") + } + if (isTRUE(has_regularization)) { + return("pairing_trueskill_u") + } + "pairing_trueskill_u0" +} + +.adaptive_resolve_selection_column <- function(utility_mode) { + mode <- as.character(utility_mode %||% NA_character_) + if (identical(mode, "pairing_trueskill_u0")) { + return("u0") + } + if (identical(mode, "pairing_trueskill_u")) { + return("u") + } + if (identical(mode, "linking_cross_set_p_times_1_minus_p")) { + return("link_u") + } + NA_character_ +} + .adaptive_local_priority_select <- function(cand, state, round, stage_committed_so_far, stage_quota, defaults) { if (nrow(cand) == 0L) { return(list(candidates = cand, mode = "standard")) @@ -1143,13 +1172,46 @@ select_next_pair <- function(state, step_id = NULL, candidates = NULL) { } else { stage_local_priority_mode <- NA_character_ } - if (isTRUE(is_link_mode)) { + has_regularized_utility <- "u" %in% names(cand) && + "u0" %in% names(cand) && + any( + is.finite(as.double(cand$u)) & + is.finite(as.double(cand$u0)) & + abs(as.double(cand$u) - as.double(cand$u0)) > sqrt(.Machine$double.eps), + na.rm = TRUE + ) + selected_utility_mode <- .adaptive_selection_utility_mode( + run_mode = controller$run_mode, + has_regularization = isTRUE(has_regularized_utility), + is_cross_set = isTRUE(is_link_mode) && isTRUE(link_phase_b) + ) + if (isTRUE(is_link_mode) && isTRUE(link_phase_b)) { # Linking mode keeps canonical candidate generation/filtering via # TrueSkill and hard invariants; this call only applies the # linking-specific final ordering priority. - order_idx <- .adaptive_linking_selection_order(cand) + order_idx <- .adaptive_linking_selection_order( + cand, + utility_mode = selected_utility_mode + ) } else { - order_idx <- order(-cand$u0, cand$i, cand$j) + utility_col <- .adaptive_resolve_selection_column(selected_utility_mode) + utility <- if (!is.na(utility_col) && utility_col %in% names(cand)) { + as.double(cand[[utility_col]]) + } else { + rep_len(NA_real_, nrow(cand)) + } + if (!any(is.finite(utility))) { + tie_utility <- if ("u0" %in% names(cand)) as.double(cand$u0) else rep_len(NA_real_, nrow(cand)) + if (any(is.finite(tie_utility))) { + tie_utility[!is.finite(tie_utility)] <- -Inf + order_idx <- order(-tie_utility, cand$i, cand$j) + } else { + order_idx <- order(cand$i, cand$j) + } + } else { + utility[!is.finite(utility)] <- -Inf + order_idx <- order(-utility, cand$i, cand$j) + } } selected_pair <- cand[order_idx[[1L]], , drop = FALSE] } @@ -1263,10 +1325,32 @@ select_next_pair <- function(state, step_id = NULL, candidates = NULL) { if (is.na(selected_spoke_id) && !is.na(selected_link_spoke_attempt)) { selected_spoke_id <- as.integer(selected_link_spoke_attempt) } + set_map <- stats::setNames(as.integer(state$items$set_id), as.character(state$items$item_id)) + set_i_selected <- as.integer(set_map[[i_id]] %||% NA_integer_) + set_j_selected <- as.integer(set_map[[j_id]] %||% NA_integer_) + selected_is_cross_set <- !is.na(set_i_selected) && !is.na(set_j_selected) && set_i_selected != set_j_selected + if (isTRUE(selected_is_cross_set) && is.na(selected_spoke_id) && isTRUE(is_link_mode)) { + hub_id <- as.integer(link_controller$hub_id %||% 1L) + if (identical(set_i_selected, hub_id)) { + selected_spoke_id <- set_j_selected + } else if (identical(set_j_selected, hub_id)) { + selected_spoke_id <- set_i_selected + } + } A_id <- as.character(order_vals[["A_id"]] %||% NA_character_) B_id <- as.character(order_vals[["B_id"]] %||% NA_character_) p_ij_ts <- trueskill_win_probability(A_id, B_id, state$trueskill_state) p_ij <- as.double(p_ij_ts) + has_regularized_utility <- "u" %in% names(selected_pair) && + "u0" %in% names(selected_pair) && + is.finite(as.double(selected_pair$u[[1L]])) && + is.finite(as.double(selected_pair$u0[[1L]])) && + abs(as.double(selected_pair$u[[1L]]) - as.double(selected_pair$u0[[1L]])) > sqrt(.Machine$double.eps) + utility_mode <- .adaptive_selection_utility_mode( + run_mode = controller$run_mode, + has_regularization = isTRUE(has_regularized_utility), + is_cross_set = isTRUE(selected_is_cross_set) + ) if (isTRUE(is_link_mode) && !is.na(selected_spoke_id)) { p_link_oriented <- .adaptive_link_predictive_prob_oriented( state = state, @@ -1330,6 +1414,7 @@ select_next_pair <- function(state, step_id = NULL, candidates = NULL) { sigma_j = as.double(sigma_vals[[j_id]]), p_ij = as.double(p_ij), U0_ij = as.double(u0_ij), + utility_mode = as.character(utility_mode), star_cap_rejects = as.integer(last_star_caps$rejects %||% 0L), star_cap_reject_items = as.integer(last_star_caps$reject_items_count %||% 0L) ) diff --git a/R/adaptive_state.R b/R/adaptive_state.R index 5df3b0fc..f25ca642 100644 --- a/R/adaptive_state.R +++ b/R/adaptive_state.R @@ -109,7 +109,7 @@ spoke_quantile_coverage_min_per_bin_per_refit = 1L, multi_spoke_mode = "independent", min_cross_set_pairs_per_spoke_per_refit = 5L, - cross_set_utility = "p_times_1_minus_p", + cross_set_utility = "linking_cross_set_p_times_1_minus_p", phase_a_mode = "run", phase_a_import_failure_policy = "fail_fast", phase_a_required_reliability_min = 0.80, @@ -328,7 +328,13 @@ 1L, Inf ) - out$cross_set_utility <- read_choice("cross_set_utility", "p_times_1_minus_p") + out$cross_set_utility <- read_choice( + "cross_set_utility", + c("linking_cross_set_p_times_1_minus_p", "p_times_1_minus_p") + ) + if (identical(out$cross_set_utility, "p_times_1_minus_p")) { + out$cross_set_utility <- "linking_cross_set_p_times_1_minus_p" + } out$phase_a_mode <- read_choice("phase_a_mode", c("run", "import", "mixed")) out$phase_a_import_failure_policy <- read_choice( "phase_a_import_failure_policy", diff --git a/R/adaptive_step.R b/R/adaptive_step.R index 93bc2bd9..42bc9cbb 100644 --- a/R/adaptive_step.R +++ b/R/adaptive_step.R @@ -103,6 +103,16 @@ validate_judge_result <- function(result, A_id, B_id) { idx_map <- state$item_index %||% stats::setNames(seq_along(state$item_ids), state$item_ids) recent_deg <- .adaptive_recent_deg(history, state$item_ids, adaptive_defaults(length(state$item_ids))$W_cap) defaults <- adaptive_defaults(length(state$item_ids)) + controller <- .adaptive_controller_resolve(state) + run_mode <- as.character(controller$run_mode %||% "within_set") + set_i <- as.integer(state$items$set_id[[idx_map[[i_id]]]]) + set_j <- as.integer(state$items$set_id[[idx_map[[j_id]]]]) + is_cross_set <- !is.na(set_i) && !is.na(set_j) && set_i != set_j + utility_mode <- .adaptive_selection_utility_mode( + run_mode = run_mode, + has_regularization = FALSE, + is_cross_set = isTRUE(is_cross_set) + ) list( i = as.integer(idx_map[[i_id]]), @@ -149,6 +159,7 @@ validate_judge_result <- function(result, A_id, B_id) { sigma_j = as.double(sigma_vals[[j_id]]), p_ij = as.double(p_ij), U0_ij = as.double(u0_ij), + utility_mode = as.character(utility_mode), star_cap_rejects = 0L, star_cap_reject_items = 0L ) @@ -214,12 +225,34 @@ validate_judge_result <- function(result, A_id, B_id) { "." )) } + valid_utility_modes <- c( + "pairing_trueskill_u0", + "pairing_trueskill_u", + "linking_cross_set_p_times_1_minus_p" + ) + utility_mode <- if ("utility_mode" %in% names(row)) { + as.character(row$utility_mode[[1L]] %||% NA_character_) + } else { + NA_character_ + } + if (!is.na(utility_mode) && !utility_mode %in% valid_utility_modes) { + rlang::abort( + paste0( + "step_log append completeness failure: `utility_mode` must be one of: ", + paste(valid_utility_modes, collapse = ", "), + ", or NA." + ) + ) + } + run_mode <- as.character(row$run_mode[[1L]] %||% "within_set") + is_link_run_mode <- run_mode %in% c("link_one_spoke", "link_multi_spoke") is_cross <- row$is_cross_set[[1L]] if (isTRUE(is_cross)) { - required_cross <- c( - "set_i", "set_j", "link_spoke_id", "run_mode", "posterior_win_prob_pre", "cross_set_utility_pre" - ) + required_cross <- c("set_i", "set_j", "link_spoke_id", "run_mode", "posterior_win_prob_pre") + if (isTRUE(is_link_run_mode)) { + required_cross <- c(required_cross, "cross_set_utility_pre") + } bad <- required_cross[vapply(required_cross, function(col) is.na(row[[col]][[1L]]), logical(1L))] if (length(bad) > 0L) { rlang::abort(paste0( @@ -234,6 +267,14 @@ validate_judge_result <- function(result, A_id, B_id) { "step_log append completeness failure for cross-set row: `link_stage` must be populated for stage-routed steps." ) } + if (isTRUE(is_link_run_mode) && !identical(utility_mode, "linking_cross_set_p_times_1_minus_p")) { + rlang::abort( + paste0( + "step_log append completeness failure for cross-set row: ", + "`utility_mode` must be linking_cross_set_p_times_1_minus_p." + ) + ) + } } else if (isFALSE(is_cross)) { if (!is.na(row$link_spoke_id[[1L]])) { rlang::abort( @@ -247,7 +288,6 @@ validate_judge_result <- function(result, A_id, B_id) { "posterior_win_prob_pre", "link_transform_mode", "cross_set_utility_pre", - "utility_mode", "log_alpha_spoke_estimate_pre", "log_alpha_spoke_sd_pre", "hub_lock_mode", @@ -261,6 +301,13 @@ validate_judge_result <- function(result, A_id, B_id) { "." )) } + if (isTRUE(is_link_run_mode) && + !is.na(utility_mode) && + !utility_mode %in% c("pairing_trueskill_u0", "pairing_trueskill_u")) { + rlang::abort( + "step_log append completeness failure: non-cross-set rows in linking runs must use pairing utility mode or NA." + ) + } } invisible(TRUE) @@ -376,7 +423,7 @@ run_one_step <- function(state, judge, ...) { run_mode <- as.character(controller$run_mode %||% "within_set") hub_id <- as.integer(controller$hub_id %||% 1L) link_transform_mode <- as.character(controller$link_transform_mode %||% NA_character_) - utility_mode <- as.character(controller$cross_set_utility %||% NA_character_) + utility_mode <- as.character(selection$utility_mode %||% NA_character_) hub_lock_mode <- as.character(controller$hub_lock_mode %||% NA_character_) hub_lock_kappa <- as.double(controller$hub_lock_kappa %||% NA_real_) set_i <- if (!is.na(selection$i)) { @@ -418,7 +465,10 @@ run_one_step <- function(state, judge, ...) { } else { NA_character_ } - cross_set_utility_pre <- if (isTRUE(is_cross_set)) { + is_link_run_mode <- run_mode %in% c("link_one_spoke", "link_multi_spoke") + cross_set_utility_pre <- if (isTRUE(is_cross_set) && + isTRUE(is_link_run_mode) && + identical(utility_mode, "linking_cross_set_p_times_1_minus_p")) { as.double(selection$U0_ij %||% NA_real_) } else { NA_real_ @@ -443,10 +493,8 @@ run_one_step <- function(state, judge, ...) { } else { NA_character_ } - utility_mode <- if (isTRUE(is_cross_set)) { - utility_mode - } else { - NA_character_ + if (!is.character(utility_mode) || length(utility_mode) != 1L || is.na(utility_mode) || utility_mode == "") { + utility_mode <- NA_character_ } log_alpha_spoke_estimate_pre <- if (isTRUE(is_cross_set)) { as.double(spoke_stats$log_alpha_spoke_mean %||% NA_real_) diff --git a/man/adaptive_rank_run_live.Rd b/man/adaptive_rank_run_live.Rd index 675edcd5..0cc9b3da 100644 --- a/man/adaptive_rank_run_live.Rd +++ b/man/adaptive_rank_run_live.Rd @@ -52,7 +52,7 @@ behavior. Supported fields: \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{min_cross_set_pairs_per_spoke_per_refit}, \code{cross_set_utility}, +\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}, \code{phase_a_compatible_config_hashes}, \code{phase_a_artifacts}, and @@ -114,8 +114,10 @@ Invalid responses produce a logged step with Pair selection does not use BTL posterior draws. Within-set routing is TrueSkill-based with utility \deqn{U_0 = p_{ij}(1 - p_{ij})}. -Linking Phase B cross-set routing is also TrueSkill-based; model-implied -predictive probabilities/utility are recorded for diagnostics only. +Linking Phase B routing ranks eligible cross-set candidates by +\eqn{p_{hx}(1-p_{hx})} under the current linking transform and judge +parameters. Linking inference parameters remain inference-only +(diagnostics and stopping) and are not direct pair-selection objectives. When \code{judge_param_mode = "phase_specific"}, startup can use deterministic fallback from within/shared judge estimates only until link-specific estimates are expected, after which malformed link estimates abort. diff --git a/man/adaptive_rank_start.Rd b/man/adaptive_rank_start.Rd index d4eb4e3b..aebaf48d 100644 --- a/man/adaptive_rank_start.Rd +++ b/man/adaptive_rank_start.Rd @@ -53,7 +53,7 @@ inputs and valid hub assignment.} \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{min_cross_set_pairs_per_spoke_per_refit}, \code{cross_set_utility}, +\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}, \code{phase_a_compatible_config_hashes}, \code{phase_a_artifacts}, @@ -82,9 +82,10 @@ Pair selection in this framework is stepwise and uncertainty-aware. Within-set routing uses TrueSkill base utility \deqn{U_0 = p_{ij}(1 - p_{ij})} where \eqn{p_{ij}} is the current TrueSkill win probability for pair \eqn{\{i, j\}}. -In linking Phase B, pair choice remains TrueSkill-based and never uses BTL -posterior quantities. Model-implied predictive probabilities/utility are -logged for diagnostics only and do not affect selection. +In linking Phase B, eligible cross-set candidates are ranked by +\eqn{p_{hx}(1-p_{hx})} under the current linking transform and judge +parameters. Linking inference parameters are used for +inference/diagnostics/stopping, not as direct selection objectives. When \code{judge_param_mode = "phase_specific"}, the first Phase B startup step may use deterministic fallback from available within/shared judge estimates if link-specific estimates are not yet available; once link-specific diff --git a/tests/testthat/test-5011-run-one-step-transactional.R b/tests/testthat/test-5011-run-one-step-transactional.R index 5ab45439..3f7a8f20 100644 --- a/tests/testthat/test-5011-run-one-step-transactional.R +++ b/tests/testthat/test-5011-run-one-step-transactional.R @@ -11,6 +11,7 @@ test_that("run_one_step commits valid results transactionally", { expect_equal(nrow(out$step_log), 1L) expect_equal(out$step_log$status[[1L]], "ok") + expect_equal(out$step_log$utility_mode[[1L]], "pairing_trueskill_u0") expect_false(is.na(out$step_log$pair_id[[1L]])) expect_equal(out$step_log$Y[[1L]], 1L) expect_false(isTRUE(all.equal(before_mu, out$trueskill_state$items$mu))) @@ -108,7 +109,7 @@ test_that("run_one_step populates linking scaffold columns for cross-set rows", expect_equal(row$link_spoke_id[[1L]], 2L) expect_equal(row$run_mode[[1L]], "link_one_spoke") expect_equal(row$link_transform_mode[[1L]], "shift_only") - expect_equal(row$utility_mode[[1L]], "p_times_1_minus_p") + expect_equal(row$utility_mode[[1L]], "linking_cross_set_p_times_1_minus_p") expect_equal(row$hub_lock_mode[[1L]], "soft_lock") expect_equal(row$hub_lock_kappa[[1L]], 0.75) expect_false(is.na(row$posterior_win_prob_pre[[1L]])) diff --git a/tests/testthat/test-5048-linking-phase-a-artifacts.R b/tests/testthat/test-5048-linking-phase-a-artifacts.R index 80ea3a91..53d3fd2a 100644 --- a/tests/testthat/test-5048-linking-phase-a-artifacts.R +++ b/tests/testthat/test-5048-linking-phase-a-artifacts.R @@ -278,7 +278,7 @@ test_that("phase_a_mode=run executes Phase A within-set steps before Phase B", { expect_true(all(is.na(out$step_log$posterior_win_prob_pre))) expect_true(all(is.na(out$step_log$link_transform_mode))) expect_true(all(is.na(out$step_log$cross_set_utility_pre))) - expect_true(all(is.na(out$step_log$utility_mode))) + expect_true(all(out$step_log$utility_mode %in% c("pairing_trueskill_u0", "pairing_trueskill_u"))) expect_true(all(is.na(out$step_log$log_alpha_spoke_estimate_pre))) expect_true(all(is.na(out$step_log$log_alpha_spoke_sd_pre))) expect_true(all(is.na(out$step_log$hub_lock_mode))) diff --git a/tests/testthat/test-5049-linking-candidates-round-routing.R b/tests/testthat/test-5049-linking-candidates-round-routing.R index e1a57192..598ee944 100644 --- a/tests/testthat/test-5049-linking-candidates-round-routing.R +++ b/tests/testthat/test-5049-linking-candidates-round-routing.R @@ -659,7 +659,7 @@ test_that("cross_set_utility_pre logs p*(1-p) before commit in linking mode", { row <- out$step_log[nrow(out$step_log), , drop = FALSE] expected <- row$posterior_win_prob_pre[[1L]] * (1 - row$posterior_win_prob_pre[[1L]]) - expect_equal(row$utility_mode[[1L]], "p_times_1_minus_p") + expect_equal(row$utility_mode[[1L]], "linking_cross_set_p_times_1_minus_p") expect_equal(row$cross_set_utility_pre[[1L]], expected, tolerance = 1e-12) }) @@ -674,6 +674,38 @@ test_that("cross-set ordering uses linking utility when predictive utility diffe expect_identical(ord[[1L]], 2L) }) +test_that("pairing ordering ignores linking utility fields", { + cand <- tibble::tibble( + i = c("a", "b"), + j = c("c", "d"), + u0 = c(0.24, 0.26), + u = c(0.24, 0.26), + link_u = c(0.90, 0.10) + ) + utility_mode <- pairwiseLLM:::.adaptive_selection_utility_mode( + run_mode = "within_set", + has_regularization = FALSE, + is_cross_set = FALSE + ) + utility_col <- pairwiseLLM:::.adaptive_resolve_selection_column(utility_mode) + ord <- order(-as.double(cand[[utility_col]]), cand$i, cand$j) + expect_identical(ord[[1L]], 2L) +}) + +test_that("linking deterministic ordering falls back when linking utility is fully non-finite", { + cand <- tibble::tibble( + i = c("a", "b", "c"), + j = c("d", "e", "f"), + u0 = c(0.20, 0.30, 0.30), + link_u = c(NA_real_, NaN, Inf) + ) + ord <- pairwiseLLM:::.adaptive_linking_selection_order( + cand, + utility_mode = "linking_cross_set_p_times_1_minus_p" + ) + expect_identical(ord, c(2L, 3L, 1L)) +}) + test_that("stopped spokes are excluded from phase B routing/candidate generation", { items <- tibble::tibble( item_id = as.character(1:9), @@ -705,6 +737,55 @@ test_that("stopped spokes are excluded from phase B routing/candidate generation expect_true(all(spoke_set == 3L)) }) +test_that("independent spoke stage progress is computed per spoke without shared coupling", { + items <- tibble::tibble( + item_id = c("h1", "h2", "h3", "s21", "s22", "s23", "s31", "s32", "s33"), + set_id = c(1L, 1L, 1L, 2L, 2L, 2L, 3L, 3L, 3L), + global_item_id = paste0("g", seq_len(9L)) + ) + state <- adaptive_rank_start( + items, + seed = 303L, + adaptive_config = list(run_mode = "link_multi_spoke", hub_id = 1L, multi_spoke_mode = "independent") + ) + state$warm_start_done <- TRUE + state <- mark_link_phase_b_ready(state) + judge <- make_deterministic_judge("i_wins") + + for (idx in seq_len(4L)) { + state <- pairwiseLLM:::run_one_step(state, judge) + state <- pairwiseLLM:::.adaptive_round_commit(state, state$step_log[nrow(state$step_log), , drop = FALSE]) + } + + quotas_2 <- pairwiseLLM:::.adaptive_round_compute_quotas( + round_id = 1L, + n_items = nrow(items), + controller = utils::modifyList(state$controller, list(current_link_spoke_id = 2L)) + ) + quotas_3 <- pairwiseLLM:::.adaptive_round_compute_quotas( + round_id = 1L, + n_items = nrow(items), + controller = utils::modifyList(state$controller, list(current_link_spoke_id = 3L)) + ) + p3_before <- pairwiseLLM:::.adaptive_link_stage_progress(state, 3L, quotas_3, pairwiseLLM:::.adaptive_stage_order()) + state2 <- state + state2$step_log <- dplyr::bind_rows( + state2$step_log, + tibble::tibble( + pair_id = 999L, + step_id = as.integer(max(as.integer(state2$step_log$step_id), na.rm = TRUE) + 1L), + is_cross_set = TRUE, + link_spoke_id = 2L, + round_stage = "anchor_link" + ) + ) + p3_after <- pairwiseLLM:::.adaptive_link_stage_progress(state2, 3L, quotas_3, pairwiseLLM:::.adaptive_stage_order()) + p2_after <- pairwiseLLM:::.adaptive_link_stage_progress(state2, 2L, quotas_2, pairwiseLLM:::.adaptive_stage_order()) + + expect_identical(p3_before$stage_committed, p3_after$stage_committed) + expect_true(any(p2_after$stage_committed >= 0L)) +}) + test_that("link stop rows update per-spoke stop state in controller metadata", { items <- tibble::tibble( item_id = as.character(1:9),