- scripts/gsm8k_frontier_report.py + test (stable buckets; rate_with_currency surfaced) - docs/recognizer-registry.md + math_candidate_graph.py comments repaired (current refusal doctrine; old skip-only marked historical) - generate/math_roundtrip.py: add 'a','an' to RATE_ANCHORS (with doc update) - generate/recognizer_anchor_inject.py: inject_rate_with_currency (narrow ProperName actor, Rate/apply_rate CandidateOperation, rejects unsafe); registered in _INJECTORS; module docs updated - tests/test_*_rate_injection*.py + frontier test (8+ unit cases, confusers, synthetic wiring, real-report frontier pin) - ratification doc (pre-code) - lookback (post-impl, truthful) All required local commands exercised (pytest green for new + prior extract/invariants; frontier script shows rate bucket; runner per brief; shas captured). wrong=0 held. No sealed movement. Proxy still expected !passed (correct_min=10). See ratification and lookback for scope, hazards, exact outputs.
5.2 KiB
Ratified Recognizer Registry (ADR-0163 Phase D)
The recognizer registry projects accepted exemplar-corpus proposals from the append-only proposal log into a tuple the math candidate-graph consults before refusing on an empty per-statement choice list. It is the connective tissue between Phase C's operator review surface and Phase D/E's admission expansion.
Projection rule
A proposal enters the registry iff all of:
source.kind == "exemplar_corpus"(Phase C's source kind)review_state == "accepted"(operator ratification — never agent-side)proposed_chain.recognizer_specparses as ateaching.recognizer_synthesis.RecognizerSpec
Pending, rejected, withdrawn, and non-exemplar proposals are
invisible. Malformed accepted proposals raise RegistryLoadError
with the offending proposal_id — silent drops are forbidden.
Registry order is (review_date, proposal_id) ascending — stable
across runs.
Match contract
generate.recognizer_match.match(statement, registry) returns at most
one RecognizerMatch per call (first-match-wins over registry order).
Each per-category matcher is rules-only — no LLM, no embedding, no
learned classifier. A module-import test pins the no-ML constraint.
parsed_anchors carry numeric tokens extracted from the statement
text, not from the spec. For descriptive_setup_no_quantity,
parsed_anchors is the empty tuple by design — the recognizer admits
the statement as setup context, contributing no math state.
Narrowness invariant
Per ADR-0163 §Phase C The Synthesis Rule property (b), the recognizer is the narrowest commitment that subsumes the seeds. The matcher inherits that narrowness verbatim:
- A currency symbol outside the spec's
observed_currency_symbolsdoes not matchrate_with_currency. - A window unit outside
observed_window_unitsdoes not matchtemporal_aggregation. - A statement with any digit, number word, or indefinite quantifier
does not match
descriptive_setup_no_quantity.
Widening happens through the corridor — wider exemplar corpus → Phase C synthesis on wider seeds → operator ratifies the wider proposal — never by editing the matcher's permissiveness.
Wiring point (current doctrine — post wrong=0 correction)
generate/math_candidate_graph.py:parse_and_solve consults the
registry at the per-statement choice loop, before the existing
no admissible candidate for statement refusal.
When the registry recognizes the statement:
- the per-category injector (
generate/recognizer_anchor_inject.inject_from_match) is consulted; - if the injector emits one or more
CandidateInitial/CandidateOperationthat survive admissibility, those candidates are added to the per-sentence choice space exactly as parser output would be; - if the injector emits nothing (or all candidates are dropped by later
pronoun/lookback guards), the graph refuses explicitly with the
reason
"recognizer matched but produced no injection for statement: ... (category=...)".
Never silently drop a recognized math statement as "zero state". The historical skip-only rule ("drop it, Cartesian product unchanged") was retired because it admitted incomplete graphs at the problem level (the solver could answer from the remaining statements and produce a number that was not the answer to the full input). The current code and this document treat "recognized + no typed emission" as a refusal case. Old skip-only language appears only in historical notes below.
Empty registry → the guard is a no-op and the pre-registry refusal behavior is preserved byte-identically.
Ratification boundary (ADR-0161 §5)
The agent does not ratify the live proposal log. Phase D tests
build a synthetic in-memory RatifiedRecognizer tuple from the
Phase C pending proposals' content (tests/_phase_d_fixture.py).
The matcher and candidate-graph wiring exercise the same
RecognizerSpec bytes the operator will later ratify, with zero
modification to teaching/proposals/proposals.jsonl.
The operator's ratification path is the existing
core teaching review <proposal_id> --accept --review-date <YYYY-MM-DD>
— no new CLI surface lands with Phase D.
Phase E / D.2 follow-up (historical note)
Early Phase D wiring (the registry + skip-only guard) was intentionally
"skip-only by construction" so that adding recognizer categories could
not regress wrong=0. That doctrine was corrected once the "recognized
but uninjected → incomplete graph" hazard was understood (see the
explicit refusal branch and comments in math_candidate_graph.py and
the ADR-0167 / Brief 11 lineage).
Current follow-up work (Workstream A Inc 2 and successors) adds the
per-category injectors that turn parsed_anchors for rate_with_currency
(and later categories) into grounded CandidateOperation / Rate /
apply_rate primitives that the existing solver already knows how to
execute. When an injector is present and emits, the statement
contributes real solver state; when it cannot, refusal (not silent drop)
is the outcome.
Historical skip-only descriptions are preserved only as "rejected behavior" markers in this document and in code comments. Grep for the old phrases on the active source surfaces should surface only such markers.