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).
441 lines
17 KiB
Markdown
441 lines
17 KiB
Markdown
# 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
|
||
|
||
```bash
|
||
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 target
|
||
- `RecognizerMatch` dataclass (line 139) — `parsed_anchors` is `tuple[Mapping[str, Any], ...]`
|
||
- `generate/recognizer_anchor_inject.py`:
|
||
- `inject_from_match` (lines 73–98) — already consults composition_registry on `composition_shape`
|
||
- `_consult_composition_registry` (lines 100–170) — the contract: matcher must publish `composition_shape` + `composed_initial` OR `composed_operation`
|
||
- `generate/comprehension/composition_registry.py` — `is_affirmed`, `is_falsified`, allowlist
|
||
- `generate/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 anchors
|
||
- `tests/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:
|
||
|
||
```python
|
||
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 `RecognizerMatch` with an optional `prior_subject` field
|
||
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)
|
||
|
||
1. **`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)
|
||
|
||
2. **`generate/recognizer_anchor_inject.py`** — **no changes**.
|
||
The consumption wire from PR #398 already handles `composition_shape`
|
||
+ `composed_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_shape` MUST match a SAFE category.** The matcher
|
||
emits `"bound(count) × bound(unit_cost)"` — the operator's
|
||
ratification of this exact pattern under
|
||
`multiplicative_composition` is what lights it up. Any other
|
||
shape string is forbidden.
|
||
- **Pre-composed value is `int` or `float` per `Quantity` invariants.**
|
||
Compute `outer_count * unit_cost`; if either is non-numeric,
|
||
refuse.
|
||
- **Source-span requirement.** `source_span` must 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`
|
||
|
||
1. **`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")
|
||
2. **`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)
|
||
3. **`test_per_unit_token_outside_observed_set_refuses`** — narrowness
|
||
guard
|
||
4. **`test_multiple_counts_refuse_composition`** — narrowness guard
|
||
(refusal-preferring on ambiguity)
|
||
5. **`test_count_word_form_admits`** — "Maria bought three vet
|
||
appointments at $400 each" → composed_initial(value=1200)
|
||
6. **`test_negative_or_zero_count_refuses`**
|
||
7. **`test_currency_symbol_outside_observed_refuses`**
|
||
8. **`test_emits_canonical_unit_dollars`** — `Quantity.unit == "dollars"`
|
||
for `$` symbol
|
||
|
||
### `tests/test_matcher_extension_case_0050_hazard_pin.py`
|
||
|
||
1. **`test_case_0050_does_not_emit_composition_shape`** — feed case
|
||
0050's actual sentences through the extended matcher; assert no
|
||
anchor carries `composition_shape`. Mandatory; runs in CI.
|
||
|
||
### `tests/test_matcher_extension_end_to_end_admission.py`
|
||
|
||
The TRUTH TEST for this PR:
|
||
|
||
1. **`test_synthetic_maria_admits_via_composition_registry`** — start
|
||
from a clean pack, ratify `bound(count) × bound(unit_cost)` under
|
||
`multiplicative_composition`, run the candidate-graph pipeline
|
||
on the synthetic Maria sentence, assert the result is a correct
|
||
admission (not refusal).
|
||
2. **`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`
|
||
|
||
1. **`test_train_sample_counts_unchanged_or_better`** — full
|
||
`train_sample` reader-on eval; assert `correct >= 3`, `wrong == 0`,
|
||
`refused <= 47`. Any improvement is a bonus; no regression is
|
||
mandatory.
|
||
|
||
### `tests/test_matcher_extension_public_split_preserved.py`
|
||
|
||
1. **`test_gsm8k_math_public_150_150_preserved`** — `core eval
|
||
gsm8k_math --split public --json` returns `cases_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 == 0` on `core eval gsm8k_math --split public` preserved
|
||
- 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_CATEGORIES` enforced at write AND load
|
||
- Refusal-preferring discipline: subject-absent → no composition emission
|
||
- `engine_state/*` never committed
|
||
- Pinned-lane SHAs may move (intentional `correct` delta 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_sample` counts 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
|
||
|
||
```text
|
||
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:
|
||
1. Read this brief plus the three "parent briefs" + ADR-0169 + the four `Reads required FIRST` files.
|
||
2. Implement the matcher extension in `_match_rate_with_currency` (or a sibling helper) per Option A.
|
||
3. Write the 12 tests across the four test files.
|
||
4. Run the 6-row truth-test sequence end-to-end.
|
||
5. Confirm all 6 rows hold before opening PR.
|
||
6. 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.
|