Follow-up to PR #398 — lights up the dormant consumption path by extending _match_rate_with_currency to publish composition_shape + pre-composed CandidateInitial in parsed_anchors. Scope: one matcher extension (_match_rate_with_currency) for the currency-per-unit composition shape ("$X each"). Other composition shapes (multi-quantity, additive, subtractive) deferred to ME-3/4/5 follow-ups. Subject binding decision pinned: Option A (refuse when same-sentence subject is absent). Forbids Option B (placeholder subject — fabricates attribution). Defers Option C (cross-sentence subject) to its own ME-2 brief. Honest consequence: case 0019 stays refused in THIS PR (requires Option C). Truth-test #1 replaced with a synthetic "Maria bought 3 vet appointments at $400 each" canary that has same-sentence subject. The flywheel turns one revolution: ratify → compile → load → consume → admit, end-to-end on the synthetic canary. Operator profile: Opus (load-bearing wrong=0; pre-composed CandidateInitial; case 0050 mandatory pin). Test surface enumerated (4 test files, 12+ tests): - test_matcher_extension_currency_per_unit (8 narrowness tests) - test_matcher_extension_case_0050_hazard_pin (mandatory) - test_matcher_extension_end_to_end_admission (truth test) - test_matcher_extension_train_sample_baseline_preserved - test_matcher_extension_public_split_preserved 6-row truth test pinned. Anti-regression invariants enumerated. Forbidden surface includes Option B + new SAFE category entries + solver mutation. Recommended next dispatch sequence: ME-1 → ME-2 (case 0019) → ME-3/4/5 (remaining composition shapes).
17 KiB
Matcher Extension — Dispatch Pack (ME-1)
Goal: Light up the dormant consumption path in PR #398 by extending
the recognizer matcher to publish composition_shape + a pre-composed
CandidateInitial in parsed_anchors. With this PR, the math
compounding loop's ratify → admit closure becomes operational for
the each/per-framed cost composition shape — and case 0019
admits.
Parent briefs:
docs/handoff/COMPOSITION-FRAME-CONSUMPTION-WIRING-BRIEF.md(PR #396)docs/handoff/CONSUMPTION-WIRING-DISPATCH-PACK.md(PR #397)- PR #398 — the consumption-wiring implementation (merged into context)
Parent ADRs: ADR-0163 (recognizer registry), ADR-0167, ADR-0168 / 0168.1, ADR-0169 / 0169.1, ADR-0170 (injector contract widening), ADR-0172
Type: Implementation dispatch; not a doctrine ADR
Why this brief exists
PR #398 shipped the consumption infrastructure (compile, loaders,
manifest, injector consultation). All 38 new tests pass. But the
truth-test rows #1 and #3 (case 0019 admits; train_sample 3/47 →
≥4/46) partial: the wiring is positioned correctly but stays
dormant because the existing matchers don't publish
composition_shape in parsed_anchors.
PR #398 §"Scope Boundary" named this finding. This brief is the follow-up.
The matchers in generate/recognizer_match.py:
| Matcher | Currently | Needs to |
|---|---|---|
_match_rate_with_currency |
Extracts (currency_symbol, amount, per_unit) per anchor; detection + extraction |
Detect the outer count in the same sentence; emit composition_shape + composed_initial when count is present |
_match_currency_amount |
Detection-only (returns empty anchors); refuses if per-unit framing present | Out of scope — this matcher's discriminator deliberately rejects per-unit framing |
_match_multiplicative_aggregation |
Detection-only; matches "M outer × N inner" shape | Out of scope (Phase B follow-up) — multiplicative_aggregation maps to a different solver shape than currency_per_unit_rate |
So the minimum-scope extension is _match_rate_with_currency.
Bundling rule
One PR. Single matcher extension + injector wire for the per-unit-cost composition shape. Don't bundle multi-quantity_composition or additive_composition cases into this brief — those are separate matcher targets and each carries its own subject-binding risk.
Branch: feat/matcher-extension-currency-per-unit-composition
Operator profile
Opus (load-bearing wrong=0 surface; the matcher extension produces
a CandidateInitial whose value is the product of two recognized
quantities — same hazard surface as CC-2, with case 0050 as mandatory
pin).
Dispatch
git fetch origin main && \
git worktree add /tmp/wt-matcher origin/main && \
cd /tmp/wt-matcher && \
git checkout -b feat/matcher-extension-currency-per-unit-composition
Reads required FIRST
generate/recognizer_match.py:_match_rate_with_currency(lines 290–344) — the extension targetRecognizerMatchdataclass (line 139) —parsed_anchorsistuple[Mapping[str, Any], ...]
generate/recognizer_anchor_inject.py:inject_from_match(lines 73–98) — already consults composition_registry oncomposition_shape_consult_composition_registry(lines 100–170) — the contract: matcher must publishcomposition_shape+composed_initialORcomposed_operation
generate/comprehension/composition_registry.py—is_affirmed,is_falsified, allowlistgenerate/math_candidate_parser.py::CandidateInitial— required fields:(initial, source_span, matched_anchor, matched_value_token, matched_unit_token, matched_entity_token)generate/math_problem_graph.py::InitialPossession, Quantity- Case 0019 audit row in
evals/gsm8k_math/train_sample/v1/audit_brief_11.json - The literal text of case 0019 in
evals/gsm8k_math/train_sample/v1/cases.jsonl tests/test_composition_consult_in_injector.py— the synthetic-anchor tests showing the consumption path admits when given populated anchorstests/test_consumption_case_0050_hazard_pin.py— the case-0050 parametrized canary
The target case
Case 0019:
"John adopts a dog from a shelter. The dog ends up having health problems and this requires 3 vet appointments, which cost $400 each. After 2.5 years of these appointments John discovers that his dog's appointments are only 50% covered by insurance. How much did John pay total?"
The sentence that triggers the composition is:
"The dog ends up having health problems and this requires 3 vet appointments, which cost $400 each."
Recognized data the extended matcher must bind:
- outer count: 3
- unit cost: $400
- per-unit token: each
- currency symbol: $
Pre-composed admission target:
CandidateInitial(
initial=InitialPossession(
entity=<resolved_subject>, # see "Subject binding" below
quantity=Quantity(value=1200, unit="dollars"),
),
source_span="3 vet appointments, which cost $400 each",
matched_anchor="cost",
matched_value_token="1200", # the composed value as a token
matched_unit_token="dollars",
matched_entity_token=<resolved_subject>,
)
composition_shape = "bound(count) × bound(unit_cost)"
(must match the surface_pattern an operator ratifies in
compositions/multiplicative_composition.jsonl)
Subject binding (the load-bearing decision)
The CandidateInitial requires a non-empty entity. Case 0019's
sentence ("The dog ends up having health problems…") has no human
subject in the same sentence — "John" lives in sentence 0.
Three options, pick one (recommend Option A):
Option A — Refuse when same-sentence subject is absent
The matcher checks the statement for a recognized proper-noun subject.
If absent (case 0019's situation), the matcher returns None for the
composition extension — falling back to the existing detection-only
behavior. The composition admission does NOT fire for case 0019.
This is honest refusal-preferring: cross-sentence subject binding is its own hazard surface (the wrong subject = wrong arithmetic attribution).
If you choose Option A, case 0019 stays refused — the brief's truth test #1 is still partial for THIS PR. Pick a different canary sentence with same-sentence subject (e.g. construct a synthetic case where the subject IS in the same sentence).
Option B — Use a placeholder subject (DISCOURAGED)
Insert entity="anonymous" or similar. Strongly discouraged —
this fabricates a subject and pollutes the candidate graph. The
solver may produce a "correct" total but attribute it wrongly, which
is structurally the same shape as case 0050 hazard (correct-looking
trace, wrong attribution).
Forbidden unless explicitly approved.
Option C — Cross-sentence subject lookup
Make the matcher consult prior-sentence state to find the most recent proper-noun entity. This requires either:
- threading sentence-state context into the recognizer match path (currently the matcher only sees one sentence at a time), or
- extending
RecognizerMatchwith an optionalprior_subjectfield populated upstream
Either is a larger surface than a single PR should land. Defer to its own brief.
Recommendation
Ship Option A, document the case-0019 gap as "requires Option C" in the PR body, and demonstrate the truth-test fires via a purpose-built test case that has same-sentence subject. Example:
"Maria bought 3 vet appointments, which cost $400 each."
For Maria-with-3-appointments: count=3, unit_cost=400, subject=Maria (same sentence) → CandidateInitial(entity="Maria", quantity=$1200).
Add this case to evals/gsm8k_math/train_sample/v1/cases.jsonl as a
synthetic test case OR write a stand-alone Python test that builds
the recognizer match directly and verifies admission.
Outcome
Modules touched (minimum scope)
-
generate/recognizer_match.py:- Extend
_match_rate_with_currency(or add a sibling helper called from it) that:- detects an outer count token in the same statement
- parses the count + per-unit cost
- resolves the subject under Option A (same-sentence proper-noun extraction; refuse otherwise)
- constructs the pre-composed
CandidateInitial - publishes the anchor with keys
composition_shape,composed_initial,currency_symbol,amount,per_unit,outer_count,subject(for debug/audit)
- Extend
-
generate/recognizer_anchor_inject.py— no changes. The consumption wire from PR #398 already handlescomposition_shapecomposed_initial. This PR proves it lights up.
Manifest / pack changes
None. The operator must separately ratify
bound(count) × bound(unit_cost) under multiplicative_composition
via apply_composition_claim() — that ratification already exists in
the canonical pack (or can be re-ratified). The matcher extension
makes the existing dormant ratification load-bearing.
Hard requirements
- Refusal-preferring. Subject absent → matcher does NOT emit a composition anchor. The existing currency_per_unit_rate detection path continues to fire (no regression).
- Outer-count parse narrowness. The outer count must be a single
integer or known number-word adjacent to a counted noun (mirror
_match_discrete_count_statement's narrowness layers). Multi-count or ambiguous → refuse the composition extension. - Per-unit token in observed set. Only fire when the matched
per-unit token (e.g. "each", "per") is in the spec's
observed_per_units(existing narrowness from_match_rate_with_currency). composition_shapeMUST match a SAFE category. The matcher emits"bound(count) × bound(unit_cost)"— the operator's ratification of this exact pattern undermultiplicative_compositionis what lights it up. Any other shape string is forbidden.- Pre-composed value is
intorfloatperQuantityinvariants. Computeouter_count * unit_cost; if either is non-numeric, refuse. - Source-span requirement.
source_spanmust be a verbatim substring of the statement (existing CandidateInitial discipline). - Case 0050 hazard pin. The matcher extension MUST NOT cause case 0050 to admit. The extension's narrowness must reject case 0050's sentence shape (which has no per-unit framing of this form).
- No new eval lanes (ADR-0166).
- No solver / parser / decomposer mutation (ADR-0169 §"Mutation boundary").
Tests
tests/test_matcher_extension_currency_per_unit.py
test_outer_count_extracted_with_subject— synthetic sentence "Maria bought 3 vet appointments, which cost $400 each."; matcher returns anchor with composition_shape + composed_initial(value=1200, entity="Maria")test_subject_absent_refuses_composition_extension— case 0019's exact sentence "The dog ends up having health problems and this requires 3 vet appointments, which cost $400 each."; matcher returns the existing rate anchors but does NOT publish composition_shape (Option A)test_per_unit_token_outside_observed_set_refuses— narrowness guardtest_multiple_counts_refuse_composition— narrowness guard (refusal-preferring on ambiguity)test_count_word_form_admits— "Maria bought three vet appointments at $400 each" → composed_initial(value=1200)test_negative_or_zero_count_refusestest_currency_symbol_outside_observed_refusestest_emits_canonical_unit_dollars—Quantity.unit == "dollars"for$symbol
tests/test_matcher_extension_case_0050_hazard_pin.py
test_case_0050_does_not_emit_composition_shape— feed case 0050's actual sentences through the extended matcher; assert no anchor carriescomposition_shape. Mandatory; runs in CI.
tests/test_matcher_extension_end_to_end_admission.py
The TRUTH TEST for this PR:
test_synthetic_maria_admits_via_composition_registry— start from a clean pack, ratifybound(count) × bound(unit_cost)undermultiplicative_composition, run the candidate-graph pipeline on the synthetic Maria sentence, assert the result is a correct admission (not refusal).test_train_sample_case_0019_partial— explicit test documenting case 0019's refusal-preserving status under Option A (refuses because cross-sentence subject; not a bug, an honest gap). Comment names Option C as the next brief.
tests/test_matcher_extension_train_sample_baseline_preserved.py
test_train_sample_counts_unchanged_or_better— fulltrain_samplereader-on eval; assertcorrect >= 3,wrong == 0,refused <= 47. Any improvement is a bonus; no regression is mandatory.
tests/test_matcher_extension_public_split_preserved.py
test_gsm8k_math_public_150_150_preserved—core eval gsm8k_math --split public --jsonreturnscases_total=150,correct=150,wrong=0,refused=0. Mandatory.
Truth test (binding 6-row table for this PR)
| # | Assertion | How to verify |
|---|---|---|
| 1 | Synthetic Maria sentence admits | test_synthetic_maria_admits_via_composition_registry passes |
| 2 | Case 0050 stays refused | test_matcher_extension_case_0050_hazard_pin.py passes |
| 3 | train_sample correct ≥ 3, no regression |
full train_sample reader-on run; report green |
| 4 | wrong == 0 preserved (train_sample) |
same |
| 5 | public split 150/150 unchanged |
core eval gsm8k_math --split public --json |
| 6 | All PR #398 tests still pass | core test --suite packs -q green |
Row #1 replaces the original case-0019 row from PR #397's truth test (case 0019 stays refused under Option A; that's a Option C concern, not this PR).
Anti-regression invariants
wrong == 0oncore eval gsm8k_math --split publicpreserved- Case 0050 stays refused under the parametrized hazard pin
- ADR-0166 — no new eval lanes
- ADR-0167 partition — matcher remains math-domain only
- Empty composition_registry runtime byte-identical to today
SAFE_COMPOSITION_CATEGORIESenforced at write AND load- Refusal-preferring discipline: subject-absent → no composition emission
engine_state/*never committed- Pinned-lane SHAs may move (intentional
correctdelta if admission fires); call out the move in the PR body
Forbidden surface
- Option B (placeholder subject) — fabricates entity attribution
- Option C (cross-sentence subject) — defer to its own brief
- New SAFE_COMPOSITION_CATEGORIES entries (this PR is read-only over the existing allowlist)
- Mutating
_match_currency_amount(its discriminator is correct; don't conflate) - Mutating
_match_multiplicative_aggregation(different shape; separate brief) - Adding new ShapeCategory values
- Solver / parser / decomposer / arithmetic-operator mutation
- Manifest schema changes (PR #398 already added the optional composition_checksum field)
What ships when this PR lands
- One matcher extension at
_match_rate_with_currency - 12+ new tests
- The first case (synthetic Maria) admits via the ratify → compile → load → consume → admit chain
train_samplecounts improve OR stay at baseline (no regression)- The dormant consumption path in PR #398 becomes load-bearing for the per-unit-cost composition shape
- Case 0019 remains refused — but explicitly under documented scope-boundary, not under structural unknown
Sequencing
- No upstream blocker beyond PR #398 landing on main.
- Subsequent briefs (each its own PR):
- ME-2: Cross-sentence subject binding (Option C); admits case 0019
- ME-3: Multi-quantity_composition matcher (8 audit cases)
- ME-4: Additive composition matcher (independent named quantities)
- ME-5: Subtractive composition matcher
Each subsequent brief carries its own canary case + truth test.
Memory pointers
- project-ratification-consumption-gap-2026-05-27 — the original finding
- feedback-ratify-vs-consume-loop-closure — pattern
- feedback-wrong-zero-hazard-case-0050 — mandatory pin
- feedback-production-line-pattern — dispatch pattern
- feedback-parallel-agent-worktrees
- feedback-address-critiques-dont-waive — Option A is the honest refusal-preferring choice; Option B is the convenient cheat that this brief explicitly forbids
Copy-paste dispatch line
Read docs/handoff/MATCHER-EXTENSION-DISPATCH-PACK.md (entire file).
git fetch origin main && git worktree add /tmp/wt-matcher origin/main && cd /tmp/wt-matcher && git checkout -b feat/matcher-extension-currency-per-unit-composition
Operator instruction sequence:
- Read this brief plus the three "parent briefs" + ADR-0169 + the four
Reads required FIRSTfiles. - Implement the matcher extension in
_match_rate_with_currency(or a sibling helper) per Option A. - Write the 12 tests across the four test files.
- Run the 6-row truth-test sequence end-to-end.
- Confirm all 6 rows hold before opening PR.
- PR title:
feat(matcher-extension): currency-per-unit composition admission (ME-1).
Decision summary
This PR closes the "consumption path is dormant" finding from PR #398 for the simplest possible composition shape (currency × per-unit count). It does NOT close case 0019 — that requires cross-sentence subject binding (Option C) and is the next brief.
The honest scoping: ship the matcher extension, prove the loop runs end-to-end on a synthetic case where same-sentence subject binding holds, leave case 0019 as the next domino. The flywheel turns one revolution.