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.
454 lines
16 KiB
Python
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())
|