diff --git a/docs/handoff/MATCHER-EXTENSION-DISPATCH-PACK.md b/docs/handoff/MATCHER-EXTENSION-DISPATCH-PACK.md new file mode 100644 index 00000000..0bfa8e12 --- /dev/null +++ b/docs/handoff/MATCHER-EXTENSION-DISPATCH-PACK.md @@ -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 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=, # 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=, +) +``` + +`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.