Files
moral-maps/src/tinymfv/guided.py
T
wassnameandClaudypoo 9d3741fb45 Unify ordinal survey readout onto the guided think-then-read core
administer()/read_items() now route through _rollout_natural_or_forced (the
nominal MFV core) instead of a think=0 single forward, so an activation steer
accrues over the think trace before the prefilled answer slot is read (spec
moral_aliens_engine.md, resolved decision: ordinal needs a think budget). The
only per-instrument difference is the answer-token set + the downstream reducer.

force_only on the shared core: the ordinal "(" prefill is one common char, so
natural-emission detection would match it by chance in the think trace and read
logits mid-think; surveys always force-read the answer slot. Nominal path keeps
natural emission (force_only defaults False). max_think_tokens floor is 1.

Smoke (tiny-random): ordinal + nominal both run; force-only demo reads the
forced ( slot.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-06-24 15:37:47 +08:00

665 lines
31 KiB
Python

"""Guided rollout: hybrid natural-emission + forced-prefill scoring.
Public API: `guided_rollout_forced_choice` (K-way moral-foundation probe with
two-pass enum-reversal position-bias debias).
Per sample at the answer slot:
(a) natural — model emitted the JSON answer prefix in-budget: read logits
at the answer-token position from `generate.scores`.
(b) interrupted — model never emitted </think>: append forced prefill on top
of the full-budget cache, batched forward, read logits at the suffix's
last position.
(c) emitted </think> but no natural answer: cache past close is junk; NaN.
Turn-boundary close+nudge in the forced path matches what a chat UI emits when
a human interrupts a partial assistant turn — on-policy in chat-tuned data.
"""
from __future__ import annotations
from dataclasses import dataclass
import torch
import torch.nn.functional as F
from loguru import logger
_CLOSE_MARKER: str = "</think>"
_ASSISTANT_SENTINEL: str = "ZZUNIQ_ASSISTANT_SENTINEL_ZZ"
def _assistant_close(tok) -> str:
"""Probe chat template for the assistant-turn close marker (e.g. `<|im_end|>\\n`
on Qwen/ChatML, `<|eot_id|>` on Llama3). Tokenizer-agnostic: mimics what a chat
UI emits when a human stops a partial assistant turn before sending a new user
message. Sentinel-diff because Qwen3 auto-injects empty `<think></think>` in
non-generation-prompt mode, breaking opened-vs-closed prefix-diff."""
closed = tok.apply_chat_template(
[{"role": "user", "content": "_"},
{"role": "assistant", "content": _ASSISTANT_SENTINEL}],
tokenize=False,
)
assert _ASSISTANT_SENTINEL in closed, f"sentinel not in template output: {closed!r}"
return closed.split(_ASSISTANT_SENTINEL, 1)[1]
def _find_natural_prefill_window(
gen_ids: torch.Tensor, pattern_text: str, tok, pad_id: int
) -> tuple[int, int] | None:
"""Return `(start_pos, answer_pos)` where `gen_ids[start_pos:answer_pos]`
are the tokens that decode to `pattern_text` (the prefill), and `answer_pos`
is the first token after the prefill (the answer slot). Returns None if
`pattern_text` never appears in the generated text, or if the pattern is
the very last thing (no answer token follows).
Token-position mapping uses incremental decoding (O(n²) on token count,
fine for n≤2k): step through gen_ids one token at a time, decode prefix,
track first index whose decoded length passes the pattern's start char,
then the first whose decoded length covers the pattern's end char."""
keep = gen_ids != pad_id
real_ids = gen_ids[keep] if keep.any() else gen_ids[:0]
if real_ids.shape[0] == 0:
return None
full_text = tok.decode(real_ids, skip_special_tokens=False)
idx = full_text.find(pattern_text)
if idx < 0:
return None
target_start = idx
target_end = idx + len(pattern_text)
start_in_real: int | None = None
end_in_real: int | None = None
for t in range(real_ids.shape[0]):
partial = tok.decode(real_ids[: t + 1], skip_special_tokens=False)
if start_in_real is None and len(partial) > target_start:
start_in_real = t
if len(partial) >= target_end:
end_in_real = t + 1
break
if start_in_real is None or end_in_real is None:
return None
real_to_full = keep.nonzero(as_tuple=True)[0]
if end_in_real >= real_to_full.shape[0]:
return None
return (
int(real_to_full[start_in_real].item()),
int(real_to_full[end_in_real].item()),
)
@torch.no_grad()
def _rollout_natural_or_forced(
model, tok,
user_prompts: list[str],
schema_hint: str,
max_think_tokens: int,
scoring_slots: list[tuple[str, str]], # (nudge_user_text, prefill) per slot
gather_token_ids: list[int], # K-way answer-token ids
*,
n_samples: int = 1,
temperature: float = 0.0,
top_p: float = 1.0,
skip_special_tokens: bool = False,
force_only: bool = False,
verbose: bool = False,
) -> tuple[list[tuple[str, int, bool]], list[list[dict]]]:
"""Hybrid natural + batched-forced scoring.
Returns `(thinks, slots)`, both flat lists of length `B*N` where
`B = len(user_prompts)` and `N = n_samples`. HF `num_return_sequences=N`
expands the batch to rows `[in_0_s_0, ..., in_0_s_(N-1), in_1_s_0, ...]`;
callers reshape via `[i*N + n]`.
thinks[j] = (gen_text, n_think_tokens, emitted_close).
slots[j][k] = {pmass_allowed, nll_json, top5_str, lp_gather}.
Phase 1: batched generate, `min_new_tokens=max_new_tokens=max_think_tokens`
→ uniform-length cache. Capture `scores` (per-step logits) and `pkv`.
Phase 2: per scoring slot, append the uniform forced suffix (`</think>` +
assistant-close + interrupt-renudge user turn + prefill) over `pkv`.
One batched forward gives forced logits and prefill NLL.
Per-sample selection: if the prefill text appears in the generation, use
natural logits from `scores[answer_pos]` and natural NLL from
`scores[start_pos:answer_pos]` (case a). Else if `</think>` never appeared,
use forced (case b). Else NaN (case c).
"""
if tok.padding_side != "left":
raise ValueError("tok.padding_side must be 'left'")
assert n_samples >= 1, f"n_samples must be >= 1, got {n_samples}"
if n_samples > 1:
assert temperature > 0.0, (
f"n_samples={n_samples} > 1 requires temperature > 0 (sampling). "
f"Got temperature={temperature}."
)
device = next(model.parameters()).device
pad_id = tok.pad_token_id if tok.pad_token_id is not None else tok.eos_token_id
close = _assistant_close(tok)
# ── Phase 1: think generation (full budget, no early stop) ──
chats = [
tok.apply_chat_template(
[{"role": "user", "content": f"{up}\n\n{schema_hint}" if schema_hint else up}],
tokenize=False, add_generation_prompt=True,
) + "<think>\n"
for up in user_prompts
]
think_end_id = tok.convert_tokens_to_ids("</think>")
if think_end_id in (None, getattr(tok, "unk_token_id", None)):
think_end_id = tok.eos_token_id
enc = tok(chats, return_tensors="pt", padding=True).to(device)
prompt_len = enc.input_ids.shape[1]
do_sample = temperature > 0.0
gen_kwargs = dict(
max_new_tokens=max_think_tokens,
# Force full budget so all samples have identical cache length →
# batched suffix forward without per-sample rewinding. Garbage tokens
# emitted past natural EOS pollute the cache only for case-(c) samples,
# which we NaN downstream anyway.
min_new_tokens=max_think_tokens,
pad_token_id=pad_id,
return_dict_in_generate=True,
output_scores=True,
num_return_sequences=n_samples,
)
if do_sample:
gen_kwargs.update(do_sample=True, temperature=temperature, top_p=top_p)
else:
gen_kwargs.update(do_sample=False)
out1 = model.generate(**enc, **gen_kwargs)
phase1_ids = out1.sequences # [B*N, prompt_len + max_think_tokens]
pkv = out1.past_key_values # cache for entire phase1_ids span
step_scores = out1.scores # tuple length max_think_tokens, each [B*N, V]
B = phase1_ids.shape[0]
assert B == len(user_prompts) * n_samples, (
f"phase1_ids batch {B} != len(user_prompts)*n_samples = "
f"{len(user_prompts)}*{n_samples}. HF expansion misaligned."
)
thinks: list[tuple[str, int, bool]] = []
for i in range(B):
gen_ids_full = phase1_ids[i, prompt_len:]
keep = gen_ids_full != pad_id
gen_ids = gen_ids_full[keep] if keep.any() else gen_ids_full[:0]
gen_text = tok.decode(gen_ids, skip_special_tokens=skip_special_tokens)
n_think = int(gen_ids.shape[0])
emitted_close = bool((gen_ids == think_end_id).any().item())
thinks.append((gen_text, n_think, emitted_close))
pref_attn = (phase1_ids != pad_id).long() # [B, prompt_len + max_think_tokens]
gid_t = torch.tensor(gather_token_ids, device=device, dtype=torch.long)
# ── Phase 2: per scoring slot, batched forced forward + natural overlay ──
slots: list[list[dict]] = [[] for _ in range(B)]
for slot_idx, (nudge, prefill) in enumerate(scoring_slots):
# Build uniform suffix. head = </think> always: case-(b) samples need
# it to close their open think; for case-(a)/(c) we don't use forced
# logits so the duplicate close doesn't matter.
interrupt = tok.apply_chat_template(
[{"role": "user", "content": nudge},
{"role": "assistant", "content": _ASSISTANT_SENTINEL}],
tokenize=False, continue_final_message=True,
)
assert _ASSISTANT_SENTINEL in interrupt, f"sentinel not in interrupt: {interrupt!r}"
interrupt_prefix = interrupt.split(_ASSISTANT_SENTINEL, 1)[0]
prefix_text = _CLOSE_MARKER + close + interrupt_prefix
prefix_ids = tok(prefix_text, add_special_tokens=False)["input_ids"]
prefill_ids = tok(prefill, add_special_tokens=False)["input_ids"]
assert prefill_ids, f"empty prefill ids for {prefill!r}"
P, J = len(prefix_ids), len(prefill_ids)
# Per-sample natural-emission window detection for THIS slot's prefill. force_only skips it
# (always read the forced slot): a short prefill like the ordinal "(" matches by chance
# anywhere in the think trace, which would read logits mid-think instead of at the answer slot.
windows: list[tuple[int, int] | None] = [None] * B if force_only else [
_find_natural_prefill_window(phase1_ids[i, prompt_len:], prefill, tok, pad_id)
for i in range(B)
]
prefix_t = torch.tensor([prefix_ids] * B, device=device, dtype=torch.long)
prefill_t = torch.tensor([prefill_ids] * B, device=device, dtype=torch.long)
prefix_mask = torch.ones((B, P), dtype=torch.long, device=device)
prefix_attn = torch.cat([pref_attn, prefix_mask], dim=1)
prefix_out = model(
input_ids=prefix_t,
attention_mask=prefix_attn,
past_key_values=pkv,
use_cache=True,
)
prefill_mask = torch.ones((B, J), dtype=torch.long, device=device)
prefill_attn = torch.cat([prefix_attn, prefill_mask], dim=1)
prefill_out = model(
input_ids=prefill_t,
attention_mask=prefill_attn,
past_key_values=prefix_out.past_key_values,
use_cache=False,
)
forced_lp_last = F.log_softmax(prefill_out.logits[:, -1].float(), dim=-1) # [B, V]
first_logp = F.log_softmax(prefix_out.logits[:, -1].float(), dim=-1) # [B, V]
first_nll = -first_logp.gather(1, prefill_t[:, :1]).squeeze(-1) # [B]
if J == 1:
forced_nll_json = first_nll
else:
next_logp = F.log_softmax(prefill_out.logits[:, :-1].float(), dim=-1) # [B, J-1, V]
next_ids = prefill_t[:, 1:].unsqueeze(-1) # [B, J-1, 1]
tail_nll = -next_logp.gather(2, next_ids).squeeze(-1).sum(dim=1) # [B]
forced_nll_json = (first_nll + tail_nll) / J
if verbose:
real0 = phase1_ids[0][phase1_ids[0] != pad_id]
prefix0_text = tok.decode(real0, skip_special_tokens=False)
suf0 = tok.decode(prefix_ids + prefill_ids, skip_special_tokens=False)
# INFO not DEBUG: this is the full first trace (prompt + think + answer
# slot, special tokens shown). evaluate() gates verbose to the first
# batch, so it fires once per run and shows on the console by default.
logger.info(
f"--- DEMO A: forced-choice readout (what's measured), slot {slot_idx} "
f"(nudge={nudge!r}, prefill={prefill!r}) ---\n"
f"SHOULD: the answer slot is prefilled to read calibrated logprobs, so the "
f"reasoning shown is only whatever fit the think budget (degenerate at think=1). "
f"See DEMO B for free reasoning.\n"
f"window[0]={windows[0]} emitted_close[0]={thinks[0][2]}\n"
f"{prefix0_text}{suf0}\n--- end DEMO A slot {slot_idx} ---"
)
for i in range(B):
win = windows[i]
emitted_close_i = thinks[i][2]
if win is not None:
# Case (a) natural. Read logits at the answer slot from
# step_scores. step_scores[t] are the logits that produced
# gen_ids[t]; gen_ids[answer_pos] is the answer token, so
# the predictive distribution at the slot is step_scores[answer_pos].
start_pos, answer_pos = win
assert answer_pos < len(step_scores), (
f"answer_pos={answer_pos} ≥ len(step_scores)={len(step_scores)}"
)
# nan_to_num: quantized + adapted forwards occasionally
# emit non-finite raw logits at a single generated step;
# ±1e4 bound keeps log_softmax stable without changing the
# argmax for well-behaved rows.
raw = step_scores[answer_pos][i].float()
lp_vec = F.log_softmax(
torch.nan_to_num(raw, nan=0.0, posinf=1e4, neginf=-1e4), dim=-1
)
gen_ids_full = phase1_ids[i, prompt_len:]
nat_nll_sum = 0.0
for k in range(start_pos, answer_pos):
raw_k = step_scores[k][i].float()
step_lp = F.log_softmax(
torch.nan_to_num(raw_k, nan=0.0, posinf=1e4, neginf=-1e4),
dim=-1,
)
nat_nll_sum += float(-step_lp[gen_ids_full[k]].item())
nll_val = nat_nll_sum / max(1, answer_pos - start_pos)
elif not emitted_close_i:
# Case (b) interrupted: forced
lp_vec = forced_lp_last[i]
nll_val = float(forced_nll_json[i].item())
else:
# Case (c) emitted </think> but no natural answer slot found.
# Model "finished thinking" without producing JSON — coherence
# collapse at the answer slot. pmass=0.0 is the honest measurement
# (no probability mass on allowed tokens at a non-existent slot)
# and lets c_scan see the failure as a real signal rather than
# crashing on NaN. nll_json stays NaN (genuinely undefined: no
# JSON tokens were emitted to score).
slots[i].append({
"pmass_allowed": 0.0,
"nll_json": float("nan"),
"top5_str": "",
"lp_gather": [float("nan")] * len(gather_token_ids),
})
continue
top5 = lp_vec.topk(5)
top5_str = " ".join(
f"{tok.decode([int(idx)])!r}:{float(prob.exp()):.3f}"
for idx, prob in zip(top5.indices, top5.values)
)
slots[i].append({
"pmass_allowed": float(lp_vec[gid_t].exp().sum().item()),
"nll_json": nll_val,
"top5_str": top5_str,
"lp_gather": lp_vec[gid_t].cpu().tolist(),
})
return thinks, slots
# ===== Forced-choice (K-way primary foundation) =====
# Foundation set + descriptions adapted from the response options in
# Clifford et al. (2015) Study 1. Their "social" option = "not morally wrong"
# (the social-norms control set). Descriptions are parallel and short
# (no repetitive "violates norms of" prefix) so no option stands out
# structurally.
_DEFAULT_FORCED_FOUNDATIONS: tuple[str, ...] = (
"care", "fairness", "loyalty", "authority", "sanctity", "liberty", "social",
)
# VERBATIM the full Clifford et al. (2015) response-option sentences -- the model sees exactly
# what Clifford's human raters chose between. Clifford deliberately kept this option vocabulary
# DISJOINT from the vignette text ("we did not use any of the words from the descriptions ... in
# the actual vignettes ... minimizing concerns that classification is driven by shared language"),
# so we do NOT paraphrase: any reword risks a shared-language confound and breaks comparability.
#
# The ANSWER is the option INDEX (a digit), not the foundation word -- see _format_options. Why:
# scoring reads the first token after the prefill `"`, and the foundation words tokenize into
# UNEVEN first pieces on Qwen3 (care/authority/social score on the whole word; fairness->"fair",
# loyalty->"loy", sanctity->"san", liberty->"lib" score on a rare subword fragment). Those pieces
# have very different unconditional priors, the bias is tied to the word so it is CONSTANT across
# the fwd/rev frames, and the position-debias cannot remove it -- it leaks into the selectivity
# softmax. Digits "0".."6" are clean single distinct tokens; with index = LISTING POSITION the
# reversed-enum pass scores each foundation on two different digits, so digit-magnitude AND
# position bias both cancel in the fwd/rev mean. It also closes Clifford's shared-language channel
# (no foundation word in the scored token at all). The dict keys stay foundation names: they are
# the canonical identity used downstream and to look up the gloss; only the EMITTED value is a digit.
_FORCED_FOUNDATION_DESCS: dict[str, str] = {
"care": "It violates norms of harm or care (e.g., unkindness, causing pain to another)",
"fairness": "It violates norms of fairness or justice (e.g., cheating or reducing equality)",
"loyalty": "It violates norms of loyalty (e.g., betrayal of a group)",
"authority": "It violates norms of respecting authority (e.g., subversion, lack of respect for tradition)",
"sanctity": "It violates norms of purity (e.g., degrading or disgusting acts)",
"liberty": "It violates norms of freedom (e.g., bullying, dominating)",
"social": "It is not morally wrong and does not apply to any of the provided choices",
}
def _format_options(foundations: list[str]) -> str:
"""JSON enum lines: value = option INDEX (the scored digit), `# comment` = the
verbatim Clifford option sentence. Index is the position in THIS listing, so the
reversed-enum frame relabels each foundation with a different digit (the debias)."""
return "\n".join(
f' "{k}", # {_FORCED_FOUNDATION_DESCS[f]}'
for k, f in enumerate(foundations)
)
# Schema hint (shown ONCE in the user-side prompt that precedes the think).
# Labelled as a JSON schema so the model treats it as a constrained answer
# space; enum + per-option `# comments` (Clifford et al. 2015 option vocabulary)
# put the gloss right at the option, not in separate prose. The prefill
# `This is wrong because {"violation": "` then forces a single-token answer.
def _make_forced_hint(foundations: list[str]) -> str:
return (
"Here is the JSON schema for the answer. This is wrong because:\n"
" {\n"
' "properties": {\n'
' "violation": {\n'
' "enum": [\n'
f"{_format_options(foundations)}\n"
" ]\n"
" }\n"
" }\n"
" }"
)
@dataclass
class ForcedChoiceResult:
user_prompt: str
# Full decoded generations per enum-ordering frame, one per sample.
# `gen_text` is always a list of length N=n_samples (even at N=1).
# Both texts are FULL — no stripping at </think>. If you want the
# pre-close part, split on `tinymfv.guided._CLOSE_MARKER`.
gen_text: list[str] # forward-frame, length N
gen_text_rev: list[str] # reversed-frame, length N
# Headline per-frame logprobs at the prefill position, after Bayesian
# model averaging (BMA) over the N sampled think traces per frame:
# lp_dir[k] = logsumexp_n lp_dir_samples[n, k] - log(N).
# Interpretation: marginal answer logprob under stochastic thinks.
# At N=1 this is identical to the single sample.
lp_fwd: dict[str, float] # enum listed [care, ..., social]
lp_rev: dict[str, float] # enum listed [social, ..., care]
# Raw per-sample logprob matrices, shape [N, K], in the same foundation
# order as `lp_fwd` / `lp_rev`. Caller can re-aggregate (log-pooling,
# majority vote on argmax, median, etc.).
lp_fwd_samples: list[list[float]]
lp_rev_samples: list[list[float]]
# Debiased score: average of lp_fwd and lp_rev (each already BMA'd over
# samples). Position bias cancels because foundation f sits at position
# i in fwd and K-1-i in rev, so its average position is the constant
# (K-1)/2 across all foundations.
score: dict[str, float]
p: dict[str, float] # softmax over the K options of `score`
top1: str
margin: float # score[top1] - score[top2], in nats
# Per-sample think lengths and close flags. Length N per direction.
think_tokens: list[int] # fwd think lengths
think_tokens_rev: list[int] # rev think lengths
emitted_close: list[bool] # fwd close flags
emitted_close_rev: list[bool] # rev close flags
# Sum of probability mass over the K foundation answer-tokens at the
# JSON answer slot, averaged across the N samples per direction first,
# then across fwd + rev framings. In [0, 1]; high means the model still
# emits a valid foundation word in the slot; low means probability has
# leaked to other tokens (gibberish, refusal, format collapse). Direct
# coherence check for forced-choice, independent of WHICH foundation
# is picked.
pmass_allowed: float
# Mean negative log-likelihood in nats/token over the assistant prefill
# content, averaged across samples and fwd + rev framings. Perplexity is
# `exp(nll_json)`.
nll_json: float
def _resolve_first_token_ids(tok, words: list[str]) -> tuple[list[int], dict[str, int]]:
"""Return (ids_in_order, str->id). Each id is the first token of the string when
it appears immediately after a `"` in JSON, i.e. with no leading space. Asserts the
first-tokens are distinct.
Called with the option-index labels "0".."K-1": the forced-choice prefill ends
`... "violation": "` so the model's next token is the first piece of the quoted
value, which for a single-digit index is the whole digit (clean single token)."""
ids: list[int] = []
mapping: dict[str, int] = {}
for w in words:
toks = tok.encode(w, add_special_tokens=False)
assert toks, f"tokenizer returned empty for {w!r}"
ids.append(toks[0])
mapping[w] = toks[0]
assert len(set(ids)) == len(ids), (
f"first-token collision among forced-choice words: "
f"{[(w, i, tok.decode([i])) for w, i in mapping.items()]}"
)
return ids, mapping
def guided_rollout_forced_choice(
model, tok,
user_prompts: list[str],
foundations: list[str] | None = None,
*,
max_think_tokens: int = 64,
n_samples: int = 1,
temperature: float = 0.0,
top_p: float = 1.0,
skip_special_tokens: bool = False,
schema_hint: str | None = None,
verbose: bool = False,
) -> list[ForcedChoiceResult]:
"""K-way forced-choice over MFT foundations with two-pass position-bias debias.
Why K-way: pairwise yes/no probes (multibool) leak a global "badness" prior
across foundations, collapsing per-foundation discrimination. A K-way
softmax over the option-index answer digits forces exactly one and sidesteps
that prior. The answer is the digit, not the foundation word, because the words
tokenize into uneven first pieces (fragments like "fair"/"san"/"lib") whose
per-word prior is constant across frames and so survives the debias.
Why two passes (forward + reversed enum):
- Position bias is real: models often favour the first option listed.
- If foundation `f` is at position `i` in the forward enum, it's at position
`K-1-i` in the reversed enum (and is labelled with digit `i` then `K-1-i`).
Averaging logprobs across the two frames makes both the position contribution
AND the digit-magnitude prior `(P[i] + P[K-1-i]) / 2` -- a constant
independent of `f`, so they fall out of the softmax over foundations.
- Two SEPARATE thinks (one per ordering) keep the framing internally
consistent. We don't try to share KV across orderings since the schema
text itself differs.
Format (one user turn per frame):
<user>
This is wrong because of which moral foundation
> {scenario}
{JSON-pseudo-schema with enum + # comments}
</user>
<assistant>
<think>...</think>
</assistant>
<user>Just answer</user>
<assistant>This is wrong because {"violation": "<scored token>
"""
if foundations is None:
foundations = list(_DEFAULT_FORCED_FOUNDATIONS)
K = len(foundations)
assert K >= 2, "need >=2 foundations for forced choice"
foundations_rev = list(reversed(foundations))
schema_fwd = schema_hint if schema_hint is not None else _make_forced_hint(foundations)
schema_rev = _make_forced_hint(foundations_rev)
# Scored answer token is the option-INDEX digit (see _format_options), not the
# foundation word. Same digit set both frames; index = listing position, so in the
# forward frame digit k == foundations[k] and in the reversed frame digit k ==
# foundations_rev[k] == foundations[K-1-k] (the rev gather is flipped back below).
position_labels = [str(k) for k in range(K)]
first_ids, _ = _resolve_first_token_ids(tok, position_labels)
nudge = "Just answer"
prefill = 'This is wrong because {"violation": "'
scoring_slot = [(nudge, prefill)]
# Frame A: forward enum order.
thinks_fwd, slots_fwd = _rollout_natural_or_forced(
model, tok, user_prompts, schema_fwd, max_think_tokens,
scoring_slots=scoring_slot,
gather_token_ids=first_ids,
n_samples=n_samples, temperature=temperature, top_p=top_p,
skip_special_tokens=skip_special_tokens,
verbose=verbose,
)
# Frame B: reversed enum order. Same gather order (by foundation name) so
# lp_rev[f] is comparable to lp_fwd[f].
thinks_rev, slots_rev = _rollout_natural_or_forced(
model, tok, user_prompts, schema_rev, max_think_tokens,
scoring_slots=scoring_slot,
gather_token_ids=first_ids,
n_samples=n_samples, temperature=temperature, top_p=top_p,
skip_special_tokens=skip_special_tokens,
verbose=verbose,
)
B = len(user_prompts)
N = n_samples
assert len(thinks_fwd) == B * N and len(thinks_rev) == B * N, (
f"expected B*N={B*N} thinks per direction, got "
f"fwd={len(thinks_fwd)} rev={len(thinks_rev)}"
)
import math
results: list[ForcedChoiceResult] = []
for i in range(B):
# Per-prompt slices of length N (HF lays out as [in_i_s_0, ..., in_i_s_(N-1), ...]).
idx = [i * N + n for n in range(N)]
gens_fwd = [thinks_fwd[j][0] for j in idx]
n_fwd_list = [thinks_fwd[j][1] for j in idx]
close_fwd_list = [thinks_fwd[j][2] for j in idx]
gens_rev = [thinks_rev[j][0] for j in idx]
n_rev_list = [thinks_rev[j][1] for j in idx]
close_rev_list = [thinks_rev[j][2] for j in idx]
# Raw per-sample logprob matrices, shape [N, K], in foundation order.
# Forward frame: digit k == foundations[k], so the gather is already in order.
lp_f_samples = [slots_fwd[j][0]["lp_gather"] for j in idx]
# Reversed frame: digit k == foundations[K-1-k], so reverse each gather vector
# back to canonical foundation order. (Position AND digit-magnitude bias then
# cancel in the fwd/rev mean, since each foundation is scored on two different digits.)
lp_r_samples = [list(reversed(slots_rev[j][0]["lp_gather"])) for j in idx]
log_N = math.log(N)
# BMA per direction: logsumexp_n lp_samples[n, k] - log(N).
def _bma(samples: list[list[float]]) -> list[float]:
out = []
for k in range(K):
vals = [samples[n][k] for n in range(N)]
m = max(vals)
out.append(m + math.log(sum(math.exp(v - m) for v in vals)) - log_N)
return out
lp_f = _bma(lp_f_samples)
lp_r = _bma(lp_r_samples)
score = [(lp_f[k] + lp_r[k]) / 2.0 for k in range(K)]
m = max(score)
exps = [math.exp(x - m) for x in score]
Z = sum(exps)
p = {foundations[k]: exps[k] / Z for k in range(K)}
order_sorted = sorted(range(K), key=lambda k: -score[k])
top1 = foundations[order_sorted[0]]
margin = score[order_sorted[0]] - score[order_sorted[1]]
# Average pmass_allowed and nll_json across N samples per direction, then across
# fwd + rev framings.
pm_f = sum(slots_fwd[j][0]["pmass_allowed"] for j in idx) / N
pm_r = sum(slots_rev[j][0]["pmass_allowed"] for j in idx) / N
pm = 0.5 * (pm_f + pm_r)
nll_f = sum(slots_fwd[j][0]["nll_json"] for j in idx) / N
nll_r = sum(slots_rev[j][0]["nll_json"] for j in idx) / N
nll_json = 0.5 * (nll_f + nll_r)
results.append(ForcedChoiceResult(
user_prompt=user_prompts[i],
gen_text=gens_fwd,
gen_text_rev=gens_rev,
lp_fwd={foundations[k]: lp_f[k] for k in range(K)},
lp_rev={foundations[k]: lp_r[k] for k in range(K)},
lp_fwd_samples=lp_f_samples,
lp_rev_samples=lp_r_samples,
score={foundations[k]: score[k] for k in range(K)},
p=p,
top1=top1,
margin=float(margin),
think_tokens=n_fwd_list,
think_tokens_rev=n_rev_list,
emitted_close=close_fwd_list,
emitted_close_rev=close_rev_list,
pmass_allowed=float(pm),
nll_json=float(nll_json),
))
return results
@torch.no_grad()
def free_generation_demo(
model, tok, user_prompt: str, *,
foundations: list[str] | None = None,
max_think_tokens: int = 512,
temperature: float = 0.0,
top_p: float = 1.0,
) -> tuple[str, str]:
"""One bs=1 free generation on a single vignette, for qualitative inspection.
The forced-choice readout (guided_rollout_forced_choice) prefills the answer
slot to read calibrated logprobs, so it shows no real reasoning -- at a small
think budget its trace is just prompt + a token + the slot. This instead lets
the model think to completion and answer naturally (no forced suffix, EOS
allowed), so you see the chain-of-thought the metric never reveals. Same
vignette + schema as the readout, so the reasoning is about the same task.
Returns (prompt_text, gen_text), both with special tokens shown."""
if foundations is None:
foundations = list(_DEFAULT_FORCED_FOUNDATIONS)
schema = _make_forced_hint(foundations)
prompt_text = tok.apply_chat_template(
[{"role": "user", "content": f"{user_prompt}\n\n{schema}"}],
tokenize=False, add_generation_prompt=True) + "<think>\n"
device = next(model.parameters()).device
enc = tok(prompt_text, return_tensors="pt", add_special_tokens=False).to(device)
pad_id = tok.pad_token_id if tok.pad_token_id is not None else tok.eos_token_id
gen_kwargs = dict(max_new_tokens=max_think_tokens, pad_token_id=pad_id)
if temperature > 0.0:
gen_kwargs.update(do_sample=True, temperature=temperature, top_p=top_p)
else:
gen_kwargs.update(do_sample=False)
out = model.generate(**enc, **gen_kwargs)
gen_text = tok.decode(out[0, enc.input_ids.shape[1]:], skip_special_tokens=False)
return prompt_text, gen_text