Guide: Adaptive Linking

This article shows how to place rankings from separately evaluated sets onto one common scale. It uses [adaptive_rank()] as the main entry point. The executable examples use a deterministic local judge so that rendering does not require credentials or make network requests. A detailed live-LLM workflow example appears later.

Adaptive linking has two phases:

  1. Phase A ranks each set internally and produces a canonical within-set artifact.
  2. Phase B collects selected hub–spoke comparisons and estimates spoke items on the hub scale.

These phases are distinct from the within-set selection stages described in Guide: Adaptive Pairing. For the algorithmic rationale, see Design: Adaptive Linking.

Public contract at a glance

User task adaptive_rank() input or output Current behavior
Identify sets data$set_id Positive integer-like values; hub_id must name one observed set.
Identify items across sources data$global_item_id Non-empty and unique across the combined input.
Link one spoke run_mode = "link_one_spoke" The input must contain exactly one non-hub set.
Link several spokes run_mode = "link_multi_spoke" Spokes progress concurrently.
Obtain Phase A data phase_a_mode = "run" Run within-set work before any Phase B comparison.
Reuse Phase A data phase_a_mode = "import" Require compatible artifacts for every set and fail fast otherwise.
Combine both approaches phase_a_mode = "mixed" Import supplied sets and run all remaining sets.
Inspect Phase A out$phase_a Manifest, paths, and per-set source/status/validation message.
Inspect linked scores out$items theta_link_eap, its uncertainty summaries, and rank_link.
Diagnose Phase B out$logs$link_stage_log Per-spoke budgets, probes, gates, and blockers.
Continue safely session_dir, resume = TRUE Load compatible state with identical IDs and order.

Phase B has one public estimation contract: anchored-joint estimation with hub item parameters hard-locked to their Phase A values. Judge parameters are shared globally, including across spokes. Candidate selection uses the implemented D-optimal utility. Held-out probes are collected at the configured fixed per-refit cap while a spoke is active.

Prerequisites

Bayesian Phase A refits and Phase B linking require cmdstanr, a working C++ toolchain, and CmdStan. They do not require an LLM credential when a custom local judge is supplied.

cmdstan_available <- identical(Sys.getenv("PAIRWISELLM_RUN_CMDSTAN_VIGNETTES"), "true") &&
  requireNamespace("cmdstanr", quietly = TRUE) &&
  tryCatch({
    cmdstanr::cmdstan_version()
    TRUE
  }, error = function(e) FALSE)

cmdstan_available
#> [1] FALSE

If this prints FALSE, install and verify CmdStan before running the remaining executable chunks. Those chunks are disabled during ordinary package builds; set PAIRWISELLM_RUN_CMDSTAN_VIGNETTES=true to opt in when rendering this source locally.

install.packages(
  "cmdstanr",
  repos = c("https://stan-dev.r-universe.dev", getOption("repos"))
)
cmdstanr::check_cmdstan_toolchain(fix = TRUE)
cmdstanr::install_cmdstan()

Construct a hub and spokes

This is a runnable offline example. Each source system uses short local IDs such as 01 and 02, so those values repeat between sets. ID is the row identifier consumed by adaptive_rank() and must be unique in the combined table. global_item_id is the stable cross-source identity used by linking and must also be globally unique.

library(pairwiseLLM)

make_set <- function(set_id, prefix, scores) {
  local_id <- sprintf("%02d", seq_along(scores))
  data.frame(
    ID = paste0(prefix, "_", local_id),
    text = paste("Writing sample", prefix, local_id),
    quality_score = as.double(scores),
    set_id = as.integer(set_id),
    global_item_id = paste0("study_2026_", prefix, "_", local_id),
    stringsAsFactors = FALSE
  )
}

hub_samples <- make_set(1L, "hub", c(6.0, 5.0, 4.0, 3.0, 2.0, 1.0))
spoke_2_samples <- make_set(2L, "school_b", c(5.5, 4.5, 3.5, 2.5, 1.5, 0.5))
spoke_3_samples <- make_set(3L, "school_c", c(5.2, 4.2, 3.2, 2.2, 1.2, 0.2))
linking_samples <- rbind(hub_samples, spoke_2_samples, spoke_3_samples)

