core/docs/recognizer-registry.md
Shay 0afaa8d0fc feat(derivation): Workstream A inc 2 — frontier report + rate_with_currency apply_rate injection
- 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.
2026-06-16 22:17:37 -07:00

114 lines
5.2 KiB
Markdown

# 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_spec` parses as a
`teaching.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_symbols`
does not match `rate_with_currency`.
- A window unit outside `observed_window_units` does not match
`temporal_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` / `CandidateOperation`
that 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.