mirror of
https://github.com/wassname/moral-maps.git
synced 2026-09-10 12:14:54 +08:00
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>
This commit is contained in:
@@ -25,7 +25,7 @@ from .eval import evaluate, CONDITIONS
|
||||
from .guided import guided_rollout_forced_choice, _DEFAULT_FORCED_FOUNDATIONS
|
||||
from .instrument import Instrument, InstrItem, per_item_categorical
|
||||
from .instruments import get as get_instrument, INSTRUMENTS, build_instrument
|
||||
from .read import read_items, resolve_answer_ids, build_prompt
|
||||
from .read import read_items, resolve_answer_ids, build_user_content
|
||||
from .administer import administer
|
||||
|
||||
|
||||
|
||||
@@ -51,16 +51,20 @@ class AdministerResult(TypedDict):
|
||||
mean_pmass_allowed: float # coherence check (mass on valid answer tokens)
|
||||
|
||||
|
||||
def administer(model, tok, instr: Instrument, *, batch_size: int = 36) -> AdministerResult:
|
||||
def administer(model, tok, instr: Instrument, *, batch_size: int = 36,
|
||||
max_think_tokens: int = 64) -> AdministerResult:
|
||||
assert instr.kind == "ordinal", "administer() is the ordinal survey readout; use evaluate() for nominal MFV"
|
||||
# Every ordinal item must carry its frame-specific response-scale legend in meta['task']; without
|
||||
# it build_prompt would silently emit a bare statement (no legend) and the profile would be junk
|
||||
# while pmass still looks fine. Fail loud.
|
||||
# it build_user_content would silently emit a bare statement (no legend) and the profile would be
|
||||
# junk while pmass still looks fine. Fail loud.
|
||||
assert all("task" in it.meta for it in instr.items), f"{instr.name}: ordinal items need meta['task']"
|
||||
w = np.arange(1, instr.scale_max + 1, dtype=float)
|
||||
answer_ids = resolve_answer_ids(tok, instr.answer_space)
|
||||
# max_think_tokens=64 is the spec's "light" default: the model thinks before the prefilled answer
|
||||
# slot, so an activation steer accrues over the trace before being read. Floor is 1 (the shared
|
||||
# rollout core's HF generate() rejects max_new_tokens=0).
|
||||
per_row = read_items(model, tok, instr, instr.items, answer_ids,
|
||||
batch_size=batch_size, verbose_first=True)
|
||||
max_think_tokens=max_think_tokens, batch_size=batch_size, verbose_first=True)
|
||||
items = per_item_categorical(per_row, instr.kind) # {id: {p, pmass, dimension, sign, ...}}
|
||||
|
||||
profile = reduce_ordinal(items, instr) # per-factor keyed agreement
|
||||
|
||||
@@ -97,6 +97,7 @@ def _rollout_natural_or_forced(
|
||||
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.
|
||||
@@ -205,8 +206,10 @@ def _rollout_natural_or_forced(
|
||||
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.
|
||||
windows: list[tuple[int, int] | None] = [
|
||||
# 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)
|
||||
]
|
||||
|
||||
+57
-36
@@ -23,10 +23,11 @@ here) is the tell that the prefill merged with the option and the readout went b
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from loguru import logger
|
||||
|
||||
from .guided import _rollout_natural_or_forced
|
||||
from .instrument import Instrument, InstrItem
|
||||
|
||||
|
||||
@@ -38,65 +39,85 @@ def resolve_answer_ids(tok, answer_space: list[str]) -> list[int]:
|
||||
return flat
|
||||
|
||||
|
||||
def build_prompt(tok, instr: Instrument, item: InstrItem) -> str:
|
||||
"""Chat-templated user turn + assistant prefill that forces the answer slot.
|
||||
def build_user_content(instr: Instrument, item: InstrItem) -> str:
|
||||
"""User-turn content (NOT chat-templated, NOT prefilled).
|
||||
|
||||
The user turn is `task\\n\\nStatement: <prompt>`. For ordinal surveys the task (response-scale
|
||||
legend) is FRAME-SPECIFIC -- inverted reverses the legend, negated negates the content -- so it
|
||||
travels per-item in `meta['task']`. Falls back to the instrument-level `schema_hint`, or the
|
||||
bare prompt (nominal vignettes)."""
|
||||
bare prompt (nominal vignettes).
|
||||
|
||||
Chat-templating + the assistant prefill that forces the answer slot are applied downstream by
|
||||
`_rollout_natural_or_forced` (so the readout shares the nominal MFV generate-think-then-read
|
||||
core); this just assembles the legend + statement the model thinks about."""
|
||||
task = item.meta.get("task") or instr.schema_hint
|
||||
content = f"{task}\n\nStatement: {item.prompt}" if task else item.prompt
|
||||
text = tok.apply_chat_template(
|
||||
[{"role": "user", "content": content}],
|
||||
tokenize=False, add_generation_prompt=True, enable_thinking=False,
|
||||
)
|
||||
return text + instr.prefill
|
||||
return f"{task}\n\nStatement: {item.prompt}" if task else item.prompt
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def read_items(model, tok, instr: Instrument, items: list[InstrItem], answer_ids: list[int],
|
||||
*, batch_size: int = 36, verbose_first: bool = False) -> list[dict]:
|
||||
*, max_think_tokens: int, batch_size: int = 36, verbose_first: bool = False) -> list[dict]:
|
||||
"""Score a list of InstrItems (one frame's worth, or any subset). Returns per-item rows with
|
||||
the keys `per_item_categorical` consumes: id, frame, p, pmass_allowed, dimension, sign, human_label."""
|
||||
device = next(model.parameters()).device
|
||||
the keys `per_item_categorical` consumes: id, frame, p, pmass_allowed, dimension, sign, human_label.
|
||||
|
||||
Goes through the SAME `_rollout_natural_or_forced` core the nominal MFV forced-choice path uses,
|
||||
so steering registers: the model generates up to `max_think_tokens` think tokens, then we read
|
||||
the answer slot under the assistant prefill. The only per-instrument difference vs the nominal
|
||||
path is the answer-token set (`answer_ids`) + reducer (downstream). The legend already lives in
|
||||
the user-turn content, so we pass `schema_hint=""` and mirror the nominal nudge `"Just answer"`.
|
||||
|
||||
`_rollout_natural_or_forced` forwards the whole batch at once, so we chunk `user_prompts` here
|
||||
and concatenate, preserving item order.
|
||||
|
||||
Floor: `max_think_tokens >= 1`. The rollout core calls HF `generate(max_new_tokens=...)`, which
|
||||
rejects 0 (`max_new_tokens must be greater than 0`). think=1 is the minimum budget.
|
||||
"""
|
||||
if tok.pad_token is None:
|
||||
tok.pad_token = tok.eos_token
|
||||
tok.padding_side = "left"
|
||||
gid = torch.tensor(answer_ids, device=device)
|
||||
|
||||
out: list[dict] = []
|
||||
for i in range(0, len(items), batch_size):
|
||||
chunk = items[i:i + batch_size]
|
||||
texts = [build_prompt(tok, instr, it) for it in chunk]
|
||||
enc = tok(texts, return_tensors="pt", padding=True, add_special_tokens=False).to(device)
|
||||
logits = model(**enc).logits[:, -1, :].float() # [B, V] next-token
|
||||
logp = F.log_softmax(logits, dim=-1)
|
||||
p_a = logp[:, gid].exp() # [B, A] prob on each answer token
|
||||
pmass = p_a.sum(dim=-1) # [B] coherence check: mass on allowed tokens
|
||||
# Renormalize within allowed. INTENTIONALLY NOT NaN-guarded: at full coherence collapse
|
||||
# pmass -> 0 so p_norm -> NaN and poisons that item's factor. That is the honest signal, a
|
||||
# distribution renormalized from ~zero mass is NOT comparable to one from real mass (the mean
|
||||
# of 10 != the mean of 130), so it must not be silently turned into a comparable-looking
|
||||
# number. NaN marks "do not compare". Do not "fix" this with a softmax/eps fallback.
|
||||
p_norm = p_a / pmass[:, None] # [B, A] within allowed (NaN at collapse, by design)
|
||||
user_prompts = [build_user_content(instr, it) for it in chunk]
|
||||
# ordinal frames are already separate InstrItems, so single-pass (no reversed-enum two-pass;
|
||||
# frame debias is downstream in canonicalize_to_forward). force_only: the "(" prefill is too
|
||||
# short for natural-emission detection (matches by chance in the think trace), so always read
|
||||
# the forced answer slot. n_samples=1, temperature=0 -> deterministic.
|
||||
_thinks, slots = _rollout_natural_or_forced(
|
||||
model, tok, user_prompts,
|
||||
schema_hint="", max_think_tokens=max_think_tokens,
|
||||
scoring_slots=[("Just answer", instr.prefill)],
|
||||
gather_token_ids=answer_ids,
|
||||
n_samples=1, temperature=0.0, force_only=True,
|
||||
verbose=verbose_first and i == 0,
|
||||
)
|
||||
for j, it in enumerate(chunk):
|
||||
slot = slots[j][0]
|
||||
# lp_gather[k] is the full-vocab log_softmax logprob of answer token k at the answer slot.
|
||||
p_a = np.exp(np.asarray(slot["lp_gather"], dtype=float)) # [A] prob on each answer token
|
||||
pmass = float(slot["pmass_allowed"]) # mass on allowed tokens (coherence)
|
||||
# Renormalize within allowed. INTENTIONALLY NOT NaN-guarded: at full coherence collapse
|
||||
# pmass -> 0 so p_norm -> NaN and poisons that item's factor. That is the honest signal, a
|
||||
# distribution renormalized from ~zero mass is NOT comparable to one from real mass (the mean
|
||||
# of 10 != the mean of 130), so it must not be silently turned into a comparable-looking
|
||||
# number. NaN marks "do not compare". Do not "fix" this with a softmax/eps fallback.
|
||||
p_norm = p_a / p_a.sum() # [A] within allowed (NaN at collapse, by design)
|
||||
out.append({
|
||||
"id": it.id, "frame": it.frame,
|
||||
"p": p_norm[j].cpu().numpy(),
|
||||
"pmass_allowed": float(pmass[j]),
|
||||
"p": p_norm,
|
||||
"pmass_allowed": pmass,
|
||||
"dimension": it.dimension, "sign": it.sign,
|
||||
"human_label": it.human_label,
|
||||
})
|
||||
if verbose_first and i == 0:
|
||||
top = logp[0].topk(10)
|
||||
toks = " ".join(f"{tok.decode([int(t)])!r}:{float(p.exp()):.3f}"
|
||||
for t, p in zip(top.indices, top.values))
|
||||
slot0 = slots[0][0]
|
||||
answer_p = {a: float(np.exp(lp)) for a, lp in zip(instr.answer_space, slot0["lp_gather"])}
|
||||
logger.debug(
|
||||
f"\n=== TRACE read first item ({instr.name}, special tokens on) ===\n"
|
||||
f"--- PROMPT+PREFILL ---\n{texts[0]}\n"
|
||||
f"--- top-10 next tokens ---\n{toks}\n"
|
||||
f"\n=== TRACE read first item ({instr.name}, think={max_think_tokens}) ===\n"
|
||||
f"--- top-5 next tokens at answer slot ---\n{slot0['top5_str']}\n"
|
||||
f"--- answer_space probs ---\n{answer_p}\n"
|
||||
f"SHOULD: top tokens are the answer_space {instr.answer_space} (format locked by the "
|
||||
f"{instr.prefill!r} prefill); pmass_allowed={float(pmass[0]):.3f} near 1.0 -> coherent. "
|
||||
f"ELSE the prefill or chat template is off.\n")
|
||||
f"{instr.prefill!r} prefill); pmass_allowed={float(slot0['pmass_allowed']):.3f} near 1.0 "
|
||||
f"-> coherent. ELSE the prefill or chat template is off.\n")
|
||||
return out
|
||||
|
||||
Reference in New Issue
Block a user