First PR plumbing recognizer parsed_anchors into the candidate-graph as
typed CandidateInitial primitives. Scope limited to discrete_count_statement;
other five round-2 categories route to the round-2 skip-only fallback until
follow-up D.2.x PRs.
Five-layer wrong=0 safety net:
1. Matcher narrowness — _try_extract_discrete_count_anchor refuses on any
ambiguity (multi-subject, pronoun subject, non-possession verb,
multi-count, clause-split, unobserved counted_noun, unobserved
count_kind).
2. Extraction correctness — refusal-preferring; populated parsed_anchors
only when ALL narrowness rules hold.
3. Injection correctness — _initial_admissible gates every constructed
CandidateInitial; failure to ground returns () (under-admit).
4. Replay gate — propose-time admissibility_replay_gate auto-rejects any
matcher change that would lift GSM8K wrong count.
5. Multi-branch decision rule — injected candidate disagreeing with
another branch triggers refuse path.
Re-baseline (GSM8K train_sample v1):
- Old (#309 alone): correct=3 refused=47 wrong=0
- New (#309 + D.2 v1): correct=3 refused=47 wrong=0
- Empirical lift in v1 = 0 cases; framework operational. No GSM8K
train_sample case has a discrete_count statement that simultaneously
meets all narrowness rules AND is missed by the existing parser.
Bottleneck moves to other recognizer categories (D.2.2+).
Validation:
- tests/test_adr_0163_d2_discrete_count_injection.py: 34 passed
- tests/test_recognizer_match.py + test_candidate_graph_recognizer_wiring
+ test_admissibility_replay_gate: 27 passed
- adr_0131_* (G1..G5 + S1 wrong=0 invariant): 222 passed / 2 pre-existing
report-comparison failures / 3 skipped — byte-identical to pre-D.2
- Solver code: unchanged
Operator caveat: round-1's ratified discrete_count_statement spec is
unchanged. Matcher behavior on the spec's canonical_pattern has been
extended from detection-only to populated parsed_anchors. Re-ratification
is not required; if policy requires it on matcher-behavior changes, the
registry digest provides byte-stable provenance.
Three new question shapes extracted from the GSM8K train_sample
post-Phase-D refusal taxonomy:
- Pattern A — "How much MASS_NOUN does ENTITY VERB ..." with narrow
whitelist (money, profit, interest, income, savings, cost, amount,
total). Extending the whitelist requires a separate ADR.
- Pattern B — "How many more UNIT does ENTITY VERB ..." (comparative).
Structurally detected (regex + comparative_marker field) but
emission is gated until the solver gains comparative semantics
(D.5 follow-up). Without solver-side handling, emission would
return the entity's current total (off by the missing delta) and
break wrong=0.
- Pattern C — "How many UNIT does PRONOUN VERB [to VERB2] ..." with
a closed-set action-verb whitelist.
Pronoun-entity resolution (Pattern C):
- Pure, deterministic function _resolve_pronoun_entity
- Refuses on ambiguity: >1 distinct female/male name in problem text
→ no candidate emitted (better refuse than admit-with-wrong-entity)
- "they" / "it" outside scope — refuses
- Closed-set ~50/~50 female/male name whitelists sourced from
GSM8K train_sample observation
Wrong=0 safety nets:
1. Regex narrowness (mass-noun whitelist, "more" anchor, closed verb set)
2. Pronoun resolver refuse-on-ambiguity
3. Pattern B emission gated until solver semantics catch up
CandidateUnknown.comparative_marker added with default False so
existing 200+ construction sites stay byte-identical.
Plumbing: extract_question_candidates / _filtered_question_choices /
parse_and_solve thread an optional problem_text through to the
pronoun resolver. No solver, recognizer-registry, matcher,
candidate-graph wiring, proposal log, or eval-harness changes.
Validation (all green on this branch):
pytest tests/test_adr_0163_d4_question_grammar.py -> 45 passed
pytest tests/test_adr_0163_d3_conditional_prefix.py -> green
pytest tests/test_math_candidate_parser.py -> green
pytest tests/test_math_candidate_graph.py -> green
pytest tests/test_candidate_graph_recognizer_wiring.py -> green
pytest tests/test_adr_0131_*.py -> green
331 passed, 3 skipped
python -m evals.math_capability_axes.G3_numerics.v1.runner -> overall_pass=True
solved=20 / wrong=0
python -m evals.gsm8k_math.train_sample.v1.runner -> correct=3
refused=47
wrong=0
GSM8K train_sample baseline:
Pre-D.4 (D.3 base): correct=3, refused=47, wrong=0
Post-D.4 (this PR): correct=3, refused=47, wrong=0
No lift on this base branch. Cases that Pattern A admits at the
question level (e.g. 0001 "how much money does she make") still
refuse at the statement layer because the round-2 exemplar-corpus
recognizers (PR #309) are not on this base. Refusal reasons
update from "no admissible candidate for question" to "no admissible
candidate for statement" / "no branch produced a solvable graph" —
expected. The grammar machinery is structurally ready: when
stacked on PR #309, the projected lift to correct=8-13 should
manifest.
Per-pattern coverage on the 38 question refusals (post-Phase-D
question shape categorization):
Pattern A — mass-noun ENTITY VERB: ≥4 evidenced cases
(0001, 0003, 0022, 0029)
Pattern B — comparative quantifier: ≥3 evidenced (0007, 0035, ...)
— detection only, no emission
Pattern C — pronoun + action verb: ≥1 in-scope (0011)
(0008 modal "be able to" + 0025
joint-subject deferred to D.5)
Cross-references: ADR-0163 (#294), Phase D.3 (#308 — base), round-1
ratification (#304), round-2 ratification (#309 — required for the
projected lift), session recap (#305).
Phase D made statement-level admission consult the ratified
recognizer registry (PR #302) but the same wiring at the
question-admissibility point was left for follow-up. Post-Phase-B
round-2 ratification, 38 of 47 still-refused GSM8K train_sample
cases now refuse on QUESTIONS (vs 7 pre-ratification) — the
architectural bottleneck has migrated downstream.
The biggest single still-refused question shape is
``nested_question_target`` (11 of 38 cases): ``If X, how many Y
does Z have?`` style. The existing ``_Q_ENTITY_RE`` regex only
matches ``How many UNIT does ENTITY have`` without a conditional
prefix.
D.3 adds a deterministic, pure prefix-strip step that runs ONLY
when the bare parser returns no candidates:
_filtered_question_choices:
candidates = existing parser
if empty AND sentence starts with "If X, ":
strip the prefix, upper-case the first letter
re-run the existing parser on the suffix
Tests pin: prefix-strip correctness on the 5 brief-mandated case
shapes, no false admissions when the suffix is still unparseable,
non-question pass-through unchanged, idempotency, no input
mutation, real-GSM8K-question parameterised coverage.
Empirical reality (verified by re-running the train_sample lane):
the strip operation succeeds deterministically on every
nested_question_target case, but the resulting suffix still hits
OTHER parser limitations (``how much`` mass nouns instead of
``how many`` units, modal verbs like ``will be able to``, pronoun
entities, additional clause prefixes). D.3 alone produces ZERO
additional case-level lift on the current parser regex. D.3 is
necessary-but-not-sufficient; the next layer (extending the
question grammar to mass nouns + non-"have" verbs + pronoun
entity resolution) is required for the conditional-question
cases to compose into correct answers.
That layer is a separate ADR — it touches grammar surface, not
admission wiring. This PR ships ONLY the wiring extension.
Validation:
- 43 new + existing tests passed: tests/test_adr_0163_d3_*,
tests/test_math_candidate_graph,
tests/test_candidate_graph_recognizer_wiring
- 222 capability-axis tests passed / 2 pre-existing main
failures / 3 skipped — G1..G5 + S1 wrong=0 byte-identical
- 67 smoke passed
wrong=0 invariant preserved by construction: recovered candidates
flow through the same _question_admissible gate as direct
candidates; no new admission paths bypass the structural check.
Scope: extends one function in generate/math_candidate_graph.py.
Does not modify the parser regexes, the solver, or the recognizer
registry.
* chore(ADR-0163.C): land three Phase C pending proposals in live log
Phase C (#301) shipped the CLI but its PR dry-run wrote to a tmp log
path. This commit moves the three Phase C proposals into the live
teaching/proposals/proposals.jsonl so the Phase B→C audit trail is
visible in the proposal log and the proposals are ready for the
operator to ratify after Phase D ships.
Proposals (all state=pending, kind="exemplar_corpus"):
- 59223f13722f906a1cf9b65d9b01c990 — descriptive_setup_no_quantity
- 46ce297f797ff16da12db5de422ca3c9 — rate_with_currency
- a3b892546977c5f0f64c578d6052adbd — temporal_aggregation
Produced by `core teaching propose-from-exemplars --all` against the
live Phase B corpora. No ratification (ADR-0161 §5 — only the repo
owner ratifies). The Phase D admissibility-replay gate confirmed
replay_equivalent=true, wrong_count_delta=0 for all three.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(ADR-0163.D): wire ratified RecognizerSpecs into math_candidate_graph admissibility surface
Phase D is the first PR to extend the math admission surface. The
audit (#294) said the gap was admission, not operators, algebra,
substrate, or packs. Phase A measured the refusal taxonomy. Phase B
authored seeds. Phase C synthesized recognizers. Phase D wires
those recognizers into generate/math_candidate_graph.py.
Modules
- generate/recognizer_registry.py — pure projection over the proposal
log. Only proposals with source.kind="exemplar_corpus" AND
review_state="accepted" enter the tuple. Sorted by
(review_date, proposal_id). In-process cache keyed on log
(mtime, sha256) — no filesystem cache (ADR-0161 §1). Malformed
accepted specs raise RegistryLoadError citing the offending
proposal_id; silent drops are forbidden.
- generate/recognizer_match.py — per-category rules-only matchers
(no LLM, no embedding, no learned classifier). Honors the Phase C
synthesizer's narrowness rule: out-of-corpus currency symbols,
window units, and per-unit values do NOT match. Three matchers:
_match_descriptive_setup_no_quantity (zero-quantity surface),
_match_temporal_aggregation (event_count_per_window with
observed_window_units/quantifiers honored), _match_rate_with_currency
(currency_per_unit_rate with observed currency/per-unit/amount-kind
honored).
- generate/math_candidate_graph.py — narrowest-edit guard at the
per-statement choice loop. Before the existing
"no admissible candidate for statement" refusal, consult the
ratified registry. Recognized statements are dropped from
per_sentence_choices (zero math state) so the Cartesian product is
identical to "this statement was never there." Empty registry is
a no-op — backward compatibility preserved byte-identically.
Downstream consumption of parsed_anchors (turning recognized
rate/temporal surfaces into solver state that produces concrete
answers) is Phase E follow-up.
Tests (32 new)
- tests/_phase_d_fixture.py — synthetic in-memory ratified registry
built from the three Phase C pending proposals' content. Per
ADR-0161 §5 the agent does NOT ratify the live log; the synthetic
registry round-trips the real RecognizerSpec bytes the operator
will ratify after Phase D ships.
- tests/test_recognizer_registry.py (9) — empty/pending/wrong-kind
filtering, sort order, malformed-spec rejection, cache hit +
invalidation, live-log Phase C audit check.
- tests/test_recognizer_match.py (14) — per-category positive cases,
narrowness (out-of-corpus surface forms rejected), no-LLM import
check.
- tests/test_candidate_graph_recognizer_wiring.py (7) — empty registry
preserves existing refusal; synthetic registry: recognized
statements no longer trigger per-statement refusal;
wrong_count_delta == 0 on GSM8K train_sample; capability axes G1..
G5+S1 wrong=0 unchanged; per-category admission counts on the
refused-set; unrecognized statements still refuse with the
existing reason.
- tests/test_phase_d_replay_evidence.py (2) — full admissibility
replay gate under synthetic registry: replay_equivalent=true,
wrong_count_delta=0, every capability axis wrong=0; each
ratified recognizer admits >= 1 train_sample statement (wiring
is consequential).
Per-category fixture-based admission counts (synthetic registry vs
GSM8K train_sample refused-set sentences):
- descriptive_setup_no_quantity: 40
- rate_with_currency: 2
- temporal_aggregation: 7
Narrowness-invariant negative case results (matcher correctly
returns None on out-of-corpus / load-bearing-math surfaces):
- rate_with_currency: "She paid $5 for the book." (no per-unit)
- temporal_aggregation: "On Saturday she went to the store." (single day token)
- descriptive_setup_no_quantity: "There are some kids in camp." (indefinite quantifier)
Candidates for Phase B round 2 (3 of 20 temporal seeds match the
spec's structural commitment but not my surface regex — author_notes
explicitly flagged these as schema-gap edge cases):
- ta-v1-0004 "Mark does a gig every other day for 2 weeks."
- ta-v1-0012 "Robin walks 4 dogs every other day around the park."
- ta-v1-0019 "The pump fills the tank with 80 gallons over 6 hours."
Three landed wirings DO NOT shift the GSM8K train_sample baseline
counts under fixture (correct=3, wrong=0, refused=47 unchanged) —
Phase D's narrow wiring is wrong=0 safe by construction; lift to
"correct" requires Phase E's downstream parser-side consumption of
parsed_anchors. Capability axes G1..G5+S1 wrong=0 unchanged.
Cross-refs: ADR-0163 (Phase D), ADR-0057 (proposal review),
ADR-0151 (auto-proposal), ADR-0161 §5 (ratification boundary),
Phase A PR #297, Phase B PR #298, Phase C PR #301.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
- Add classify_sentence() + has_numeric_token() to math_candidate_parser.py.
Rule: sentence with no digit and no word-number cannot introduce parseable
numeric state — classify as "context" and skip safely (wrong==0 preserved).
- Add pre-pass in parse_and_solve() (math_candidate_graph.py): strips context
sentences before extraction; falls through to refusal if none remain numeric.
- Extend capacity patterns for gsm8k-0018:
- _CAPACITY_INVERTED_RE: "During M <time-unit> <Actor> can <verb> N <unit>"
- _CAPACITY_Q2_RE: "How many <unit> [on average] is <Actor> able to <verb>,
when the <event> lasted for T <time-unit>?"
- GSM8K: 1/50 -> 2/50 (gsm8k-0018 admits with answer 16.0); admitted_wrong==0.
- Tests: 47/47 pass (12 new for classifier, inverted patterns, 0018 end-to-end).
First capability-axis iteration after ADR-0131.G baseline. Extends the
candidate-graph parser's <value> slot to recognize:
- Money symbol literals: $N and $N.NN (1-2 decimals); $N.NNN refused
- Money word forms: N dollars / N cents
- Hyphenated multi-word cardinals: twenty-five, ninety-nine, ...
All money values normalize to integer cents, unit 'cents' — pack-aligned
with en_units_v1's canonical_unit='cent' for the money dimension.
en_numerics_v1's parse_compound_cardinal handles hyphenated cardinals.
Parser changes (generate/):
- math_candidate_parser.py: _VALUE alternation widened; _resolve_value
refactored to return _ResolvedValue|None carrying optional unit
override; _INITIAL_HAS_RE unit slot made optional; dollar/dollars →
cents normalization at candidate build.
- math_roundtrip.py: new _unit_grounds helper (money-aware); _value_grounds
widened for the three new literal shapes; roundtrip_admissible uses
_unit_grounds for the unit check.
- math_candidate_graph.py: _initial_admissible and _question_admissible
use _unit_grounds.
New axis lane (evals/math_capability_axes/G3_numerics/v1/):
- 26 curated cases (20 positive across 4 classes + 6 refusal probes)
- runner.py wraps _score_one_candidate_graph; byte-equal report.json
- 20/20 positive solved correct; 6/6 refusal probes refused typed;
solved_wrong == 0; overall_pass == True
Tests: 27/27 in 0.19s. 420 existing candidate-parser/math-parser/pack
tests still green. GSM8K probe safety rail (admitted_wrong == 0)
preserved.
Honest scope-limit (documented in ADR): admission_rate on the GSM8K
probe stays at 0/50 because (a) the probe currently consults the legacy
parser path, not the candidate-graph pipeline G.3 extends, and (b) most
money-bearing GSM8K cases fail first on verb (G.1) or multi-clause (G.4)
shape, not on the money literal. The axis lane is the load-bearing
measurement for this iteration. Reserved follow-up: a small probe-
infra ADR to switch run_coverage_probe.py to the candidate-graph
pipeline.
Out of scope, deferred to G.3.1: fractions end-to-end (resolver supports
N/M but no axis cases), multi-currency (¢ € £ ¥ ₱), space-separated
multi-word cardinals (one hundred), word-number-adjective compositions
(five full boxes).
P3 — generate/math_candidate_graph.py:
Branch enumeration over per-sentence candidate choices (Cartesian
product, cap=64). Per-sentence ambiguity tiebreaker via most-grounded-
slots-wins (transfer beats subtract when 'to Tom' grounds). Decision
rule: 0 admissible -> refuse; 1 -> emit; >=2 same answer -> emit;
>=2 different answers -> refuse (preserves wrong==0 on genuine
ambiguity). End-to-end parse_and_solve(text) -> CandidateGraphResult.
Question extractor added to math_candidate_parser.py (CandidateUnknown,
total + entity question shapes mirroring math_parser).
22 new tests. Permissive verbs ('bought', 'ate', 'bakes') now produce
correct answers via the candidate-graph path; ambiguous 'gives to Tom'
resolves to transfer reading (Tom gets the apples) deterministically.
P4 — evals/gsm8k_math/runner.py:
New sibling function _score_one_candidate_graph(case) -> CaseOutcome.
Identical shape to _score_one; swaps parse_problem for parse_and_solve;
preserves verifier/realizer/expected-answer stages. Callers (e.g.
PR #160's train_sample/v1/runner.py) substitute the new function in
one line to evaluate the candidate-graph topology.
9 new wiring tests. Three groups:
- No regression: cases legacy solves, new also solves.
- Lift: cases legacy refuses, new solves (the architectural payoff).
- Wrong==0: out-of-grammar refuses, never wrong.
Regression: 714/714 existing math + runner tests still green.
ADR-0126 total: 74/74 tests green across P1+P2+P3+P4.