core/docs/decisions/ADR-0170-injector-contract-widening.md
Shay ce6b2f5173
docs(ADR-0170): injector contract widening + DCS-S1 schema-gap finding (#372)
DCS-S1 (proper-noun possession sub-shape expansion) investigation
revealed that the recognizer-injector path's `CandidateInitial`-only
return type is a substrate-level constraint blocking four Wave-Next
sub-shape categories — not just one.

## Two artifacts

1. **`docs/handoff/DCS-S1-FINDING.md`** — investigation result. Of
   the 21 DCS-refused GSM8K cases, zero are pure S1-only blockers.
   Acquisition-verb expansion (`collected`, etc.) conflicts with
   ADR-0131.G.1's branch-disagreement discipline. The right fix is
   the DCS injector emitting `CandidateOperation(add)`, but the
   `inject_from_match` return type doesn't allow that.

2. **`docs/decisions/ADR-0170-injector-contract-widening.md`** —
   scoping ADR. Names the contract change, the four categories it
   unblocks (DCS-S1 acquisition, A1 currency, A3 multiplicative,
   A4 temporal), the three load-bearing rules it must preserve
   (ADR-0131.G.1, SentenceChoice union, admissibility gates), and
   a 5-step implementation outline.

## Pattern recognised

Wave-Next surfaced four schema gaps. All four trace to the same
constraint: per-category injectors can only emit `CandidateInitial`.
The right next-capability work is ADR-0170 ratification, then a
small no-behavior-change PR widening the contract, then per-injector
follow-up PRs against the widened contract.

That is the actual lift-per-risk path for GSM8K Round-1 closure.

## Test plan

Docs-only. No code, no test, no eval, no pack change.

## Cross-references

- ADR-0163.D.2 — original parsed_anchors → solver-state ADR
- ADR-0131.G.1 — branch-disagreement discipline ADR-0170 preserves
- ADR-0167 — parallel teaching-corridor mechanism (independent)
- ADR-0167-FOLLOWUPS §7 — Wave-Next findings backlog
- WAVE-NEXT-REVISED — parent plan; ADR-0170 is the upstream blocker
- PR #369 — A2's schema-refusal artifact (first observation of gap)
2026-05-27 10:51:46 -07:00

10 KiB
Raw Permalink Blame History

ADR-0170 — Recognizer Injector Contract Widening

Status: Proposed (scoping ADR; no runtime change in this PR) Date: 2026-05-27 Author: Shay Parent: ADR-0163.D.2 (parsed_anchors → MathProblemGraph) Related: ADR-0131.G.1, ADR-0167, ADR-0167-FOLLOWUPS §7, PR #369 (A2 rate_with_currency), DCS-S1 finding Gating rule: ADR-0166


Context

The inject_from_match dispatch in generate/recognizer_anchor_inject.py has a return type of tuple[CandidateInitial, ...]. Every per-category injector can only emit CandidateInitial records.

Wave-Next (the GSM8K next-capability push) attempted four parallel sub-shape injectors. Three of the four hit the same substrate constraint: the category they targeted cannot be expressed as CandidateInitial alone.

Brief Required emission type CandidateInitial-only sufficient?
A2 rate_with_currency CandidateRate(Rate(amount, num_unit, den_unit)) No
A3 multiplicative_aggregation CandidateInitial(product) OR CandidateOperation(multiply) Partially (product semantics OK; composition not)
A4 temporal_aggregation CandidateOperation(apply_rate, ...) No
DCS-S1 (acquisition verbs) CandidateOperation(add) (per ADR-0131.G.1) No

This is no longer four independent sub-shape gaps. It is one substrate constraint that blocks the entire recognizer-injector capability extension surface.

Decision

Widen the inject_from_match contract so per-category injectors can emit the full SentenceChoice union (and any extensions thereto), not just CandidateInitial.

The widening is type-level only. The dispatch table, the four-narrowness gating, the wrong=0 invariants, the per-sentence admission gate — all preserved. The change is: per-category injectors gain the ability to return CandidateOperation (and, after the corresponding schema work, CandidateRate).

Why this is not "just change a return type"

The widening interacts with three load-bearing existing rules:

1. ADR-0131.G.1 — branch-disagreement discipline

Acquisition verbs (collected, bought, saved, buys, makes) are deliberately routed to ADD_VERBS / SUBTRACT_VERBS rather than to initial-anchor extraction. The reason: "Sam collected 5 apples" could otherwise emit both CandidateInitial(Sam=5) AND CandidateOperation(Sam, add, 5), triggering branch disagreement and refusing the case.

ADR-0170 must preserve this discipline. The DCS injector's acquisition-verb path emits CandidateOperation(add) — matching the existing parser's behaviour for those verbs — NOT CandidateInitial. The solver's defaults-from-zero rule then produces 0 + N for the single-statement case, identical to the parser's current behaviour.

The wrong=0 hazard is: if the injector emits CandidateOperation(add) and the parser ALSO emits CandidateOperation(add) from the same sentence, that's a per-sentence collapsed-tie, not a branch disagreement. Acceptable. But if the injector emits and the parser silently drops the sentence (multi-word unit, etc.), the operation is admitted from only one source — fine, but the test net must verify no admission widens when the parser is updated to also extract.

2. ADR-0167 SentenceChoice union

PR #369 (A2 rate_with_currency) documented that Rate is not in SentenceChoice = Union[CandidateInitial, CandidateOperation]. The follow-up plan (WAVE-NEXT-REVISED.md §Schema-Gap 1) lays out 4 steps for adding CandidateRate. ADR-0170 sequences in front of that work: first widen the injector contract to support the existing union fully (CandidateOperation), then extend the union with CandidateRate in a separate ADR.

3. Existing _initial_admissible gate

generate/math_candidate_graph.py runs _initial_admissible(c) on every injected candidate. That gate is CandidateInitial-specific. Widening to CandidateOperation requires the parallel roundtrip_admissible(c) gate (which exists for parser operations) be applied to injected operations.

This is a real implementation concern but mechanically tractable: the admissibility dispatch becomes:

if isinstance(c, CandidateInitial):
    admissible = _initial_admissible(c)
elif isinstance(c, CandidateOperation):
    admissible = roundtrip_admissible(c)

The branch is one new line per check site; the gating semantics are identical to what the parser already enforces.

Open questions (must resolve in implementation ADR/PR)

  1. Atomic widening vs per-injector: do we widen the return type in one PR (mechanical, no behavior change) and let each future injector PR add a category-specific test for its emission shape? Or bundle widening with the first acquisition-verb-emitting injector (DCS-S1)?

    Recommendation: widen first as a no-behavior-change PR. Existing _INJECTORS table entries continue to return only CandidateInitial; the change is a type-level relaxation. Then future injectors gain the ability without each one paying the widening cost.

  2. CandidateOperation admissibility: confirm that roundtrip_admissible(c) accepts an operation whose surface span covers a single-statement acquisition (no separate "more" or subtractive operand). The existing parser path generates these, so the predicate already accepts them — but verify before relying on it.

  3. Branch-disagreement across parser + injector: when the regex parser updates to handle multi-word units (e.g., "Pokemon cards"), it would emit CandidateOperation(Nicole, add, 400, Pokemon cards) for the same sentence the DCS injector emitted. The _collapse_per_sentence_ties mechanism handles ties; verify it collapses identical operations cleanly. If not, the wider parser work needs to coordinate with the injector path.

  4. CandidateRate sequencing: ADR-0170 is the type widening on the current SentenceChoice union (CandidateInitial | CandidateOperation). Adding CandidateRate is a separate (downstream) ADR — call it ADR-0171. ADR-0170 ships first because it unblocks DCS-S1, A3, A4 without waiting on the rate-type design.

  5. Test pattern: every per-category injector PR after ADR-0170 must pin both the admitted-graph shape AND the case 0050 hazard (no widening of admissions on the hazard canary).

ADR-0166 three-question test

  • Q1 — Capability: A widened injector contract that lets per-category injectors emit CandidateOperation in addition to CandidateInitial. Specifically unblocks DCS-S1 acquisition verbs (the largest single subset of the 21-case DCS bucket), A3 multiplicative composition, A4 temporal aggregation. The capability is mechanism, not measurement — it enables follow-up injector PRs to ship lift.
  • Q2 — Lane: evals/gsm8k_math/train_sample/v1 is the regression surface. Each follow-up injector PR runs its own before/after delta on the existing report. No new canonical lanes (ADR-0166 still gates).
  • Q3 — Invariant: wrong == 0 preserved by the existing five-layer safety net (ADR-0163.D.2): matcher narrowness, extraction correctness, injection correctness (_initial_admissible / roundtrip_admissible), propose-time replay gate, multi-branch decision rule. ADR-0170 extends layer 3's dispatch but doesn't weaken any layer.

Implementation outline (subsequent PRs, not this one)

ADR-0170-impl-W1 — Type widening, no behavior change:

  • Change inject_from_match return type to tuple[CandidateInitial | CandidateOperation, ...]
  • Update _INJECTORS value type
  • Update admissibility dispatch in math_candidate_graph.py to branch on the candidate type
  • Pinning test: existing inject_discrete_count_statement still emits only CandidateInitial; behavior byte-identical pre/post.

ADR-0170-impl-W2 — First operation-emitting injector (DCS-S1):

  • Extend matcher's _POSSESSION_VERBS to accept acquisition verbs OR add a separate _ACQUISITION_VERBS set
  • Injector emits CandidateInitial for has/have/had (existing) AND CandidateOperation(add) for acquisition verbs (new)
  • Tests pin both paths; case 0050 hazard pin remains

ADR-0170-impl-W3 — A1 currency_amount reimplementation:

  • Sandbox config note: ensure additionalDirectories includes /tmp/wt-* paths for any future dispatched agent
  • Injector emits CandidateInitial for <ProperNoun> earns|charges $<amount> (new verbs beyond _INITIAL_HAS_RE's coverage)

ADR-0170-impl-W4 — A3 multiplicative_aggregation:

  • Per A3 design: emit CandidateInitial(outer × inner, inner_unit) pre-computed product (matches solver's defaults-from-zero semantics)

ADR-0170-impl-W5 — A4 temporal_aggregation:

  • Requires apply_rate primitive in the algebra (separate ADR work)
  • Without that primitive, injector continues to refuse explicitly

Each impl PR is small, focused, regression-tested, and ships with its own before/after eval delta.

What ADR-0170 does NOT do

  • It does not widen the SentenceChoice union (that's ADR-0171 for CandidateRate).
  • It does not add new eval lanes (ADR-0166 still gates).
  • It does not propose any non-deterministic mechanism.
  • It does not weaken wrong=0 or the five-layer safety net.
  • It does not mandate that all four blocked injectors ship — each is its own follow-up PR with its own go/no-go decision.

Cross-references

  • ADR-0163.D.2 (the original parsed_anchors → solver-state ADR; ADR-0170 widens its injector contract)
  • [ADR-0131.G.1] — branch-disagreement discipline that ADR-0170 must preserve
  • ADR-0167 — parallel teaching-corridor mechanism; ratification path independent of injector contract
  • ADR-0167-FOLLOWUPS §7 — the four-finding source
  • WAVE-NEXT-REVISED §Schema-Gap 1/3 — the categories this ADR unblocks
  • DCS-S1 finding — the investigation that surfaced the contract gap
  • PR #369 — A2's schema-refusal artifact documenting Rate follow-up