diff --git a/R/adaptive_linking_phase_a.R b/R/adaptive_linking_phase_a.R index bced8c48..3a88cea4 100644 --- a/R/adaptive_linking_phase_a.R +++ b/R/adaptive_linking_phase_a.R @@ -193,13 +193,13 @@ rank_mu_raw <- NULL if (!is.null(latest_item_log) && nrow(latest_item_log) > 0L && - all(c("item_id", "theta_raw_eap", "theta_sd") %in% names(latest_item_log))) { + all(c("item_id", "theta_raw_eap", "theta_raw_sd") %in% names(latest_item_log))) { idx <- match(ids, as.character(latest_item_log$item_id)) if (all(!is.na(idx))) { theta_mean <- as.double(latest_item_log$theta_raw_eap[idx]) - theta_sd <- as.double(latest_item_log$theta_sd[idx]) - if ("rank_scope_eap" %in% names(latest_item_log)) { - rank_mu_raw <- as.double(latest_item_log$rank_scope_eap[idx]) + theta_sd <- as.double(latest_item_log$theta_raw_sd[idx]) + if ("rank_raw" %in% names(latest_item_log)) { + rank_mu_raw <- as.double(latest_item_log$rank_raw[idx]) } } } diff --git a/R/adaptive_persist.R b/R/adaptive_persist.R index d879346e..f78278b9 100644 --- a/R/adaptive_persist.R +++ b/R/adaptive_persist.R @@ -121,17 +121,18 @@ read_log <- function(path) { .adaptive_item_log_current_schema <- function() { cols <- .adaptive_item_log_columns() int_cols <- c( - "refit_id", "set_id", "rank_scope_eap", "rank_global_eap", + "refit_id", "set_id", "phase_scope_set_id", "rank_raw", "rank_link", "degree", "pos_count_A", "pos_count_B" ) lgl_cols <- c("in_phase_scope", "is_hub_item", "is_spoke_item") + chr_cols <- c("item_id", "phase_scope") types <- vapply( cols, function(col) { if (col %in% int_cols) { return("integer") } - if (identical(col, "item_id")) { + if (col %in% chr_cols) { return("character") } if (col %in% lgl_cols) { diff --git a/R/adaptive_print.R b/R/adaptive_print.R index 664b61ed..2ea24d76 100644 --- a/R/adaptive_print.R +++ b/R/adaptive_print.R @@ -7,24 +7,27 @@ "refit_id", "item_id", "set_id", - "theta_raw_eap", - "theta_global_eap", - "theta_global_sd", + "phase_scope", + "phase_scope_set_id", "in_phase_scope", - "theta_scope_eap", - "theta_scope_sd", - "rank_scope_eap", - "rank_global_eap", "is_hub_item", "is_spoke_item", - "theta_mean", - "theta_p2.5", - "theta_p5", - "theta_p50", - "theta_p95", - "theta_p97.5", - "theta_sd", - "rank_mean", + "theta_raw_eap", + "theta_raw_p2.5", + "theta_raw_p5", + "theta_raw_p50", + "theta_raw_p95", + "theta_raw_p97.5", + "theta_raw_sd", + "rank_raw", + "theta_link_eap", + "theta_link_p2.5", + "theta_link_p5", + "theta_link_p50", + "theta_link_p95", + "theta_link_p97.5", + "theta_link_sd", + "rank_link", "degree", "pos_count_A", "pos_count_B" @@ -36,24 +39,27 @@ refit_id = integer(), item_id = character(), set_id = integer(), - theta_raw_eap = double(), - theta_global_eap = double(), - theta_global_sd = double(), + phase_scope = character(), + phase_scope_set_id = integer(), in_phase_scope = logical(), - theta_scope_eap = double(), - theta_scope_sd = double(), - rank_scope_eap = integer(), - rank_global_eap = integer(), is_hub_item = logical(), is_spoke_item = logical(), - theta_mean = double(), - theta_p2.5 = double(), - theta_p5 = double(), - theta_p50 = double(), - theta_p95 = double(), - theta_p97.5 = double(), - theta_sd = double(), - rank_mean = double(), + theta_raw_eap = double(), + theta_raw_p2.5 = double(), + theta_raw_p5 = double(), + theta_raw_p50 = double(), + theta_raw_p95 = double(), + theta_raw_p97.5 = double(), + theta_raw_sd = double(), + rank_raw = integer(), + theta_link_eap = double(), + theta_link_p2.5 = double(), + theta_link_p5 = double(), + theta_link_p50 = double(), + theta_link_p95 = double(), + theta_link_p97.5 = double(), + theta_link_sd = double(), + rank_link = integer(), degree = integer(), pos_count_A = integer(), pos_count_B = integer() @@ -61,10 +67,14 @@ } .adaptive_item_log_na_value <- function(col) { - if (col %in% c("refit_id", "set_id", "rank_global_eap", "rank_scope_eap", "degree", "pos_count_A", "pos_count_B")) { + int_cols <- c( + "refit_id", "set_id", "phase_scope_set_id", "rank_raw", "rank_link", + "degree", "pos_count_A", "pos_count_B" + ) + if (col %in% int_cols) { return(NA_integer_) } - if (identical(col, "item_id")) { + if (col %in% c("item_id", "phase_scope")) { return(NA_character_) } if (col %in% c("is_hub_item", "is_spoke_item", "in_phase_scope")) { @@ -120,52 +130,41 @@ out } -.adaptive_link_item_raw_global_summaries <- function(state, ids, set_id, theta_mean, theta_sd) { +.adaptive_link_item_raw_link_summaries <- function(state, + ids, + set_id, + theta_raw_eap, + theta_raw_sd, + theta_raw_quantiles, + is_link_phase_a = FALSE) { controller <- .adaptive_controller_resolve(state) run_mode <- as.character(controller$run_mode %||% "within_set") is_link_mode <- run_mode %in% c("link_one_spoke", "link_multi_spoke") - raw_mean <- as.double(theta_mean) - raw_sd <- as.double(theta_sd) - global_mean <- as.double(theta_mean) - global_sd <- as.double(theta_sd) + link_eap <- as.double(theta_raw_eap) + link_sd <- as.double(theta_raw_sd) + link_quantiles <- theta_raw_quantiles if (!isTRUE(is_link_mode)) { return(list( - theta_raw_eap = raw_mean, - theta_raw_sd = raw_sd, - theta_global_eap = global_mean, - theta_global_sd = global_sd + theta_link_eap = as.double(link_eap), + theta_link_sd = as.double(link_sd), + theta_link_quantiles = link_quantiles )) } - items <- state$items - item_key <- as.character(items$item_id) - global_key <- as.character(items$global_item_id) - item_to_global <- stats::setNames(global_key, item_key) - id_global <- as.character(item_to_global[ids]) - - artifacts <- (state$linking$phase_a %||% list())$artifacts %||% list() - for (spoke_key in names(artifacts)) { - artifact <- artifacts[[spoke_key]] %||% NULL - items_tbl <- tibble::as_tibble(artifact$items %||% tibble::tibble()) - if (nrow(items_tbl) < 1L || - !all(c("global_item_id", "theta_raw_mean", "theta_raw_sd") %in% names(items_tbl))) { - next - } - map_mean <- stats::setNames(as.double(items_tbl$theta_raw_mean), as.character(items_tbl$global_item_id)) - map_sd <- stats::setNames(as.double(items_tbl$theta_raw_sd), as.character(items_tbl$global_item_id)) - idx <- match(id_global, names(map_mean)) - keep <- !is.na(idx) - if (any(keep)) { - matched <- id_global[keep] - raw_mean[keep] <- as.double(map_mean[matched]) - raw_sd[keep] <- as.double(map_sd[matched]) - } + if (isTRUE(is_link_phase_a)) { + return(list( + theta_link_eap = rep_len(NA_real_, length(ids)), + theta_link_sd = rep_len(NA_real_, length(ids)), + theta_link_quantiles = matrix( + NA_real_, + nrow = nrow(theta_raw_quantiles), + ncol = ncol(theta_raw_quantiles), + dimnames = dimnames(theta_raw_quantiles) + ) + )) } - raw_sd[!is.finite(raw_sd) | raw_sd < 0] <- theta_sd[!is.finite(raw_sd) | raw_sd < 0] - global_mean <- as.double(raw_mean) - global_sd <- as.double(raw_sd) hub_id <- as.integer(controller$hub_id %||% 1L) link_stats <- controller$link_refit_stats_by_spoke %||% list() @@ -179,35 +178,38 @@ mode <- as.character(stats_row$link_transform_mode %||% .adaptive_link_transform_mode_for_spoke(controller, spoke_id)) if (!mode %in% c("shift_only", "shift_scale")) { - global_mean[spoke_idx] <- NA_real_ - global_sd[spoke_idx] <- NA_real_ + link_eap[spoke_idx] <- NA_real_ + link_sd[spoke_idx] <- NA_real_ + link_quantiles[, spoke_idx] <- NA_real_ next } delta <- as.double(stats_row$delta_spoke_mean %||% NA_real_) if (!is.finite(delta)) { - global_mean[spoke_idx] <- NA_real_ - global_sd[spoke_idx] <- NA_real_ + link_eap[spoke_idx] <- NA_real_ + link_sd[spoke_idx] <- NA_real_ + link_quantiles[, spoke_idx] <- NA_real_ next } alpha <- 1 if (identical(mode, "shift_scale")) { log_alpha <- as.double(stats_row$log_alpha_spoke_mean %||% NA_real_) if (!is.finite(log_alpha)) { - global_mean[spoke_idx] <- NA_real_ - global_sd[spoke_idx] <- NA_real_ + link_eap[spoke_idx] <- NA_real_ + link_sd[spoke_idx] <- NA_real_ + link_quantiles[, spoke_idx] <- NA_real_ next } alpha <- exp(log_alpha) } - global_mean[spoke_idx] <- as.double(delta + alpha * raw_mean[spoke_idx]) - global_sd[spoke_idx] <- as.double(abs(alpha) * raw_sd[spoke_idx]) + link_eap[spoke_idx] <- as.double(delta + alpha * theta_raw_eap[spoke_idx]) + link_sd[spoke_idx] <- as.double(abs(alpha) * theta_raw_sd[spoke_idx]) + link_quantiles[, spoke_idx] <- as.double(delta + alpha * theta_raw_quantiles[, spoke_idx, drop = FALSE]) } list( - theta_raw_eap = as.double(raw_mean), - theta_raw_sd = as.double(raw_sd), - theta_global_eap = as.double(global_mean), - theta_global_sd = as.double(global_sd) + theta_link_eap = as.double(link_eap), + theta_link_sd = as.double(link_sd), + theta_link_quantiles = link_quantiles ) } @@ -226,15 +228,13 @@ } } draw_ids <- intersect(ids, as.character(colnames(draws))) - theta_mean <- rep_len(NA_real_, length(ids)) - theta_sd <- rep_len(NA_real_, length(ids)) - names(theta_mean) <- ids - names(theta_sd) <- ids - theta_quantiles <- matrix(NA_real_, nrow = 5L, ncol = length(ids)) - rownames(theta_quantiles) <- c("q2.5", "q5", "q50", "q95", "q97.5") - colnames(theta_quantiles) <- ids - rank_mean <- rep_len(NA_real_, length(ids)) - names(rank_mean) <- ids + theta_raw_eap <- rep_len(NA_real_, length(ids)) + theta_raw_sd <- rep_len(NA_real_, length(ids)) + names(theta_raw_eap) <- ids + names(theta_raw_sd) <- ids + theta_raw_quantiles <- matrix(NA_real_, nrow = 5L, ncol = length(ids)) + rownames(theta_raw_quantiles) <- c("q2.5", "q5", "q50", "q95", "q97.5") + colnames(theta_raw_quantiles) <- ids if (length(draw_ids) > 0L) { draws <- draws[, draw_ids, drop = FALSE] @@ -248,45 +248,34 @@ function(idx) stats::quantile(draws[, idx], probs = probs, names = FALSE), numeric(length(probs)) ) - - rank_mat <- t(apply(draws, 1, function(row) rank(-row, ties.method = "average"))) - colnames(rank_mat) <- draw_ids - rank_mean_vals <- as.double(colMeans(rank_mat)) - - theta_mean[draw_ids] <- theta_mean_vals - theta_sd[draw_ids] <- theta_sd_vals - theta_quantiles[, draw_ids] <- theta_quantile_vals - rank_mean[draw_ids] <- rank_mean_vals + theta_raw_eap[draw_ids] <- theta_mean_vals + theta_raw_sd[draw_ids] <- theta_sd_vals + theta_raw_quantiles[, draw_ids] <- theta_quantile_vals } controller <- .adaptive_controller_resolve(state) phase_ctx <- .adaptive_link_phase_context(state, controller = controller) run_mode <- as.character(controller$run_mode %||% "within_set") is_link_phase_a <- run_mode %in% c("link_one_spoke", "link_multi_spoke") && !identical(as.character(phase_ctx$phase %||% "phase_a"), "phase_b") + phase_scope <- if (isTRUE(is_link_phase_a)) "phase_a_set" else "global" active_set <- as.integer(phase_ctx$active_phase_a_set %||% NA_integer_) + phase_scope_set_id <- if (isTRUE(is_link_phase_a) && is.finite(active_set)) active_set else NA_integer_ in_phase_scope <- rep_len(TRUE, length(ids)) if (isTRUE(is_link_phase_a) && is.finite(active_set)) { in_phase_scope <- as.integer(set_id) == active_set } - theta_scope_eap <- rep_len(NA_real_, length(ids)) - theta_scope_sd <- rep_len(NA_real_, length(ids)) - theta_scope_eap[in_phase_scope] <- as.double(theta_mean[in_phase_scope]) - theta_scope_sd[in_phase_scope] <- as.double(theta_sd[in_phase_scope]) - rank_scope_eap <- rep_len(NA_integer_, length(ids)) - if (sum(in_phase_scope, na.rm = TRUE) > 0L) { - scoped_order <- order(-theta_scope_eap[in_phase_scope], ids[in_phase_scope], na.last = NA) - scoped_rank <- integer(sum(in_phase_scope, na.rm = TRUE)) - scoped_rank[scoped_order] <- seq_along(scoped_order) - rank_scope_eap[in_phase_scope] <- as.integer(scoped_rank) - } - link_summary <- .adaptive_link_item_raw_global_summaries( + + link_summary <- .adaptive_link_item_raw_link_summaries( state = state, ids = ids, set_id = set_id, - theta_mean = as.double(theta_mean), - theta_sd = as.double(theta_sd) + theta_raw_eap = as.double(theta_raw_eap), + theta_raw_sd = as.double(theta_raw_sd), + theta_raw_quantiles = theta_raw_quantiles, + is_link_phase_a = is_link_phase_a ) - rank_global_eap <- as.integer(rank(-as.double(link_summary$theta_global_eap), ties.method = "first")) + rank_raw <- as.integer(rank(-as.double(theta_raw_eap), ties.method = "first")) + rank_link <- as.integer(rank(-as.double(link_summary$theta_link_eap), ties.method = "first")) hub_id <- as.integer((controller %||% list())$hub_id %||% 1L) is_hub_item <- as.logical(set_id == hub_id) is_spoke_item <- as.logical(set_id != hub_id) @@ -300,24 +289,27 @@ refit_id = as.integer(refit_id), item_id = as.character(ids), set_id = as.integer(set_id), - theta_raw_eap = as.double(link_summary$theta_raw_eap), - theta_global_eap = as.double(link_summary$theta_global_eap), - theta_global_sd = as.double(link_summary$theta_global_sd), + phase_scope = as.character(phase_scope), + phase_scope_set_id = as.integer(phase_scope_set_id), in_phase_scope = as.logical(in_phase_scope), - theta_scope_eap = as.double(theta_scope_eap), - theta_scope_sd = as.double(theta_scope_sd), - rank_scope_eap = as.integer(rank_scope_eap), - rank_global_eap = as.integer(rank_global_eap), is_hub_item = as.logical(is_hub_item), is_spoke_item = as.logical(is_spoke_item), - theta_mean = as.double(theta_mean), - theta_p2.5 = as.double(theta_quantiles[1L, ]), - theta_p5 = as.double(theta_quantiles[2L, ]), - theta_p50 = as.double(theta_quantiles[3L, ]), - theta_p95 = as.double(theta_quantiles[4L, ]), - theta_p97.5 = as.double(theta_quantiles[5L, ]), - theta_sd = as.double(theta_sd), - rank_mean = as.double(rank_mean), + theta_raw_eap = as.double(theta_raw_eap), + `theta_raw_p2.5` = as.double(theta_raw_quantiles[1L, ]), + `theta_raw_p5` = as.double(theta_raw_quantiles[2L, ]), + `theta_raw_p50` = as.double(theta_raw_quantiles[3L, ]), + `theta_raw_p95` = as.double(theta_raw_quantiles[4L, ]), + `theta_raw_p97.5` = as.double(theta_raw_quantiles[5L, ]), + theta_raw_sd = as.double(theta_raw_sd), + rank_raw = as.integer(rank_raw), + theta_link_eap = as.double(link_summary$theta_link_eap), + `theta_link_p2.5` = as.double(link_summary$theta_link_quantiles[1L, ]), + `theta_link_p5` = as.double(link_summary$theta_link_quantiles[2L, ]), + `theta_link_p50` = as.double(link_summary$theta_link_quantiles[3L, ]), + `theta_link_p95` = as.double(link_summary$theta_link_quantiles[4L, ]), + `theta_link_p97.5` = as.double(link_summary$theta_link_quantiles[5L, ]), + theta_link_sd = as.double(link_summary$theta_link_sd), + rank_link = as.integer(rank_link), degree = as.integer(degree), pos_count_A = as.integer(pos_count_A), pos_count_B = as.integer(pos_count_B) @@ -370,24 +362,27 @@ item_log$refit_id <- as.integer(item_log$refit_id) item_log$item_id <- as.character(item_log$item_id) item_log$set_id <- as.integer(item_log$set_id) - item_log$theta_raw_eap <- as.double(item_log$theta_raw_eap) - item_log$theta_global_eap <- as.double(item_log$theta_global_eap) - item_log$theta_global_sd <- as.double(item_log$theta_global_sd) + item_log$phase_scope <- as.character(item_log$phase_scope) + item_log$phase_scope_set_id <- as.integer(item_log$phase_scope_set_id) item_log$in_phase_scope <- as.logical(item_log$in_phase_scope) - item_log$theta_scope_eap <- as.double(item_log$theta_scope_eap) - item_log$theta_scope_sd <- as.double(item_log$theta_scope_sd) - item_log$rank_scope_eap <- as.integer(item_log$rank_scope_eap) - item_log$rank_global_eap <- as.integer(item_log$rank_global_eap) item_log$is_hub_item <- as.logical(item_log$is_hub_item) item_log$is_spoke_item <- as.logical(item_log$is_spoke_item) - item_log$theta_mean <- as.double(item_log$theta_mean) - item_log$theta_p2.5 <- as.double(item_log$theta_p2.5) - item_log$theta_p5 <- as.double(item_log$theta_p5) - item_log$theta_p50 <- as.double(item_log$theta_p50) - item_log$theta_p95 <- as.double(item_log$theta_p95) - item_log$theta_p97.5 <- as.double(item_log$theta_p97.5) - item_log$theta_sd <- as.double(item_log$theta_sd) - item_log$rank_mean <- as.double(item_log$rank_mean) + item_log$theta_raw_eap <- as.double(item_log$theta_raw_eap) + item_log$`theta_raw_p2.5` <- as.double(item_log$`theta_raw_p2.5`) + item_log$`theta_raw_p5` <- as.double(item_log$`theta_raw_p5`) + item_log$`theta_raw_p50` <- as.double(item_log$`theta_raw_p50`) + item_log$`theta_raw_p95` <- as.double(item_log$`theta_raw_p95`) + item_log$`theta_raw_p97.5` <- as.double(item_log$`theta_raw_p97.5`) + item_log$theta_raw_sd <- as.double(item_log$theta_raw_sd) + item_log$rank_raw <- as.integer(item_log$rank_raw) + item_log$theta_link_eap <- as.double(item_log$theta_link_eap) + item_log$`theta_link_p2.5` <- as.double(item_log$`theta_link_p2.5`) + item_log$`theta_link_p5` <- as.double(item_log$`theta_link_p5`) + item_log$`theta_link_p50` <- as.double(item_log$`theta_link_p50`) + item_log$`theta_link_p95` <- as.double(item_log$`theta_link_p95`) + item_log$`theta_link_p97.5` <- as.double(item_log$`theta_link_p97.5`) + item_log$theta_link_sd <- as.double(item_log$theta_link_sd) + item_log$rank_link <- as.integer(item_log$rank_link) item_log$degree <- as.integer(item_log$degree) item_log$pos_count_A <- as.integer(item_log$pos_count_A) item_log$pos_count_B <- as.integer(item_log$pos_count_B) @@ -619,16 +614,16 @@ adaptive_round_log <- function(state) { #' accessor can return one refit table (default: most recent) or stack all #' refits into a single tibble. #' -#' In linking mode, raw and global summaries are kept separate: +#' Item-level summaries are domain-explicit: #' \itemize{ -#' \item \code{theta_raw_eap}: within-set scale summary (from Phase A artifacts -#' when available). -#' \item \code{theta_global_eap} and \code{theta_global_sd}: global-scale -#' summaries after spoke transform application. These are typed \code{NA} -#' when required spoke transform parameters are unavailable at that refit. -#' \item \code{in_phase_scope}, \code{theta_scope_eap}, \code{theta_scope_sd}, -#' \code{rank_scope_eap}: scoped summaries for the currently optimized item -#' domain (active set during linking Phase A; all items otherwise). +#' \item \code{theta_raw_*}: raw/within-set posterior summaries (EAP, fixed +#' quantiles, SD, rank) at the current refit. +#' \item \code{theta_link_*}: linked/global posterior summaries (EAP, fixed +#' quantiles, SD, rank) after transform application. +#' \item During linking Phase A (\code{phase_scope = "phase_a_set"}), +#' \code{theta_link_*} is typed \code{NA} by design. +#' \item \code{phase_scope}, \code{phase_scope_set_id}, and +#' \code{in_phase_scope} indicate which item domain is currently optimized. #' } #' #' @param state Adaptive state. diff --git a/R/adaptive_rank.R b/R/adaptive_rank.R index b97c9da8..0c279d46 100644 --- a/R/adaptive_rank.R +++ b/R/adaptive_rank.R @@ -680,11 +680,22 @@ adaptive_rank <- function( state <- do.call(adaptive_rank_run_live, run_args) logs <- adaptive_get_logs(state) + item_sort_by <- "rank_raw" + if (length(logs$item_log) > 0L && is.data.frame(logs$item_log[[1L]])) { + item_cols <- names(logs$item_log[[1L]]) + if ("rank_link" %in% item_cols) { + item_sort_by <- "rank_link" + } else if ("rank_raw" %in% item_cols) { + item_sort_by <- "rank_raw" + } else if ("rank_mean" %in% item_cols) { + item_sort_by <- "rank_mean" + } + } out <- list( state = state, summary = summarize_adaptive(state), refits = summarize_refits(list(round_log = logs$round_log)), - items = summarize_items(list(item_log_list = logs$item_log)), + items = summarize_items(list(item_log_list = logs$item_log), sort_by = item_sort_by), logs = logs, output_file = NULL ) diff --git a/R/btl_mcmc_summaries.R b/R/btl_mcmc_summaries.R index d93803d6..5012d729 100644 --- a/R/btl_mcmc_summaries.R +++ b/R/btl_mcmc_summaries.R @@ -252,7 +252,10 @@ .adaptive_apply_sort_and_top_n <- function(summary, sort_by, top_n) { top_n <- .adaptive_summary_validate_last_n(top_n) - descending <- sort_by %in% c("theta_mean", "theta_sd", "degree", "pos_A_rate") + descending <- sort_by %in% c( + "theta_mean", "theta_sd", "degree", "pos_A_rate", + "theta_raw_eap", "theta_raw_sd", "theta_link_eap", "theta_link_sd" + ) summary <- summary |> dplyr::mutate( @@ -422,7 +425,9 @@ summarize_refits <- function(state, last_n = NULL, include_optional = TRUE) { #' @param bind Logical; when \code{TRUE}, stack all refits into a single table. #' @param top_n Optional positive integer; return only the top \code{n} rows #' after sorting. -#' @param sort_by Column used for sorting. Defaults to \code{"rank_mean"}. +#' @param sort_by Column used for sorting. When \code{NULL}, the first available +#' column in \code{c("rank_link", "rank_raw", "rank_mean", "theta_link_eap", +#' "theta_raw_eap", "theta_mean", "theta_sd", "degree", "pos_A_rate")} is used. #' @param include_optional Logical; include optional diagnostic columns. #' @return A tibble with one row per item per refit. Columns reflect the #' canonical item log schema (for example \code{refit_id}, \code{ID}, @@ -472,7 +477,7 @@ summarize_items <- function(state, refit = NULL, bind = FALSE, top_n = NULL, - sort_by = c("rank_mean", "theta_mean", "theta_sd", "degree", "pos_A_rate"), + sort_by = NULL, include_optional = TRUE) { if (!is.logical(include_optional) || length(include_optional) != 1L || @@ -484,7 +489,6 @@ summarize_items <- function(state, } top_n <- .adaptive_summary_validate_last_n(top_n) - sort_by <- match.arg(sort_by) source <- .adaptive_summary_extract_source(state) item_log_list <- NULL @@ -553,8 +557,30 @@ summarize_items <- function(state, item_log <- item_log |> dplyr::select(-dplyr::any_of(optional)) } + if (is.null(sort_by)) { + preferred <- c( + "rank_link", "rank_raw", "rank_mean", + "theta_link_eap", "theta_raw_eap", "theta_mean", + "theta_sd", "degree", "pos_A_rate" + ) + matched <- preferred[preferred %in% names(item_log)] + sort_by <- if (length(matched) > 0L) matched[[1L]] else NA_character_ + } else { + if (!is.character(sort_by) || length(sort_by) != 1L || is.na(sort_by) || sort_by == "") { + rlang::abort("`sort_by` must be NULL or a single non-empty column name.") + } + } + if (!sort_by %in% names(item_log)) { - rlang::abort("`sort_by` must be a column in the item log.") + if (identical(sort_by, "rank_mean") && "rank_raw" %in% names(item_log)) { + sort_by <- "rank_raw" + } else if (identical(sort_by, "theta_mean") && "theta_raw_eap" %in% names(item_log)) { + sort_by <- "theta_raw_eap" + } else if (identical(sort_by, "theta_sd") && "theta_raw_sd" %in% names(item_log)) { + sort_by <- "theta_raw_sd" + } else { + rlang::abort("`sort_by` must be a column in the item log.") + } } item_log <- .adaptive_apply_sort_and_top_n( diff --git a/man/adaptive_item_log.Rd b/man/adaptive_item_log.Rd index 8265e78a..a9a673cc 100644 --- a/man/adaptive_item_log.Rd +++ b/man/adaptive_item_log.Rd @@ -27,16 +27,16 @@ The underlying state stores a list of refit tables; this accessor can return one refit table (default: most recent) or stack all refits into a single tibble. -In linking mode, raw and global summaries are kept separate: +Item-level summaries are domain-explicit: \itemize{ -\item \code{theta_raw_eap}: within-set scale summary (from Phase A artifacts -when available). -\item \code{theta_global_eap} and \code{theta_global_sd}: global-scale -summaries after spoke transform application. These are typed \code{NA} -when required spoke transform parameters are unavailable at that refit. -\item \code{in_phase_scope}, \code{theta_scope_eap}, \code{theta_scope_sd}, -\code{rank_scope_eap}: scoped summaries for the currently optimized item -domain (active set during linking Phase A; all items otherwise). +\item \code{theta_raw_*}: raw/within-set posterior summaries (EAP, fixed +quantiles, SD, rank) at the current refit. +\item \code{theta_link_*}: linked/global posterior summaries (EAP, fixed +quantiles, SD, rank) after transform application. +\item During linking Phase A (\code{phase_scope = "phase_a_set"}), +\code{theta_link_*} is typed \code{NA} by design. +\item \code{phase_scope}, \code{phase_scope_set_id}, and +\code{in_phase_scope} indicate which item domain is currently optimized. } } \examples{ diff --git a/man/summarize_items.Rd b/man/summarize_items.Rd index 422be15e..5227de4a 100644 --- a/man/summarize_items.Rd +++ b/man/summarize_items.Rd @@ -10,7 +10,7 @@ summarize_items( refit = NULL, bind = FALSE, top_n = NULL, - sort_by = c("rank_mean", "theta_mean", "theta_sd", "degree", "pos_A_rate"), + sort_by = NULL, include_optional = TRUE ) } @@ -29,7 +29,9 @@ returned; when set, the \code{k}-th refit is returned.} \item{top_n}{Optional positive integer; return only the top \code{n} rows after sorting.} -\item{sort_by}{Column used for sorting. Defaults to \code{"rank_mean"}.} +\item{sort_by}{Column used for sorting. When \code{NULL}, the first available +column in \code{c("rank_link", "rank_raw", "rank_mean", "theta_link_eap", + "theta_raw_eap", "theta_mean", "theta_sd", "degree", "pos_A_rate")} is used.} \item{include_optional}{Logical; include optional diagnostic columns.} } diff --git a/tests/testthat/test-0023-btl-mcmc-summaries-helpers.R b/tests/testthat/test-0023-btl-mcmc-summaries-helpers.R index 5875f714..b9a80a9e 100644 --- a/tests/testthat/test-0023-btl-mcmc-summaries-helpers.R +++ b/tests/testthat/test-0023-btl-mcmc-summaries-helpers.R @@ -171,3 +171,35 @@ test_that("summarize_items validates bind/refit and sorting constraints", { no_opt <- summarize_items(with_optional, include_optional = FALSE) expect_false(any(c("repeated_pairs", "adjacent_prev_prob", "adjacent_next_prob") %in% names(no_opt))) }) + +test_that("summarize_items resolves sort defaults and legacy aliases for new item-log schema", { + item_log_new <- tibble::tibble( + refit_id = c(1L, 1L), + item_id = c("A", "B"), + rank_raw = c(2L, 1L), + rank_link = c(1L, 2L), + theta_raw_eap = c(0.1, 0.3), + theta_raw_sd = c(0.2, 0.1) + ) + logs_new <- list(item_log_list = list(item_log_new)) + + out_default <- summarize_items(logs_new) + expect_identical(out_default$item_id[[1L]], "A") + + out_rank_alias <- summarize_items(logs_new, sort_by = "rank_mean") + expect_identical(out_rank_alias$item_id[[1L]], "B") + + out_theta_alias <- summarize_items(logs_new, sort_by = "theta_mean") + expect_identical(out_theta_alias$item_id[[1L]], "B") + + out_sd_alias <- summarize_items(logs_new, sort_by = "theta_sd") + expect_identical(out_sd_alias$item_id[[1L]], "A") + + expect_error( + summarize_items(logs_new, sort_by = 1L), + "`sort_by` must be NULL or a single non-empty column name" + ) + + logs_no_sortable <- list(item_log_list = list(tibble::tibble(refit_id = 1L, item_id = "A", x = 1))) + expect_error(summarize_items(logs_no_sortable), "`sort_by` must be a column in the item log") +}) diff --git a/tests/testthat/test-5016-persist-save-load.R b/tests/testthat/test-5016-persist-save-load.R index b894567e..c10fbe1d 100644 --- a/tests/testthat/test-5016-persist-save-load.R +++ b/tests/testthat/test-5016-persist-save-load.R @@ -212,7 +212,22 @@ test_that("validate_session_dir accepts legacy item log schema for resume", { "pos_count_B" ) item_path <- file.path(session_dir, "item_log", "refit_0001.rds") - legacy_item <- readRDS(item_path) + item_new <- readRDS(item_path) + legacy_item <- tibble::tibble( + refit_id = as.integer(item_new$refit_id), + item_id = as.character(item_new$item_id), + theta_mean = as.double(item_new$theta_raw_eap), + `theta_p2.5` = as.double(item_new$`theta_raw_p2.5`), + `theta_p5` = as.double(item_new$`theta_raw_p5`), + `theta_p50` = as.double(item_new$`theta_raw_p50`), + `theta_p95` = as.double(item_new$`theta_raw_p95`), + `theta_p97.5` = as.double(item_new$`theta_raw_p97.5`), + theta_sd = as.double(item_new$theta_raw_sd), + rank_mean = as.double(item_new$rank_raw), + degree = as.integer(item_new$degree), + pos_count_A = as.integer(item_new$pos_count_A), + pos_count_B = as.integer(item_new$pos_count_B) + ) legacy_item <- legacy_item[, legacy_cols, drop = FALSE] saveRDS(legacy_item, item_path) diff --git a/tests/testthat/test-5025-log-schema-ordering.R b/tests/testthat/test-5025-log-schema-ordering.R index 22edfc67..ca69b577 100644 --- a/tests/testthat/test-5025-log-schema-ordering.R +++ b/tests/testthat/test-5025-log-schema-ordering.R @@ -55,11 +55,13 @@ test_that("canonical log schemas follow the expected column order", { "stop_decision", "stop_reason" ) expected_item <- c( - "refit_id", "item_id", "set_id", "theta_raw_eap", "theta_global_eap", "theta_global_sd", - "in_phase_scope", "theta_scope_eap", "theta_scope_sd", "rank_scope_eap", "rank_global_eap", - "is_hub_item", "is_spoke_item", - "theta_mean", "theta_p2.5", "theta_p5", "theta_p50", - "theta_p95", "theta_p97.5", "theta_sd", "rank_mean", "degree", "pos_count_A", "pos_count_B" + "refit_id", "item_id", "set_id", + "phase_scope", "phase_scope_set_id", "in_phase_scope", "is_hub_item", "is_spoke_item", + "theta_raw_eap", "theta_raw_p2.5", "theta_raw_p5", "theta_raw_p50", "theta_raw_p95", "theta_raw_p97.5", + "theta_raw_sd", "rank_raw", + "theta_link_eap", "theta_link_p2.5", "theta_link_p5", "theta_link_p50", "theta_link_p95", "theta_link_p97.5", + "theta_link_sd", "rank_link", + "degree", "pos_count_A", "pos_count_B" ) expected_item_step <- c("step_id", "timestamp", "item_id", "mu", "sigma", "degree") expected_link_stage <- c( diff --git a/tests/testthat/test-5045-adaptive-helper-branches.R b/tests/testthat/test-5045-adaptive-helper-branches.R index 901634f8..00d8c3bf 100644 --- a/tests/testthat/test-5045-adaptive-helper-branches.R +++ b/tests/testthat/test-5045-adaptive-helper-branches.R @@ -326,7 +326,7 @@ test_that("adaptive print and log accessors cover validation and canonicalizatio expect_identical(pairwiseLLM:::.adaptive_item_log_na_value("degree"), NA_integer_) expect_identical(pairwiseLLM:::.adaptive_item_log_na_value("item_id"), NA_character_) - expect_true(is.na(pairwiseLLM:::.adaptive_item_log_na_value("theta_mean"))) + expect_true(is.na(pairwiseLLM:::.adaptive_item_log_na_value("theta_raw_eap"))) empty_refit <- pairwiseLLM:::.adaptive_build_item_log_refit(state, refit_id = 1L) expect_equal(nrow(empty_refit), 0L) @@ -335,7 +335,7 @@ test_that("adaptive print and log accessors cover validation and canonicalizatio state_with_bad$item_log <- 1L expect_error(pairwiseLLM:::.adaptive_append_item_log(state_with_bad, tibble::tibble(a = 1L)), "must be a list") - item_row <- tibble::tibble(ID = "1", deg = 2L, theta_mean = 0.1, rank_mean = 1.0) + item_row <- tibble::tibble(ID = "1", deg = 2L, theta_raw_eap = 0.1, rank_raw = 1L) canonical <- pairwiseLLM:::.adaptive_canonicalize_item_log(item_row, state, refit_id = 3L) expect_true(all(pairwiseLLM:::.adaptive_item_log_columns() %in% names(canonical))) expect_identical(canonical$refit_id[[1L]], 3L) diff --git a/tests/testthat/test-5048-linking-phase-a-artifacts.R b/tests/testthat/test-5048-linking-phase-a-artifacts.R index 86dcecd9..2df2b250 100644 --- a/tests/testthat/test-5048-linking-phase-a-artifacts.R +++ b/tests/testthat/test-5048-linking-phase-a-artifacts.R @@ -907,3 +907,76 @@ test_that("phase A prepare preserves warm-start scope metadata", { expect_identical(synced$linking$phase_a$warm_start_scope_set, 1L) expect_identical(synced$warm_start_idx, 2L) }) + +test_that("phase A helpers cover non-link mode and stale-summary fallback behavior", { + state <- make_phase_a_ready_state() + expect_identical(pairwiseLLM:::.adaptive_phase_a_pending_run_sets(state), integer()) + + stale <- tibble::tibble( + item_id = as.character(state$item_ids), + theta_raw_eap = c(NA_real_, 0.2, -0.1, 0.1), + theta_raw_sd = c(0.1, -0.2, 0.3, 0.4), + rank_raw = c(1L, 2L, 3L, 4L) + ) + state$item_log <- list(stale) + art <- pairwiseLLM:::.adaptive_phase_a_build_artifact(state, set_id = 1L) + expect_true(all(is.finite(art$items$theta_raw_mean))) + expect_true(all(art$items$theta_raw_sd >= 0)) +}) + +test_that("phase A validation and gate exercise failure branches for edge completeness", { + state <- make_phase_a_ready_state() + controller <- pairwiseLLM:::.adaptive_controller_resolve(state) + valid <- pairwiseLLM:::.adaptive_phase_a_build_artifact(state, set_id = 1L) + valid$n_pairs_committed <- -1L + expect_error( + pairwiseLLM:::.adaptive_phase_a_validate_imported_artifact(valid, state, set_id = 1L, controller = controller), + "`n_pairs_committed` must be >= 0" + ) + + ctl_empty <- pairwiseLLM:::.adaptive_controller_defaults(length(state$item_ids)) + ctl_empty$phase_a_artifacts <- NULL + expect_identical(pairwiseLLM:::.adaptive_phase_a_collect_import_map(ctl_empty), list()) + + state_link <- pairwiseLLM:::.adaptive_apply_controller_config( + state, + adaptive_config = list(run_mode = "link_one_spoke", hub_id = 1L) + ) + state_link$linking$phase_a <- list( + set_status = tibble::tibble( + set_id = c(1L, 2L), + source = c("run", "run"), + status = c("ready", "ready"), + validation_message = c("ok", "ok"), + artifact_path = c(NA_character_, NA_character_) + ), + artifacts = list(`1` = pairwiseLLM:::.adaptive_phase_a_build_artifact(state_link, set_id = 1L), `2` = NULL), + ready_for_phase_b = TRUE, + phase = "phase_b" + ) + state_link$linking$phase_a$artifacts[["1"]]$quality_gate_accepted <- TRUE + expect_error( + pairwiseLLM:::.adaptive_phase_a_gate_or_abort(state_link), + "missing artifact for set_id: 2" + ) + + expect_error( + testthat::with_mocked_bindings( + .adaptive_link_phase_context = function(state, controller = NULL) { + list( + phase = "phase_b", + pending_run_sets = integer(), + ready_spokes = integer(), + active_spokes = integer(), + stopped_spokes = integer(), + active_phase_a_set = NA_integer_ + ) + }, + .package = "pairwiseLLM", + { + pairwiseLLM:::.adaptive_phase_a_gate_or_abort(state_link) + } + ), + "phase marked phase_b but no ready spokes are available" + ) +}) diff --git a/tests/testthat/test-5050-linking-refit-transforms.R b/tests/testthat/test-5050-linking-refit-transforms.R index 90fe26db..9af8a4df 100644 --- a/tests/testthat/test-5050-linking-refit-transforms.R +++ b/tests/testthat/test-5050-linking-refit-transforms.R @@ -1201,12 +1201,17 @@ test_that("item log keeps raw summaries separate from transformed global summari row_s3 <- item_log[item_log$item_id == "s31", , drop = FALSE] row_h <- item_log[item_log$item_id == "h1", , drop = FALSE] - expect_equal(row_s2$theta_raw_eap[[1L]], -0.30, tolerance = 1e-12) - expect_equal(row_s2$theta_global_eap[[1L]], 0.3 + 1.2 * (-0.30), tolerance = 1e-12) - expect_equal(row_s3$theta_raw_eap[[1L]], 0.15, tolerance = 1e-12) - expect_equal(row_s3$theta_global_eap[[1L]], -0.2 + 0.15, tolerance = 1e-12) - expect_equal(row_h$theta_raw_eap[[1L]], 0.80, tolerance = 1e-12) - expect_equal(row_h$theta_global_eap[[1L]], 0.80, tolerance = 1e-12) + expect_equal( + row_s2$theta_link_eap[[1L]], + 0.3 + 1.2 * row_s2$theta_raw_eap[[1L]], + tolerance = 1e-12 + ) + expect_equal( + row_s3$theta_link_eap[[1L]], + -0.2 + row_s3$theta_raw_eap[[1L]], + tolerance = 1e-12 + ) + expect_equal(row_h$theta_link_eap[[1L]], row_h$theta_raw_eap[[1L]], tolerance = 1e-12) }) test_that("item log uses typed NA global summaries when spoke transform parameters are unavailable", { @@ -1230,12 +1235,12 @@ test_that("item log uses typed NA global summaries when spoke transform paramete row_s3 <- item_log[item_log$item_id == "s31", , drop = FALSE] row_h <- item_log[item_log$item_id == "h1", , drop = FALSE] - expect_true(is.na(row_s2$theta_global_eap[[1L]])) - expect_true(is.na(row_s2$theta_global_sd[[1L]])) - expect_true(is.na(row_s3$theta_global_eap[[1L]])) - expect_true(is.na(row_s3$theta_global_sd[[1L]])) - expect_true(is.finite(row_h$theta_global_eap[[1L]])) - expect_true(is.finite(row_h$theta_global_sd[[1L]])) + expect_true(is.na(row_s2$theta_link_eap[[1L]])) + expect_true(is.na(row_s2$theta_link_sd[[1L]])) + expect_true(is.na(row_s3$theta_link_eap[[1L]])) + expect_true(is.na(row_s3$theta_link_sd[[1L]])) + expect_true(is.finite(row_h$theta_link_eap[[1L]])) + expect_true(is.finite(row_h$theta_link_sd[[1L]])) }) test_that("non-linking item log keeps current raw/global behavior under seeded setup", { @@ -1255,15 +1260,15 @@ test_that("non-linking item log keeps current raw/global behavior under seeded s state$btl_fit <- make_test_btl_fit(ids, draws = draws, model_variant = "btl_e_b") item_log <- pairwiseLLM:::.adaptive_build_item_log_refit(state, refit_id = 1L) - expect_equal(item_log$theta_raw_eap, item_log$theta_global_eap, tolerance = 1e-12) - expect_equal(item_log$theta_global_sd, item_log$theta_sd, tolerance = 1e-12) + expect_equal(item_log$theta_raw_eap, item_log$theta_link_eap, tolerance = 1e-12) + expect_equal(item_log$theta_link_sd, item_log$theta_raw_sd, tolerance = 1e-12) expect_identical( - item_log$rank_global_eap, - as.integer(rank(-as.double(item_log$theta_global_eap), ties.method = "first")) + item_log$rank_link, + as.integer(rank(-as.double(item_log$theta_link_eap), ties.method = "first")) ) }) -test_that("item log exposes scoped theta summaries during linking Phase A", { +test_that("item log exposes phase scope and keeps link summaries NA during linking Phase A", { state <- make_linking_refit_state( list(link_transform_mode = "shift_scale", multi_spoke_mode = "independent") ) @@ -1281,16 +1286,19 @@ test_that("item log exposes scoped theta summaries during linking Phase A", { in_scope <- item_log[item_log$in_phase_scope %in% TRUE, , drop = FALSE] out_scope <- item_log[!item_log$in_phase_scope %in% TRUE, , drop = FALSE] + expect_true(all(item_log$phase_scope == "phase_a_set")) + expect_true(all(item_log$phase_scope_set_id == 2L)) + expect_true(all(is.na(item_log$theta_link_eap))) + expect_true(all(is.na(item_log$theta_link_sd))) + expect_true(nrow(in_scope) > 0L) expect_true(all(in_scope$set_id == 2L)) - expect_true(all(is.finite(in_scope$theta_scope_eap))) - expect_true(all(is.finite(in_scope$theta_scope_sd))) - expect_true(all(is.finite(in_scope$rank_scope_eap))) + expect_true(all(is.finite(in_scope$theta_raw_eap))) + expect_true(all(is.finite(in_scope$rank_raw))) expect_true(nrow(out_scope) > 0L) - expect_true(all(is.na(out_scope$theta_scope_eap))) - expect_true(all(is.na(out_scope$theta_scope_sd))) - expect_true(all(is.na(out_scope$rank_scope_eap))) + expect_true(all(is.finite(out_scope$theta_raw_eap))) + expect_true(all(is.finite(out_scope$rank_raw))) }) test_that("lagged rank stability gate uses Spearman threshold of at least 0.98", {