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

17 KiB
Raw Permalink Blame History

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

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.pyis_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:

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.pyno 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_dollarsQuantity.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_preservedcore 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


Copy-paste dispatch line

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.