instrument: Option-3 spec (one reader, reducer-only variation) + canonicalize-at-reader

Unifies forced-choice (nominal) and Likert (ordinal, expectation over the integer
distribution) on tinymfv's answer-token reader. Per scientist panel (docs/reviews/
sci_ma_*.md): canonicalize every frame's distribution to one forward orientation before
metrics+reducer (fixes the asymmetric nominal-reader/ordinal-reducer reflection), renormalize
p with pmass kept as canary, add ordinal |E-error| metric, keying applied only in the profile
reducer (proven orthogonal to framing, not a double-flip), cross-scale guard, negative-control
shuffle. 8 pure-function unit tests pass.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
This commit is contained in:
wassname
2026-06-21 10:12:05 +08:00
co-authored by Claudypoo
parent b726db958b
commit c173efa7b9
2 changed files with 291 additions and 0 deletions
+180
View File
@@ -0,0 +1,180 @@
"""Instrument spec: one answer-token reader, many questionnaires (Option 3).
Every instrument is the SAME measurement: a softmax over an answer-token set at a prefilled
slot, with a think budget, debias, BMA over sampled traces, a pmass coherence canary, and
temperature calibration to human soft-labels. Per-instrument variation is small:
1. answer_space : tokens gathered. Nominal = foundation words (forced-choice MFV);
ordinal = scale points ['1'..'M'] (MFQ-2, Big5, 16PF, HSQ).
2. reducer : per-item canonical distribution -> profile over `dimensions`.
Nominal -> mean choice frequency (the answer IS the dimension).
Ordinal -> E[scale point] grouped by item dimension (expectation over the
integer distribution, not argmax), with reverse-keying applied HERE only.
3. scaffold : prompt + assistant prefill that forces the answer slot.
4. human_label : per-item forward-orientation distribution over answer_space.
Design corrected after a frontier scientist panel (docs/reviews/sci_ma_*.md). The panel's
central, agreed flaw: leaving the frame reflection in the reducer made nominal and ordinal
debias asymmetric and risked double-flipping. The fix unifies them:
CANONICALIZE-AT-READER. Every frame's gathered distribution is mapped to ONE forward
orientation before anything else (`canonicalize_to_forward`): nominal reorder frames reindex
to canonical answer order (the reader already gathers in canonical order, so this is identity);
ordinal `inverted`/`negated` frames reverse the probability vector (the distribution-level
analog of agreement = M+1 - E). The per-item object is then the mean of these canonical
distributions over frames -- a single forward-orientation categorical, used IDENTICALLY for:
- metrics: soft-NLL / JS / temperature T vs the forward human histogram (both kinds), plus
an ORDINAL metric (mean |E_model - E_human|) so the certifying metric is sensitive to the
expectation the profile actually uses; top1 / informedness only for nominal (an argmax
flip metric is meaningless on an ordered scale: 4-vs-5 != 1-vs-5).
- profile: the reducer. Keying (reverse-keyed items) is applied ONLY in reduce_ordinal,
because it is a pooling-into-factor correction, orthogonal to and composable with the
framing canonicalization -- not a double-correction.
`p` everywhere is renormalized over the allowed answer tokens (sums to 1); `pmass_allowed` is
the separate coherence canary, never mixed into the distribution.
"""
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Literal
import numpy as np
Kind = Literal["nominal", "ordinal"]
ORDINAL_REFLECT_FRAMES = {"inverted", "negated"} # frames whose meaning is flipped vs forward
@dataclass
class InstrItem:
id: str
prompt: str # user-turn content (vignette, or task + statement)
dimension: str | None = None # ordinal: the factor; nominal: None (read from answer)
sign: int = 1 # ordinal keying: -1 = reverse-keyed (pool-reflect, profile only)
frame: str = "forward" # forward | inverted | negated (canonicalized before use)
human_label: np.ndarray | None = None # forward-orientation dist over answer_space, sums to 1
meta: dict = field(default_factory=dict)
@dataclass
class Instrument:
name: str
construct: str # map-layer tag: "salience" | "endorsement" | "wrongness" ...
kind: Kind
answer_space: list[str] # tokens gathered at the answer slot
dimensions: list[str] # profile axes for the map
items: list[InstrItem]
prefill: str # assistant prefill that forces the answer slot
schema_hint: str | None = None # instruction appended to the user turn
answer_to_dim: dict[str, str] | None = None # nominal only: answer token -> dimension
scale_max: int = 5 # ordinal: top of the Likert scale (== len(answer_space))
human_scale_max: int = 5 # human reference scale; HSQ humans on 1-7 (see caveat below)
def __post_init__(self):
if self.kind == "ordinal":
assert len(self.answer_space) == self.scale_max, "ordinal answer_space must be 1..scale_max"
if self.kind == "nominal" and self.answer_to_dim is None:
self.answer_to_dim = {a: a for a in self.answer_space}
# Cross-scale caveat (panel: human_scale_max was unused): a 1-7 human histogram (HSQ)
# cannot share a 5-way soft-NLL/JS with a 1-5 model directly. Calibration for such
# instruments must project both to a common support (or report 0-1 endorsement only).
# Enforced loudly rather than silently mis-comparing:
if self.kind == "ordinal" and self.human_scale_max != self.scale_max:
assert all(it.human_label is None for it in self.items), (
f"{self.name}: human_scale_max={self.human_scale_max} != scale_max={self.scale_max}; "
"per-item categorical calibration needs a common support. Project the human "
"histogram to the model scale before attaching human_label, or leave it None.")
# --- canonicalization: every frame -> one forward orientation, BEFORE metrics or reducer ---
def canonicalize_to_forward(p: np.ndarray, frame: str, kind: Kind) -> np.ndarray:
"""Map a presented-orientation answer distribution to forward orientation.
nominal: reader gathers in canonical answer order already -> identity (reorder frames are
averaged as aligned probability vectors upstream).
ordinal: `inverted` (scale legend reversed) and `negated` (content negated) flip meaning, so
reverse the vector: p_forward[d] = p_presented[M+1-d]. This is the distribution-level
form of agreement = M+1 - E. NB negation is not a guaranteed-exact semantic
complement (panel caveat); the frame-disagreement diagnostic surfaces items where it
breaks. `forward` -> identity.
"""
p = np.asarray(p, dtype=float)
if kind == "ordinal" and frame in ORDINAL_REFLECT_FRAMES:
return p[::-1].copy()
return p
def per_item_categorical(per_row: list[dict], kind: Kind) -> dict[str, dict]:
"""Collapse (item, frame) rows to one forward-orientation categorical per item id.
Each row has: id, frame, p (renormalized over answer_space, sums to 1), pmass_allowed,
dimension, sign, human_label. Returns {id: {p, pmass, dimension, sign, human_label, n_frames,
frame_spread}} where p is the mean of canonicalized frame distributions and frame_spread is
the max L1 gap between any two canonical frames (the acquiescence/negation diagnostic).
"""
by_id: dict[str, list[dict]] = defaultdict(list)
for r in per_row:
by_id[r["id"]].append(r)
out: dict[str, dict] = {}
for iid, rows in by_id.items():
canon = [canonicalize_to_forward(r["p"], r["frame"], kind) for r in rows]
C = np.stack(canon)
spread = float(max((np.abs(C[i] - C[j]).sum()
for i in range(len(C)) for j in range(i + 1, len(C))), default=0.0))
r0 = rows[0]
out[iid] = {
"p": C.mean(axis=0),
"pmass": float(np.mean([r["pmass_allowed"] for r in rows])),
"dimension": r0.get("dimension"),
"sign": r0.get("sign", 1),
"human_label": r0.get("human_label"),
"n_frames": len(rows),
"frame_spread": spread,
}
return out
# --- profile reducers: the only kind-specific summary ---
def reduce_nominal(items: dict[str, dict], instr: Instrument) -> np.ndarray:
"""Forced-choice profile = mean choice frequency over the answer space, folded to dimensions."""
dim_idx = {d: j for j, d in enumerate(instr.dimensions)}
acc = np.zeros((len(items), len(instr.dimensions)))
for i, it in enumerate(items.values()):
for a, pa in zip(instr.answer_space, it["p"]):
acc[i, dim_idx[instr.answer_to_dim[a]]] += pa
return acc.mean(axis=0)
def reduce_ordinal(items: dict[str, dict], instr: Instrument) -> np.ndarray:
"""Likert profile = mean keyed agreement per dimension; agreement = E[scale point].
Keying lives here and ONLY here: a reverse-keyed item (sign<0) means agreeing with it scores
LOW on the factor, so reflect agreement = M+1 - E. The frame canonicalization already happened
in per_item_categorical, so this is the single, orthogonal keying step (no double-flip).
"""
w = np.arange(1, instr.scale_max + 1, dtype=float)
by_dim: dict[str, list[float]] = defaultdict(list)
for it in items.values():
E = float((it["p"] * w).sum())
agr = (instr.scale_max + 1 - E) if it["sign"] < 0 else E
by_dim[it["dimension"]].append(agr)
return np.array([float(np.mean(by_dim[d])) for d in instr.dimensions])
REDUCERS = {"nominal": reduce_nominal, "ordinal": reduce_ordinal}
def expected_value(p: np.ndarray, scale_max: int) -> float:
return float((np.asarray(p) * np.arange(1, scale_max + 1)).sum())
# --- negative control: shuffle item->dimension (ordinal) / answer->dim (nominal); profile
# correlation with the true profile must collapse to chance. A non-null result is only
# interpretable if this control passes (all three reviewers required it). ---
def shuffle_dimensions(items: dict[str, dict], rng: np.random.Generator) -> dict[str, dict]:
dims = [it["dimension"] for it in items.values()]
rng.shuffle(dims)
return {iid: {**it, "dimension": d} for (iid, it), d in zip(items.items(), dims)}
+111
View File
@@ -0,0 +1,111 @@
"""Unit tests for the instrument abstraction's pure functions (no model needed).
Covers the panel's flagged risks: frame canonicalization, the keying-vs-framing composition
(NOT a double-flip), renormalized p, ordinal expectation, nominal choice frequency, and the
negative control.
"""
import numpy as np
from tinymfv.instrument import (
Instrument, InstrItem, canonicalize_to_forward, per_item_categorical,
reduce_nominal, reduce_ordinal, expected_value, shuffle_dimensions,
)
M = 5
ONEHOT = {d: np.eye(M)[d - 1] for d in range(1, M + 1)} # ONEHOT[4] = mass on scale point 4
def _ord_instr(items):
return Instrument("t", "endorsement", "ordinal", ["1", "2", "3", "4", "5"],
["care", "authority"], items, prefill="(")
def test_canonicalize():
# forward + nominal -> identity; ordinal inverted/negated -> reversed vector
p = ONEHOT[5]
assert np.array_equal(canonicalize_to_forward(p, "forward", "ordinal"), p)
assert np.array_equal(canonicalize_to_forward(p, "inverted", "ordinal"), ONEHOT[1])
assert np.array_equal(canonicalize_to_forward(p, "negated", "ordinal"), ONEHOT[1])
assert np.array_equal(canonicalize_to_forward(p, "inverted", "nominal"), p) # nominal identity
def test_forward_expectation():
rows = [{"id": "1", "frame": "forward", "p": ONEHOT[4], "pmass_allowed": 1.0,
"dimension": "care", "sign": 1, "human_label": None}]
items = per_item_categorical(rows, "ordinal")
assert abs(expected_value(items["1"]["p"], M) - 4.0) < 1e-9
prof = reduce_ordinal(items, _ord_instr([]))
assert abs(prof[0] - 4.0) < 1e-9 # care
assert np.isnan(prof[1]) # authority has no items
def test_frame_consistency():
# Same item, forward vs inverted: after canonicalization the per-item categorical must agree.
fwd = [{"id": "1", "frame": "forward", "p": ONEHOT[1], "pmass_allowed": 1.0,
"dimension": "care", "sign": 1, "human_label": None}]
inv = [{"id": "1", "frame": "inverted", "p": ONEHOT[5], "pmass_allowed": 1.0,
"dimension": "care", "sign": 1, "human_label": None}]
e_fwd = expected_value(per_item_categorical(fwd, "ordinal")["1"]["p"], M)
e_inv = expected_value(per_item_categorical(inv, "ordinal")["1"]["p"], M)
assert abs(e_fwd - 1.0) < 1e-9 and abs(e_inv - 1.0) < 1e-9 # canonicalization makes them agree
def test_keying_is_not_double_flip():
# Reverse-keyed item ("I keep in the background", sign=-1) on an INVERTED scale.
# Model puts mass on presented "5". Panel claimed frame+keying double-corrects; show it does not.
# inverted: presented 5 -> canonical 1 (disagrees with the item) -> E_canon = 1.
# keying sign<0 (pool-reflect): agreement = M+1 - 1 = 5 = HIGH on the factor. Correct:
# disagreeing with "I keep in the background" == extraverted.
rows = [{"id": "x", "frame": "inverted", "p": ONEHOT[5], "pmass_allowed": 1.0,
"dimension": "care", "sign": -1, "human_label": None}]
items = per_item_categorical(rows, "ordinal")
assert abs(expected_value(items["x"]["p"], M) - 1.0) < 1e-9 # canonical agreement-with-item = 1
prof = reduce_ordinal(items, _ord_instr([]))
assert abs(prof[0] - 5.0) < 1e-9 # keyed factor score = 5, not 1 (no double flip)
# And it matches the SAME reverse-keyed item shown forward with the mirrored answer (mass on 1):
rows_fwd = [{"id": "x", "frame": "forward", "p": ONEHOT[1], "pmass_allowed": 1.0,
"dimension": "care", "sign": -1, "human_label": None}]
prof_fwd = reduce_ordinal(per_item_categorical(rows_fwd, "ordinal"), _ord_instr([]))
assert abs(prof[0] - prof_fwd[0]) < 1e-9
def test_frame_spread_diagnostic():
# forward mass on 1, inverted mass on presented 1 (-> canonical 5): canonical frames disagree
# maximally -> frame_spread = 2.0 (L1 between two disjoint one-hots).
rows = [{"id": "1", "frame": "forward", "p": ONEHOT[1], "pmass_allowed": 1.0,
"dimension": "care", "sign": 1, "human_label": None},
{"id": "1", "frame": "inverted", "p": ONEHOT[1], "pmass_allowed": 1.0,
"dimension": "care", "sign": 1, "human_label": None}]
items = per_item_categorical(rows, "ordinal")
assert abs(items["1"]["frame_spread"] - 2.0) < 1e-9
def test_reduce_nominal():
instr = Instrument("mfv", "salience", "nominal", ["care", "authority"],
["care", "authority"], [InstrItem("1", "v1")], prefill="(")
rows = [{"id": "1", "frame": "forward", "p": np.array([0.8, 0.2]), "pmass_allowed": 1.0},
{"id": "2", "frame": "forward", "p": np.array([0.4, 0.6]), "pmass_allowed": 1.0}]
prof = reduce_nominal(per_item_categorical(rows, "nominal"), instr)
assert np.allclose(prof, [0.6, 0.4]) # mean choice frequency
def test_negative_control_shuffle():
rng = np.random.default_rng(0)
rows = [{"id": str(i), "frame": "forward", "p": ONEHOT[(i % 5) + 1], "pmass_allowed": 1.0,
"dimension": "care" if i < 5 else "authority", "sign": 1, "human_label": None}
for i in range(10)]
items = per_item_categorical(rows, "ordinal")
shuffled = shuffle_dimensions(items, rng)
# same item ids, dimensions permuted
assert set(shuffled) == set(items)
assert [shuffled[k]["dimension"] for k in items] != [items[k]["dimension"] for k in items]
def test_cross_scale_guard():
# HSQ-like: human on 1-7, model on 1-5, with a human_label attached -> must raise.
import pytest
items = [InstrItem("1", "q", dimension="care", human_label=np.ones(5) / 5)]
with pytest.raises(AssertionError):
Instrument("hsq", "endorsement", "ordinal", ["1", "2", "3", "4", "5"],
["care"], items, prefill="(", human_scale_max=7)