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:
parent
78ddab79b4
commit
7a0b1ed931
1 changed files with 441 additions and 0 deletions
441
docs/handoff/MATCHER-EXTENSION-DISPATCH-PACK.md
Normal file
441
docs/handoff/MATCHER-EXTENSION-DISPATCH-PACK.md
Normal 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 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.
|
||||
Loading…
Reference in a new issue