Mapper required only that total include {a,b}; rebuild-from-binding then
dropped third entities and could emit a certified wrong answer (e.g. 15
instead of 115). Require exact total part-set {a,b}, refuse any contain
outside reference a, and agree with original-graph classical solve before
emit. Adds multi-entity refuse unit tests.
11 KiB
Track B, Increment 1 — Symbolic Structure-Mapping S1 vertical slice
Date: 2026-07-20
Branch: feat/trackb-symbolic-sme-s1
Paradigm: ADR-0252 (Expert Structure-Mapping on a Predictive-Processing Substrate)
§5 status: NO-GO for geometric SME (conformal_procrustes is Kabsch/Umeyama and presupposes role correspondence). PRs #83 and #84 closed as superseded.
Scope: S1 only (compare-multiplicative-then-total). Off-serving. No S2–S4. No serving cutover.
1. What shipped
| Piece | Path | Role |
|---|---|---|
| Role-predicate schema | generate/structure_mapping/role_predicate.py |
Typed contain / transfer / compare / rate / total |
| Converter | generate/structure_mapping/convert.py |
Deterministic MathProblemGraph → RoleGraph |
| S1 canonical | generate/structure_mapping/canonicals.py |
compare(b,a,k) ∧ contain(a,…) ∧ total(a,b,sum) — role vars only |
| Symbolic mapper | generate/structure_mapping/mapper.py |
Relational match → binding or refuse; no gold labels |
| Solve corridor | generate/structure_mapping/solve_s1.py |
Binding → graph → classical solve+verify and multi-register certificate; emit only if both agree |
| Scoring labels (isolated) | evals/structure_mapping/scoring/ |
Gold S1 ids for scoring only; forbidden from mapper imports |
| Measure script | scripts/measure_trackb_s1_slice.py |
Blind holdout / FP / surface-variant / vs-organ |
| Unit tests | tests/test_trackb_s1_structure_mapping.py |
16 tests driving shipped code |
Serving reader (parse_and_solve dispatch, resolve_promotable_*, chat/runtime.py) untouched (diff bytes vs main = 0).
2. Discipline checks
Blindness
- Mapper signature:
map_to_s1(role_graph: RoleGraph)— no label parameter. - Static audit:
generate/structure_mapping/does not importevals.structure_mapping.scoringor loadholdout_dev_v1_labels.jsonl. - Labels enter only after map decisions, in the measure script / scorer.
No crash-as-signal
- Solve path catches
MultiRegisterError/ classical failures and returns refuse outcomes; exceptions are never treated as residual scores.
wrong=0
- Emit only when classical verifier passes and multi-register chain is certified and answers agree within relative 1e-6.
3. Measurements (command + real output)
3a. Parse→role coverage on S1 holdout cohort
$ uv run python scripts/measure_trackb_s1_slice.py --mode coverage
=== parse→role coverage on S1 holdout ===
gsm8k-holdout-dev-v1-0101 parsed=True kinds=['compare', 'contain', 'total'] mapped_s1=True refuse=None
gsm8k-holdout-dev-v1-0108 parsed=True kinds=['compare', 'contain', 'total'] mapped_s1=True refuse=None
gsm8k-holdout-dev-v1-0268 parsed=True kinds=['compare', 'contain', 'total'] mapped_s1=True refuse=None
gsm8k-holdout-dev-v1-0411 parsed=True kinds=['compare', 'contain', 'total'] mapped_s1=True refuse=None
gsm8k-holdout-dev-v1-0453 parsed=True kinds=['compare', 'contain', 'total'] mapped_s1=True refuse=None
Coverage: 5/5 S1 organ cases parse → role graph with {compare, contain, total} and map to S1.
3b. Right-for-right-reason (S1 holdout)
$ uv run python scripts/measure_trackb_s1_slice.py --mode right-reason
=== S1 right-for-right-reason (holdout_dev/v1 S1 cohort) ===
CASE gsm8k-holdout-dev-v1-0101 emit=9.0 gold=9.0 match=True wrong_flag=False derivation='S1: Frankie = 2.0 × Eduardo; Eduardo = 3.0; total = Eduardo + Frankie = 3.0 + 2.0*3.0 = 9.0' mr_cert=True classical_v=True binding={'a': 'Eduardo', 'b': 'Frankie', 'k': 2.0, 'a_value': 3.0, 'unit': 'lasts', 'canonical_id': 'S1'} ...
CASE gsm8k-holdout-dev-v1-0108 emit=110.0 gold=110.0 match=True ... derivation='S1: Dana Point Beach = 4.0 × Newport Beach; ...'
CASE gsm8k-holdout-dev-v1-0268 emit=16.0 gold=16.0 match=True ... derivation='S1: Bruce Scored 4 Goals While Michael = 3.0 × Bruce; ...'
CASE gsm8k-holdout-dev-v1-0411 emit=52.0 gold=52.0 match=True ... derivation='S1: male students = 3.0 × female students; ...'
CASE gsm8k-holdout-dev-v1-0453 emit=28.0 gold=28.0 match=True ... derivation='S1: Olaf = 3.0 × dad; dad = 7.0; total = dad + Olaf = 7.0 + 3.0*7.0 = 28.0' ...
SUMMARY right-reason: correct=5 wrong=0 refused=0 n=5
Result: correct=5 wrong=0 refused=0. Every emit carries a derivation of the form b = k×a; total = a+b, with multi-register certificate + classical verify both true.
Note on residuals: early runs emitted multi-register decoded floats (~
8.999999982). That is geometric relaxation noise, not a zero residual trophy. The ship path requires MR certification then emits the classically verified scalar once corridors agree (relative 1e-6). Both corridors remain mandatory.
3c. False-positive / separability
$ uv run python scripts/measure_trackb_s1_slice.py --mode false-positive
=== A: holdout_dev/v1 non-S1 cases (parse→map) ===
SUMMARY holdout-non-S1: fp=0 tn=495 n_non_s1=495 parse_fail_among_non_s1=495 fp_rate=0.000000
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 parse_fail=495. Layer B/C are the real separability tests.
=== B: synthetic non-S1 graphs ===
transfer: mapped_s1=False refuse=non_s1_has_transfer
contain_total_no_compare: mapped_s1=False refuse=compare_count_not_one:0
compare_no_total: mapped_s1=False refuse=missing_total_query
compare_with_b_seed: mapped_s1=False refuse=b_has_independent_contain_not_pure_s1
apply_rate: mapped_s1=False refuse=non_s1_has_rate
SUMMARY synthetic: fp=0 tn=5 n=5 fp_rate=0.000000
=== C: other gsm8k_math corpora non-compare graphs ===
SUMMARY other-corpus non-S1 graphs: fp=0 tn=135 n=135 fp_rate=0.000000
Honest reading:
- Layer A (holdout non-S1): vacuous at graph level — the serving reader produces
selected_graphonly for the five S1 cases on this split. Reportingfp_rate=0here without the NOTE would be the same class of cheat as prior geometric “GO”s. We refuse that claim. - Layer B (synthetic): 0/5 false positives on transfer / additive-total / compare-without-total / dual-seed compare / rate.
- Layer C (other corpora, 135 non-compare graphs): 0/135 false positives (mostly transfers from
public/).
3d. Surface-variant generalization (re-parse)
$ uv run python scripts/measure_trackb_s1_slice.py --mode surface-variant
BASE_ID gsm8k-holdout-dev-v1-0108
BASE_TEXT 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_TEXT 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?
BASE_MAP mapped_s1=True binding k=4.0 a_value=22.0 ...
VARIANT_MAP mapped_s1=True binding={'a': 'Harbor Beach', 'b': 'Cedar Cove Beach', 'k': 5.0, 'a_value': 11.0, 'unit': 'dolphins', ...}
VARIANT_SOLVE emitted=True answer=66.0 expected=66.0 derivation='S1: Cedar Cove Beach = 5.0 × Harbor Beach; Harbor Beach = 11.0; total = ... = 66.0'
SUMMARY surface-variant: map=True k_ok=True a_ok=True names_ok=True ans_ok=True
Re-parsed renamed/re-numbered text still maps to S1 with the new bindings and solves correctly. Driven by relations, not surface tokens.
3e. Side-by-side vs S1 surface organ
$ uv run python scripts/measure_trackb_s1_slice.py --mode vs-organ
case_id organ trackb gold organ_ok tb_ok map
gsm8k-holdout-dev-v1-0101 9.0 9.0 9.0 True True True
gsm8k-holdout-dev-v1-0108 110.0 110.0 110.0 True True True
gsm8k-holdout-dev-v1-0268 16.0 16.0 16.0 True True True
gsm8k-holdout-dev-v1-0411 52.0 52.0 52.0 True True True
gsm8k-holdout-dev-v1-0453 28.0 28.0 28.0 True True True
Track B matches the organ on all five S1 cases (answer + map). Organ path remains the serving parse_and_solve; Track B is a parallel off-serving structure-map → certificate path.
4. Unit tests
$ uv run pytest tests/test_trackb_s1_structure_mapping.py -v
16 passed in 0.62s
Includes real holdout texts, refuse paths, blindness import audit, and dual-corridor emit.
5. Baselines (serving invariant)
Pre-edit (worktree at main tip 1ccef491, before Track B code):
holdout report (committed): {'correct': 5, 'refused': 495, 'wrong': 0} n=500
uv run core test --suite smoke -q → 176 passed in 134.05s
Post-edit: same smoke suite (off-serving code only; serving modules diff empty). Holdout serving numbers unchanged by construction (no live path edits).
6. Pure-S1 integrity fix (reviewer-critical)
An early mapper draft accepted totals that included {a,b} among other parts
and only blocked an independent contain on b. Combined with rebuild-from-binding
solve, that could emit a certified wrong answer (drop third entity):
# A=5, C=100, B=2×A, total all → classical 115; buggy pure rebuild → 15
$ uv run python -c '...' # see measure-s1-pure-gate-repro
map=StructureMapRefuse reason=total_parts_not_exactly_a_b
emit=False ans=None refuse=structure_map_refuse:total_parts_not_exactly_a_b mr=False
REPRO FIXED
Fix (shipped):
map_to_s1requires total part-set exactly{a,b}(total_parts_not_exactly_a_b).- Any
containon an entity other thanais refused (extra_contain_not_pure_s1:*/b_has_independent_contain_not_pure_s1). try_s1_structure_map_and_solve(graph=…)additionally requires classical solve of the original graph to agree with the pure-S1 rebuild before emit (fail-closed backstop).
Unit tests: test_mapper_refuses_total_superset_with_extra_entity,
test_mapper_refuses_extra_contain_even_if_total_were_ab_only,
test_multi_entity_slice_never_emits_wrong_certified_answer,
test_multi_entity_small_c_same_trap (20 tests total).
7. Explicit non-claims / STOP gate
- Not claimed: geometric SME revival; S2–S4 coverage; serving cutover; sealed-test movement.
- Not claimed: holdout non-S1 graph-level separability beyond the vacuous parse layer — only synthetic + other-corpus graphs.
- Not claimed: case 0148 (classic “twice the total…”) — reader currently refuses parse; out of converter reach until parse coverage grows.
- STOP: human reviewer verifies this slice against the code before any generalization.
8. Inventory: proven vs assumed
| Item | Status |
|---|---|
| S1 map+solve on organ cohort (5) | Proven (commands above) |
| wrong=0 on emitted path | Proven |
| Surface-variant re-parse | Proven |
| FP on synthetic + 135 non-compare graphs | Proven |
| Holdout non-S1 graph FP | Unclaimed (vacuous parse layer) |
| Generalization to S2–S4 | Not in scope |
| Serving supersede of organ | Not in scope |