stopifnot(!anyDuplicated(linking_samples$ID))
stopifnot(!anyDuplicated(linking_samples$global_item_id))

deterministic_judge <- function(A, B, state, ...) {
  list(
    is_valid = TRUE,
    Y = as.integer(A$quality_score[[1L]] >= B$quality_score[[1L]]),
    invalid_reason = NA_character_
  )
}

The simulated score exists only to make the judge reproducible. It is not passed to the Bayesian model as an item score. A valid custom judge returns Y = 1 when displayed item A wins and Y = 0 when B wins.

The following settings keep this vignette quick. Very low refit and probe thresholds are useful for exercising the workflow, not for a substantive study. Production runs should normally retain the package defaults unless a study design justifies changing them.

tutorial_btl <- list(
  refit_pairs_target = 3L,
  ess_bulk_min = 1,
  ess_bulk_min_near_stop = 1,
  max_rhat = 5,
  divergences_max = 100L
)

tutorial_link_controls <- list(
  phase_a_required_reliability_min = 0,
  probe_panel_edges = 8L,
  probe_pairs_per_refit_per_spoke = 1L,
  probe_edges_min_for_stop = 2L,
  min_refits_in_phase_b = 1L,
  stability_window_refits = 1L,
  stability_passes_required = 1L
)

Phase A from raw samples

A practical workflow often ranks the hub and each spoke in separate jobs. Each call below starts from raw samples and writes a reusable canonical artifact under its session directory.

phase_a_root <- tempfile("pairwisellm-phase-a-")

hub_run <- adaptive_rank(
  data = hub_samples,
  id_col = "ID",
  text_col = "text",
  judge = deterministic_judge,
  n_steps = 6L,
  btl_config = tutorial_btl,
  session_dir = file.path(phase_a_root, "hub"),
  persist_item_log = TRUE,
  resume = FALSE,
  seed = 101L,
  progress = "none"
)

spoke_2_run <- adaptive_rank(
  data = spoke_2_samples,
  id_col = "ID",
  text_col = "text",
  judge = deterministic_judge,
  n_steps = 6L,
  btl_config = tutorial_btl,
  session_dir = file.path(phase_a_root, "spoke-2"),
  persist_item_log = TRUE,
  resume = FALSE,
  seed = 102L,
  progress = "none"
)

hub_run$phase_a$set_status
spoke_2_run$phase_a$set_status
hub_run$phase_a$artifact_paths

The status is ready only after the latest canonical artifact has committed within-set evidence, passing MCMC diagnostics, and EAP reliability at or above phase_a_required_reliability_min. A small call can instead return pending_finalization; resume that same session with more steps until its quality criteria pass. Phase B never begins merely because an artifact file exists.

hub_artifact <- hub_run$phase_a$manifest[["1"]]

names(hub_artifact)
hub_artifact[c("set_id", "n_items", "n_pairs_committed", "fit_model_id")]
hub_artifact$diagnostics
head(hub_artifact$items)
head(hub_artifact$phase_a_within_set_evidence)

out$phase_a, out$phase_a$manifest, an artifact .rds file, a phase_a_artifacts/ directory, or a saved session directory can be supplied to a later wrapper call. The wrapper normalizes these surfaces before exact runtime validation.

Import and mixed Phase A modes

Import mode is fail-fast: every required set needs a compatible canonical artifact. Compatibility checks include the set ID, item identities, item count, schema/configuration fingerprints, exact within-set committed-edge evidence, and normalized BTL model variant. For example, an artifact created with model_variant = "btl" cannot be imported into a "btl_e_b" run. Reuse it with the matching variant or rerun Phase A; there is no cross-variant conversion.

Mixed mode imports the sets you supply and runs Phase A for every remaining set. Here the hub is imported and spoke 3 is run locally:

mixed_samples <- linking_samples[linking_samples$set_id %in% c(1L, 3L), ]

