core/scripts/measure_trackb_s1_slice.py
Shay 943614c522 feat(trackb): S1 symbolic structure-mapping vertical slice (off-serving)
ADR-0252 §5 geometric SME is NO-GO; this is Track B Increment 1.
Adds role-predicate conversion from MathProblemGraph, S1 canonical skeleton,
blind symbolic mapper (match/refuse + binding), and solve via classical
verify plus multi-register certificate. Research report and holdout measures
included. Serving reader unchanged; no S2–S4 generalization.
2026-07-19 19:01:23 -07:00

454 lines
16 KiB
Python

"""Blind measurement for Track B Increment 1 (S1 symbolic structure-mapping).
Usage (from worktree root)::
uv run python scripts/measure_trackb_s1_slice.py --mode right-reason
uv run python scripts/measure_trackb_s1_slice.py --mode false-positive
uv run python scripts/measure_trackb_s1_slice.py --mode surface-variant
uv run python scripts/measure_trackb_s1_slice.py --mode vs-organ
uv run python scripts/measure_trackb_s1_slice.py --mode all
Labels are loaded only in scoring branches (after map decisions). The mapper
API is never passed a structure label.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from evals.structure_mapping.scoring.labels import (
S1_HOLDOUT_CASE_IDS,
load_structure_labels,
score_label,
)
from generate.math_candidate_graph import parse_and_solve
from generate.structure_mapping.convert import graph_to_role_graph
from generate.structure_mapping.mapper import StructureMapRefuse, StructureMapResult, map_to_s1
from generate.structure_mapping.solve_s1 import try_s1_structure_map_and_solve
HOLDOUT_CASES = Path("evals/gsm8k_math/holdout_dev/v1/cases.jsonl")
def _load_cases() -> dict[str, dict]:
out: dict[str, dict] = {}
for line in HOLDOUT_CASES.read_text(encoding="utf-8").splitlines():
row = json.loads(line)
out[row["id"]] = row
return out
def _map_decision(problem: str) -> dict:
"""Parse → convert → map. Never loads labels."""
parsed = parse_and_solve(problem)
if parsed.selected_graph is None:
return {
"parsed": False,
"mapped_s1": False,
"refuse": parsed.refusal_reason or "no_graph",
"binding": None,
"role_kinds": [],
"organ_answer": parsed.answer,
"organ_refusal": parsed.refusal_reason,
}
rg = graph_to_role_graph(parsed.selected_graph)
mapped = map_to_s1(rg)
if isinstance(mapped, StructureMapRefuse):
return {
"parsed": True,
"mapped_s1": False,
"refuse": mapped.reason,
"binding": None,
"role_kinds": sorted(rg.kinds()),
"organ_answer": parsed.answer,
"organ_refusal": parsed.refusal_reason,
"graph": parsed.selected_graph,
"role_graph": rg,
}
assert isinstance(mapped, StructureMapResult)
return {
"parsed": True,
"mapped_s1": True,
"refuse": None,
"binding": dict(mapped.binding),
"role_kinds": sorted(rg.kinds()),
"organ_answer": parsed.answer,
"organ_refusal": parsed.refusal_reason,
"graph": parsed.selected_graph,
"role_graph": rg,
}
def mode_right_reason() -> int:
cases = _load_cases()
labels = load_structure_labels() # scoring only, after decisions
print("=== S1 right-for-right-reason (holdout_dev/v1 S1 cohort) ===")
wrong = 0
correct = 0
refused = 0
for cid in sorted(S1_HOLDOUT_CASE_IDS):
c = cases[cid]
gold = float(c["expected_answer"])
dec = _map_decision(c["problem"])
# score label AFTER decision
sc = score_label(cid, dec["mapped_s1"], labels)
if not dec["mapped_s1"]:
refused += 1
print(
f"CASE {cid} REFUSE map={dec['refuse']} organ_ans={dec['organ_answer']} "
f"gold={gold} score={sc}"
)
continue
assert dec["graph"] is not None
out = try_s1_structure_map_and_solve(graph=dec["graph"])
if not out.emitted or out.answer is None:
refused += 1
print(
f"CASE {cid} REFUSE solve={out.refusal_reason} binding={out.binding} "
f"gold={gold} derivation={out.derivation}"
)
continue
ok = abs(out.answer - gold) <= 1e-6 * max(1.0, abs(gold))
if ok:
correct += 1
else:
wrong += 1
print(
f"CASE {cid} emit={out.answer} gold={gold} match={ok} wrong_flag={not ok} "
f"derivation={out.derivation!r} "
f"mr_cert={out.multi_register_certified} classical_v={out.classical_verified} "
f"binding={out.binding} score={sc}"
)
print(
f"SUMMARY right-reason: correct={correct} wrong={wrong} refused={refused} "
f"n={len(S1_HOLDOUT_CASE_IDS)}"
)
return 0 if wrong == 0 else 2
def mode_false_positive() -> int:
"""Separability: S1-mapper must not map non-S1 structures.
Layer A — holdout_dev/v1 non-S1 cohort (honest about vacuous parse layer).
Layer B — synthetic non-S1 MathProblemGraphs (transfer/rate/no-compare/…).
Layer C — other gsm8k_math corpora: every parsed graph whose ops are not
pure compare_multiplicative (real non-S1 graphs the reader can emit).
"""
from generate.math_problem_graph import (
Comparison,
InitialPossession,
MathProblemGraph,
Operation,
Quantity,
Rate,
Unknown,
)
cases = _load_cases()
print("=== A: holdout_dev/v1 non-S1 cases (parse→map) ===")
fp = 0
tn = 0
parse_fail = 0
n = 0
fp_ids: list[str] = []
for cid, c in cases.items():
if cid in S1_HOLDOUT_CASE_IDS:
continue
n += 1
dec = _map_decision(c["problem"])
if not dec["parsed"]:
parse_fail += 1
tn += 1
continue
if dec["mapped_s1"]:
fp += 1
fp_ids.append(cid)
print(
f"FP {cid} binding={dec['binding']} kinds={dec['role_kinds']} "
f"problem={c['problem'][:100]!r}"
)
else:
tn += 1
rate = fp / n if n else 0.0
print(
f"SUMMARY holdout-non-S1: fp={fp} tn={tn} n_non_s1={n} "
f"parse_fail_among_non_s1={parse_fail} fp_rate={rate:.6f}"
)
print(
"NOTE: holdout_dev/v1 currently yields selected_graph on only the 5 S1 "
"organ cases; non-S1 holdout FP is vacuous at the graph layer when "
f"parse_fail={parse_fail}. Layer B/C are the real separability tests."
)
if fp_ids:
print("FP_IDS", json.dumps(fp_ids))
print("=== B: synthetic non-S1 graphs ===")
synth: list[tuple[str, MathProblemGraph]] = [
(
"transfer",
MathProblemGraph(
entities=("Sam", "Alex"),
initial_state=(
InitialPossession("Sam", Quantity(10, "apples")),
InitialPossession("Alex", Quantity(2, "apples")),
),
operations=(
Operation(
"Sam", "transfer", Quantity(3, "apples"), target="Alex"
),
),
unknown=Unknown("Sam", "apples"),
),
),
(
"contain_total_no_compare",
MathProblemGraph(
entities=("A", "B"),
initial_state=(
InitialPossession("A", Quantity(5, "x")),
InitialPossession("B", Quantity(7, "x")),
),
operations=(),
unknown=Unknown(None, "x"),
),
),
(
"compare_no_total",
MathProblemGraph(
entities=("A", "B"),
initial_state=(InitialPossession("A", Quantity(5, "x")),),
operations=(
Operation(
"B",
"compare_multiplicative",
Comparison("A", None, 2.0, "times"),
),
),
unknown=Unknown("B", "x"),
),
),
(
"compare_with_b_seed",
MathProblemGraph(
entities=("A", "B"),
initial_state=(
InitialPossession("A", Quantity(5, "x")),
InitialPossession("B", Quantity(10, "x")),
),
operations=(
Operation(
"B",
"compare_multiplicative",
Comparison("A", None, 2.0, "times"),
),
),
unknown=Unknown(None, "x"),
),
),
(
"apply_rate",
MathProblemGraph(
entities=("shop",),
initial_state=(InitialPossession("shop", Quantity(3, "apples")),),
operations=(
Operation(
"shop", "apply_rate", Rate(2.0, "dollars", "apples")
),
),
unknown=Unknown("shop", "dollars"),
),
),
]
s_fp = s_tn = 0
for name, g in synth:
rg = graph_to_role_graph(g)
mapped = map_to_s1(rg)
is_map = isinstance(mapped, StructureMapResult)
reason = (
mapped.reason if isinstance(mapped, StructureMapRefuse) else None
)
print(f" {name}: mapped_s1={is_map} refuse={reason} kinds={sorted(rg.kinds())}")
if is_map:
s_fp += 1
else:
s_tn += 1
print(
f"SUMMARY synthetic: fp={s_fp} tn={s_tn} n={s_fp + s_tn} "
f"fp_rate={(s_fp / (s_fp + s_tn) if s_fp + s_tn else 0):.6f}"
)
print("=== C: other gsm8k_math corpora non-compare graphs ===")
c_fp = c_tn = 0
fp_examples: list[object] = []
for path in Path("evals/gsm8k_math").rglob("cases.jsonl"):
if "holdout_dev" in str(path):
continue
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
row = json.loads(line)
text = row.get("problem") or row.get("question")
if not text:
continue
parsed = parse_and_solve(text)
if parsed.selected_graph is None:
continue
kinds = {op.kind for op in parsed.selected_graph.operations}
if "compare_multiplicative" in kinds:
continue # not a non-S1 negative for this check
rg = graph_to_role_graph(parsed.selected_graph)
mapped = map_to_s1(rg)
if isinstance(mapped, StructureMapResult):
c_fp += 1
fp_examples.append(
{
"path": str(path),
"id": row.get("id") or row.get("case_id"),
"kinds": sorted(kinds),
"binding": dict(mapped.binding),
}
)
else:
c_tn += 1
print(
f"SUMMARY other-corpus non-S1 graphs: fp={c_fp} tn={c_tn} "
f"n={c_fp + c_tn} fp_rate={(c_fp / (c_fp + c_tn) if c_fp + c_tn else 0):.6f}"
)
if fp_examples:
print("FP_EXAMPLES", json.dumps(fp_examples[:20]))
return 0 if (fp + s_fp + c_fp) == 0 else 2
def mode_surface_variant() -> int:
"""Rename entity + change numbers in text; re-parse; must still map S1."""
cases = _load_cases()
print("=== surface-variant generalization (re-parse) ===")
# Use 0108: clear entities and numbers
base_id = "gsm8k-holdout-dev-v1-0108"
base = cases[base_id]["problem"]
# Original: Dana Point beach has four times the number of sharks as Newport Beach.
# If Newport Beach has 22 sharks, how many sharks are there in total on the two beaches?
variant = (
"Cedar Cove beach has five times the number of dolphins as Harbor Beach. "
"If Harbor Beach has 11 dolphins, how many dolphins are there in total on the two beaches?"
)
print("BASE_ID", base_id)
print("BASE_TEXT", base)
print("VARIANT_TEXT", variant)
base_dec = _map_decision(base)
var_dec = _map_decision(variant)
print("BASE_MAP", {k: base_dec[k] for k in ("mapped_s1", "refuse", "binding", "role_kinds")})
print("VARIANT_MAP", {k: var_dec[k] for k in ("mapped_s1", "refuse", "binding", "role_kinds")})
if not var_dec["mapped_s1"]:
print("SUMMARY surface-variant: FAIL not_mapped")
return 2
b = var_dec["binding"]
assert b is not None
ok_k = abs(float(b["k"]) - 5.0) < 1e-9
ok_a = abs(float(b["a_value"]) - 11.0) < 1e-9
# entity rename: Harbor / Cedar (parser may normalize casing/names)
a_name = str(b["a"]).lower()
b_name = str(b["b"]).lower()
ok_names = ("harbor" in a_name) and ("cedar" in b_name or "cove" in b_name)
out = try_s1_structure_map_and_solve(graph=var_dec["graph"])
expected = 11.0 * (1.0 + 5.0) # 66
ans_ok = (
out.emitted
and out.answer is not None
and abs(out.answer - expected) <= 1e-6 * expected
)
print(
f"VARIANT_SOLVE emitted={out.emitted} answer={out.answer} expected={expected} "
f"derivation={out.derivation!r} refuse={out.refusal_reason}"
)
print(
f"SUMMARY surface-variant: map={var_dec['mapped_s1']} k_ok={ok_k} a_ok={ok_a} "
f"names_ok={ok_names} ans_ok={ans_ok} binding={b}"
)
return 0 if (var_dec["mapped_s1"] and ok_k and ok_a and ans_ok) else 2
def mode_vs_organ() -> int:
cases = _load_cases()
print("=== side-by-side vs S1 surface organ (parse_and_solve) ===")
print(
f"{'case_id':<32} {'organ':>10} {'trackb':>10} {'gold':>10} "
f"{'organ_ok':>8} {'tb_ok':>8} {'map':>6}"
)
for cid in sorted(S1_HOLDOUT_CASE_IDS):
c = cases[cid]
gold = float(c["expected_answer"])
organ = parse_and_solve(c["problem"])
dec = _map_decision(c["problem"])
tb_ans = None
if dec["mapped_s1"] and dec.get("graph") is not None:
out = try_s1_structure_map_and_solve(graph=dec["graph"])
tb_ans = out.answer if out.emitted else None
organ_ok = organ.answer is not None and abs(organ.answer - gold) <= 1e-6 * max(
1.0, abs(gold)
)
tb_ok = tb_ans is not None and abs(tb_ans - gold) <= 1e-6 * max(1.0, abs(gold))
print(
f"{cid:<32} {organ.answer!s:>10} {tb_ans!s:>10} {gold!s:>10} "
f"{str(organ_ok):>8} {str(tb_ok):>8} {str(dec['mapped_s1']):>6}"
)
print("SUMMARY vs-organ: table above; organ is serving parse_and_solve path")
return 0
def mode_coverage() -> int:
"""Parse→role coverage on S1 holdout cases."""
cases = _load_cases()
print("=== parse→role coverage on S1 holdout ===")
for cid in sorted(S1_HOLDOUT_CASE_IDS):
c = cases[cid]
dec = _map_decision(c["problem"])
print(
f"{cid} parsed={dec['parsed']} kinds={dec['role_kinds']} "
f"mapped_s1={dec['mapped_s1']} refuse={dec['refuse']}"
)
return 0
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument(
"--mode",
choices=(
"right-reason",
"false-positive",
"surface-variant",
"vs-organ",
"coverage",
"all",
),
required=True,
)
args = p.parse_args(argv)
modes = {
"right-reason": mode_right_reason,
"false-positive": mode_false_positive,
"surface-variant": mode_surface_variant,
"vs-organ": mode_vs_organ,
"coverage": mode_coverage,
}
if args.mode == "all":
rc = 0
for name in (
"coverage",
"right-reason",
"false-positive",
"surface-variant",
"vs-organ",
):
print()
r = modes[name]()
rc = rc or r
return rc
return modes[args.mode]()
if __name__ == "__main__":
sys.exit(main())