core/docs/recognizer-registry.md
Shay e9b7eb0b1f
feat(ADR-0163.D): wire ratified RecognizerSpecs into math_candidate_graph admissibility surface (#302)
* 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>
2026-05-26 13:11:47 -07:00

89 lines
3.8 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
`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 statement is dropped from
`per_sentence_choices` and the loop continues. Empty registry → the
guard is a no-op and the existing behavior is preserved
byte-identically.
Skipping a recognized statement contributes ZERO math state to the
solver, so the Cartesian product is identical to "this statement
was never there." This preserves `wrong = 0` by construction; the
downstream solver still refuses if the remaining statements +
question cannot produce a consistent answer.
## 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 follow-up
Phase D wires the registry into the admission boundary; downstream
consumption of `parsed_anchors` (turning recognized rate/temporal
surfaces into solver state that produces concrete answers) is
deferred to Phase E. The wiring is in place; Phase E adds the
math_candidate_parser handler that consumes the typed anchors.