mixed <- adaptive_rank(
  data = mixed_samples,
  id_col = "ID",
  text_col = "text",
  judge = deterministic_judge,
  n_steps = 6L,
  adaptive_config = c(
    list(
      run_mode = "link_one_spoke",
      hub_id = 1L,
      phase_a_mode = "mixed",
      phase_a_artifacts = list(`1` = phase_a_artifacts[["1"]])
    ),
    tutorial_link_controls
  ),
  btl_config = tutorial_btl,
  session_dir = tempfile("pairwisellm-mixed-"),
  resume = FALSE,
  seed = 301L,
  progress = "none"
)

mixed$phase_a$set_status

The expected sources are import for set 1 and run for set 3. If the run budget ends before set 3 passes its gate, its status remains pending_finalization and no Phase B work is scheduled.

Common import errors name the failed invariant: a missing artifact, set/item mismatch, absent within-set evidence, different fit configuration, insufficient reliability, or incompatible model variant. Treat the full error as a data provenance problem; do not catch it and silently start a new link.

Useful message fragments map directly to corrective action:

Message fragment What to check
configured for import but no artifact was provided Supply every required set or use mixed mode.
global_item_id mapping mismatch Reconcile the artifact with the combined input identities.
item-count metadata mismatch Use the exact Phase A item set, without adding or dropping rows.
item_id mapping mismatch Restore the original row IDs and order for that set.
missing reliability_EAP_within Rerun/finalize Phase A or perform a documented external review.
reliability gate failed Continue Phase A or justify a trusted external quality decision.
evidence-domain availability failure Recreate the artifact with canonical committed-edge history.

Configuration- or model-variant mismatches similarly require the matching Phase A configuration; the error reports the expected and observed surfaces.

Persistence, interruption, and resume

With session_dir, the wrapper writes state, step, round, link-stage, metadata, optional BTL fit, Phase A artifacts, and optional per-refit item logs. Writes are atomic at the individual-file level. Completed refits, terminal events, the end of an ordinary call, and configured checkpoints are saved. An abrupt process termination can lose attempts since the most recent checkpoint.

validate_session_dir(one_spoke_session)[c(
  "schema_version", "package_version", "n_items"
)]

saved_state <- load_adaptive_session(one_spoke_session)
summarize_adaptive(saved_state)

before_steps <- nrow(one_spoke$logs$step_log)
resumed_one_spoke <- adaptive_rank(
  data = one_spoke_samples,
  id_col = "ID",
  text_col = "text",
  judge = deterministic_judge,
  n_steps = 2L,
  adaptive_config = c(
    list(
      run_mode = "link_one_spoke",
      hub_id = 1L,
      phase_a_mode = "import",
      phase_a_artifacts = phase_a_artifacts
    ),
    tutorial_link_controls
  ),
  btl_config = tutorial_btl,
  session_dir = one_spoke_session,
  persist_item_log = TRUE,
  resume = TRUE,
  progress = "none"
)

c(
  before = before_steps,
  after = nrow(resumed_one_spoke$logs$step_log)
)

Resume is strict. The supplied IDs and order must exactly match the saved state. Stale or corrupt schemas, incompatible artifacts, changed link configuration, or inconsistent probe/frozen state abort with an actionable error rather than silently starting over. Use resume = FALSE only with a new or empty directory when you intentionally want a new study. For expensive LLM runs, set checkpoint_every_steps according to how many completed calls you can afford to repeat.

A detailed live-LLM workflow

The following is a live-API example and is not evaluated. It uses the OpenAI configuration recorded as tested with package 1.3.1 on 2026-09-05. A tested configuration is evidence about that dated request shape, not a promise that the provider still offers the model. Check Backends and Tested Model Configurations and the provider catalog before a long run.

Set OPENAI_API_KEY outside the script. Replace real_linking_samples with a data frame containing unique ID, text, integer-like set_id, and globally unique global_item_id columns. Keep a copy of that input in its exact row order for resume.

library(pairwiseLLM)

stopifnot(nzchar(Sys.getenv("OPENAI_API_KEY")))

