core/docs/handoff/MATCHER-EXTENSION-DISPATCH-PACK.md
Shay 7a0b1ed931
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).
2026-05-27 16:17:30 -07:00

441 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.