docs(matcher-extension): dispatch pack (ME-1) (#399)

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).
This commit is contained in:
Shay 2026-05-27 16:17:30 -07:00 committed by GitHub
parent 78ddab79b4
commit 7a0b1ed931
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -0,0 +1,441 @@
# 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 290344) — the extension target
- `RecognizerMatch` dataclass (line 139) — `parsed_anchors` is `tuple[Mapping[str, Any], ...]`
- `generate/recognizer_anchor_inject.py`:
- `inject_from_match` (lines 7398) — already consults composition_registry on `composition_shape`
- `_consult_composition_registry` (lines 100170) — 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.