real_linking_samples <- utils::read.csv(
  "writing-samples-to-link.csv",
  stringsAsFactors = FALSE
)
stopifnot(!anyDuplicated(real_linking_samples$ID))
stopifnot(!anyDuplicated(real_linking_samples$global_item_id))
stopifnot(length(unique(real_linking_samples$set_id)) == 2L)

live_link <- adaptive_rank(
  data = real_linking_samples,
  id_col = "ID",
  text_col = "text",
  backend = "openai",
  model = "gpt-5.6-luna",
  endpoint = "responses",
  trait_name = "Overall writing quality",
  trait_description = paste(
    "Prefer the response that is clearer, better organized, better supported,",
    "and more effective for its intended audience."
  ),
  judge_args = list(
    reasoning = "none"
  ),
  n_steps = 200L,
  adaptive_config = list(
    run_mode = "link_one_spoke",
    hub_id = 1L,
    phase_a_mode = "run",
    max_pairs_after_stop = 0L
  ),
  session_dir = "adaptive-link-live",
  persist_item_log = TRUE,
  checkpoint_every_steps = 10L,
  resume = TRUE,
  seed = 20260904L,
  progress = "refits",
  save_outputs = TRUE
)

This single high-level call builds the LLM judge, performs or continues Phase A, enters Phase B only when all required Phase A sets are ready, persists state, and returns reporting views. Do not add tutorial thresholds from the offline fixture. The defaults provide the package’s current quality and stopping policy.

Inspect both progress and quality before reporting a linked ranking:

live_link$phase_a$set_status
live_link$summary

live_link$items[, c(
  "item_id", "set_id", "theta_link_eap", "theta_link_sd", "rank_link"
)]

live_link$logs$step_log[, c(
  "step_id", "status", "judge_backend", "judge_model", "judge_endpoint",
  "judge_invalid_reason", "llm_status_code", "llm_error_message",
  "prompt_tokens", "completion_tokens", "total_tokens"
)] |>
  tail()

live_link$logs$link_stage_log[, c(
  "refit_id", "spoke_id", "linking_identified", "link_stop_pass",
  "link_state_frozen", "probe_edges_realized", "probe_quality_pass",
  "stop_blocker_codes"
)] |>
  tail()

include_raw = TRUE can preserve serialized raw provider responses for deeper auditing, but those payloads may contain submitted text and increase storage. Enable it only under an appropriate data retention policy. Invalid API, refusal, or parse responses consume attempted steps but do not commit comparisons or update either ranking model. Inspect status, judge_invalid_reason, and provider error fields before adding more budget.

Continue the exact study with the same input and configuration:

live_link <- adaptive_rank(
  data = real_linking_samples,
  id_col = "ID",
  text_col = "text",
  backend = "openai",
  model = "gpt-5.6-luna",
  endpoint = "responses",
  trait_name = "Overall writing quality",
  trait_description = paste(
    "Prefer the response that is clearer, better organized, better supported,",
    "and more effective for its intended audience."
  ),
  judge_args = list(reasoning = "none"),
  n_steps = 100L,
  adaptive_config = list(
    run_mode = "link_one_spoke",
    hub_id = 1L,
    phase_a_mode = "run",
    max_pairs_after_stop = 0L
  ),
  session_dir = "adaptive-link-live",
  persist_item_log = TRUE,
  checkpoint_every_steps = 10L,
  resume = TRUE,
  progress = "refits"
)

For independently completed Phase A jobs, change phase_a_mode to "import" and set phase_a_artifacts to their wrapper outputs or session directories. For partial reuse, use "mixed" and supply only the completed sets. For more than one spoke, change run_mode to "link_multi_spoke"; no other mode switch is required.

Live comparisons usually dominate monetary cost and elapsed time. Phase A reuse avoids repeating within-set judgments when the same compatible artifacts are linked again. Phase B cost grows with active spokes, selected cross-set comparisons, held-out probes, invalid responses, and any extra steps needed to clear diagnostics or stopping blockers. Bayesian refits add local computation.

Citation

Mercer, S. H. (2026). Guide: Adaptive linking [R package vignette]. Comprehensive R Archive Network. https://doi.org/10.32614/CRAN.package.pairwiseLLM