# ADR-0186 — Sealed candidate-graph injector lane: resume ADR-0170 W2–W5 under the ADR-0175 seal

**Status:** Proposed (scoping + seal-mechanism ADR; first injector ships behind the seal)
**Date:** 2026-05-29
**Parent / resumes:** [ADR-0170](./ADR-0170-injector-contract-widening.md) (the W1–W5 injector roadmap; W1 type-widening shipped, W2–W5 are scoped-but-unbuilt go/no-go follow-ups)
**Governed by:** [ADR-0175](./ADR-0175-calibrated-attempt-and-eliminate-learning.md) (the serving/sealed two-regime doctrine + the Phase-5 ratification bridge)
**Gating:** [ADR-0166](./ADR-0166-measurement-capability-sequencing.md)
**Cue discipline:** [ADR-0165](./ADR-0165-regex-scope-rule.md)
**Supersedes the premise of:** [ADR-0185](./ADR-0185-division-reading.md) — its
central claim ("the engine cannot divide") is true only of the derivation reader,
which does not feed the goal metric. The candidate-graph reader (the goal organ)
**already divides**; a division *reading* added to `generate/derivation/*` cannot
move `3/47/0`. ADR-0185 is retained as a record but is **not implemented**; any
division work belongs in the candidate-graph path under this ADR's seal.

---

## 1. The microscope finding that motivates this (and corrects a session-long misframe)

A topology audit (2026-05-29) established that the GSM8K substrate has **two
structurally disjoint readers**, sharing only a tokenizer (`_tokens`,
`WORD_NUMBERS`, `_value_grounds` from `generate/math_roundtrip`) — no shared
recognizer, solver, problem-graph, or injectors:

| | **candidate-graph reader** | **derivation reader** (`resolve_pooled`) |
|---|---|---|
| Owns the official metric? | **Yes** — `_score_one_candidate_graph` produces `3/47/0` | No — called only by the confuser runner + tests |
| `train_sample` score | **3 / 47 / 0** (wrong = 0) | 2 / 8 / 40 (wrong = 8) |
| Arithmetic | divide, multiply, add, subtract, `compare_additive`, `Rate` | add + multiply only |
| Status | **frozen serving** ("stays `3/47/0` until ratified") | sealed practice (ADR-0175) |

Three consequences this ADR acts on:

1. **The recent ADR-0182/0184/0185 work matured the derivation reader — which does
   not feed the goal metric.** It is real work on the *sealed comprehension* organ,
   but it moves nothing on official GSM8K, and it cannot until an ADR-0175 Phase-5
   bridge exists.
2. **The goal organ already has the arithmetic.** ADR-0185's premise ("the engine
   cannot divide") is true only of the derivation reader. The candidate-graph reader
   *already* divides, multiplies, and does comparative arithmetic. Adding a division
   *reading* to `generate/derivation/*` cannot move `3/47/0`.
3. **The goal organ's wall is injection coverage + multi-statement composition, not
   arithmetic.** All 47 refusals are at the recognizer→graph boundary: *"recognizer
   matched but produced no injection"* (35) or *"no admissible candidate"* (12). The
   capable solver downstream is never fed.

### 1.1 The 47 refusals, by first-blocking category

| category | n | injector status (per ADR-0170) |
|---|---|---|
| `discrete_count_statement` | 20 | injector EXISTS; refuses these because the statement carries *additional* structure (comparative, multi-count, percentage, partition) — mostly genuine composition, not a leaf gap |
| (no category — "no admissible candidate") | 12 | deep structural (fraction-of-fraction, multi-clause comparative) |
| `descriptive_setup_no_quantity` | 4 | **by design** (no quantity to inject) |
| `rate_with_currency` | 3 | **no injector** — needs `CandidateRate` (the planned `SentenceChoice` union extension, ADR-0170 §Open-Q4, not yet written) |
| `multiplicative_aggregation` | 3 | WAVE-A injector exists, narrows by anchor kind |
| `currency_amount` | 3 | **no injector** — `CandidateInitial`-shaped (ADR-0170 W3) |
| `temporal_aggregation` | 2 | **no injector** — needs `apply_rate` primitive (ADR-0170 W5) |

This is precisely the ADR-0170 W2–W5 backlog. **We are resuming a roadmap the project
already scoped, not inventing one** (ADR cross-reference discipline).

### 1.2 Honest scope — one injector does not solve a case

A per-statement instrumentation probe was **rejected as unreliable**: a standalone
`recognizer_match.match(s)` returns `NO_MATCH` for statements the real loop classifies,
because the live loop feeds `prior_subject` + the ratified registry that the standalone
call lacks. So **no claim is made about which single injector flips which case.** The
refusals are dominated by multi-statement composition; a leaf injector is *necessary
groundwork* whose first effect is **elimination** (grounding a previously-unread
quantity), with **solves arriving only when a case's full statement set + question all
inject and compose.** Claiming a single injector flips N cases would be the overfitting
trap ([[feedback-synthetic-corpus-overfitting-trap]]).

## 2. The tension this ADR resolves

ADR-0170 (2026-05-27) scoped W2–W5 and its Q2 stated the lane is
`evals/gsm8k_math/train_sample/v1` with "each follow-up injector PR runs its own
before/after delta **on the existing report**" — i.e. injectors were expected to
*change* `train_sample` directly.

ADR-0175 (the **later, governing** direction) sealed serving: **`3/47/0` is frozen
until a Phase-5 reviewed promotion.** A W2–W5 injector that changes the frozen
`report.json` would violate the seal and trip `generate_claims.py --check`.

**Resolution.** ADR-0170's injectors are the right capability work; under ADR-0175
they must be developed **sealed** — measured on a sealed lane, with the frozen
`3/47/0` artifact **byte-identical**, and promotion to serving deferred to Phase-5.
This ADR defines that seal.

## 3. Decision — a flag-gated sealed injector dispatch

ADR-0174 Phase 5a retired flag-gated *reader variants* (two parallel question
parsers) because maintaining two reader code-paths bred drift. This ADR does **not**
reintroduce that: there is **one** injector dispatch; the seal is a single boolean on
the dispatch that selects *which categories are eligible to emit*, not a forked reader.

```
inject_from_match(match, sentence, *, sealed: bool = False)
```

- `sealed=False` (default) — the **frozen** behavior. `_INJECTORS` resolves exactly
  the categories that ship today. `_score_one_candidate_graph` and the frozen
  `train_sample` runner call with the default → **`3/47/0` byte-identical.**
- `sealed=True` — the **sealed lane**. The dispatch additionally consults
  `_SEALED_INJECTORS` (the new W2–W5 entries). A separate sealed runner
  (`evals/gsm8k_math/train_sample/v1/run_sealed_injectors.py`) calls with
  `sealed=True` and writes `report_sealed.json` — the progress metric.

Phase-5 promotion is then a **one-line, reviewed flip**: moving a category from
`_SEALED_INJECTORS` into `_INJECTORS` (or flipping the default), accompanied by a
regenerated, ratified `report.json`. Until that review, serving is untouched.

### 3.1 Why a boolean parameter, not an env var or a forked module

- **Not a forked module** — that is the reinvention ADR-0174 Phase 5a warned against;
  the sealed path must be the *same* recognizer/solver, differing only in injector
  eligibility, so a sealed solve is a true preview of the promoted behavior.
- **Not an env var** — invisible global state breaks deterministic replay and is a
  forbidden hidden-behavior surface (CLAUDE.md). An explicit keyword argument is
  inspectable, defaulted-safe, and threaded only through the eval call site.
- **Default-off** — the freeze is the default; you must *opt in* to the sealed lane,
  so no accidental serving drift is possible.

## 4. wrong=0 obligations (the seal's guarantees, before any merge)

1. **Frozen path byte-identical.** With `sealed=False`, `train_sample/report.json`
   stays `{"correct":3,"refused":47,"wrong":0}` byte-identical;
   `generate_claims.py --check` and lane-SHA checks pass unchanged. A test pins this.
2. **Sealed lane `wrong=0`.** On `report_sealed.json`, `wrong` must be **0** — a
   sealed injector that lets the solver commit a wrong answer is rejected outright.
   `correct` may rise (or hold, with refusals converted to grounded-but-still-refused
   eliminations); `wrong` may **never** rise above 0.
3. **The five-layer net is preserved, not weakened** (ADR-0163.D.2 / ADR-0170 Q3):
   matcher narrowness → extraction correctness → injection admissibility
   (`_initial_admissible` / `roundtrip_admissible`) → propose-time replay gate →
   multi-branch decision rule. A sealed injector adds an `_INJECTORS`-shaped entry; it
   passes through every existing gate.
4. **Case 0050 hazard pin** (ADR-0167 W2-D / [[feedback-wrong-zero-hazard-case-0050]]):
   the sealed lane must not widen admissions on the 0050 canary.
5. **No overfitting.** Each injector is a *general* shape (a category the recognizer
   already classifies), validated on the whole `train_sample` + the confuser probe,
   never tuned to specific rows. No per-case rule.

## 5. First injector — selection is code-constrained, not category-aesthetic

The "cleanest leaf" intuition (currency_amount, because it is a plain
`CandidateInitial`) **did not survive contact with the code**, and recording why is
part of the eliminate-first discipline:

- `_match_currency_amount` is **detection-only** — it returns `(tuple(), "amount")`
  with **empty `parsed_anchors`** (no value, no entity). An injector therefore has
  nothing to consume; W3 first requires *extending the matcher to extract values*
  (an `extract_values=True` anchor path), which is upstream surface, not a leaf.
- `CandidateInitial.__post_init__` **whitelists the anchor verb**. `"It cost
  $100,000…"` (case 0028) uses `cost`, which is **not** a registered initial-state
  anchor; emitting an initial for it would raise. So W3 also needs a dataclass
  whitelist change — more wrong=0 surface than assumed.

### 5.1 What the live-loop instrumentation actually showed

Wrapping `inject_from_match` inside the **live** loop (the standalone `match()` probe
of §1.2 is untrustworthy — it lacks `prior_subject` + the ratified registry) over the
47 refusals revealed a **schema-vs-extraction split** that determines surface:

| refusing category | n | `parsed_anchors` already populated? | blocked on |
|---|---|---|---|
| `rate_with_currency` | 3 | **yes (3/3)** — `amount, per_unit, currency_symbol, kind` | **schema** — needs `CandidateRate` in the `SentenceChoice` union |
| `temporal_aggregation` | 2 | **yes (2/2)** — `count_token, window_unit, window_quantifier` | **schema** — needs an `apply_rate` primitive |
| `discrete_count_statement` | 20 | no (empty) | matcher **extraction** + downstream composition |
| `currency_amount` | 3 | no (empty) | matcher **extraction** + `CandidateInitial` anchor-whitelist (`cost`) |
| `multiplicative_aggregation` | 3 | no (empty) | matcher **extraction** (WAVE-A partial) |
| `descriptive_setup_no_quantity` | 4 | no (by design) | nothing to inject |

So there is **no 10-line leaf win**: the matcher-complete categories are blocked on a
schema extension, and the schema-free categories are blocked on matcher extraction.

### 5.2 The deeper finding: no case is one injector away

Tracing the matcher-complete cases to their full requirement set:

- **0001** (`Tina makes $18/hour`, gold 990) needs rate **+** the `1/2`-wage overtime
  fraction **+** a `>8 hours` threshold **+** `10 hours × 5 days`.
- **0017** (`$50/day or $500 for 14 days`, gold 800) needs min-cost reasoning over
  `$50×20` vs `$500×⌈20/14⌉` blocks.

**Every refused case is multi-statement.** A single injector grounds one quantity but
leaves the graph incomplete → the case **stays refused** (no report-level delta). The
candidate-graph reader's 47 refusals are therefore **not** unlocked one leaf at a time;
the unit of measurable progress is a **target case's complete injector + composition
set**, landing together.

### 5.3 Consequence for sequencing

1. **Seal mechanism first** (§3) — ships and is validated with an empty
   `_SEALED_INJECTORS` proving the frozen path is byte-identical and the sealed lane
   exists (a true wrong=0 guarantee; not yet a capability claim).
2. **`CandidateRate` schema next** (the planned ADR-0170 §Open-Q4 work) — the highest-
   leverage, most foundational unblock: the `rate_with_currency` + `temporal_aggregation`
   matchers already extract everything; rate is pervasive across GSM8K. This is its own
   ADR + sealed PR.
3. **A first complete target-case unlock** — pick the case with the *smallest* full
   requirement set (injectors + composition), land its set together on the sealed lane,
   and measure a `report_sealed.json` `correct++` with `wrong=0`. Only then is a
   capability claim earned.

In all cases the first effect of any individual injector is honestly **elimination**
(grounding a previously unread quantity); a **solve** requires the whole case's set —
per §1.2/§5.2, no flip count is claimed in advance.

Deferred to follow-ups (each its own go/no-go, all sealed): the remaining W2–W5
injectors and `rate_with_currency` (needs the planned `CandidateRate` union extension,
ADR-0170 §Open-Q4).

## 6. What this ADR does NOT do

- Does **not** change serving `3/47/0` (the whole point of the seal).
- Does **not** touch the derivation reader / `resolve_pooled` (the disjoint sealed
  comprehension organ; its confuser work stands on its own lane).
- Does **not** build the Phase-5 ratification bridge — that remains the ADR-0175
  deliverable and the hard dependency before *any* sealed gain reaches the headline.
- Does **not** add a non-deterministic mechanism, a forked reader, or hidden global
  state.

## 7. ADR-0166 three-question test

- **Q1 — Capability:** a sealed injector lane that lets ADR-0170 W2–W5 injectors be
  developed and measured without mutating frozen serving. First deliverable is the
  seal mechanism itself; the first *capability* is the `CandidateRate` schema (§5.3),
  since the matcher-complete `rate_with_currency`/`temporal_aggregation` categories are
  blocked on schema, not extraction.
- **Q2 — Lane:** `evals/gsm8k_math/train_sample/v1/report_sealed.json` (new, sealed).
  The frozen `report.json` stays the ratified serving artifact.
- **Q3 — Invariant:** `wrong == 0` on both the frozen path (byte-identical) and the
  sealed lane, enforced by the unchanged five-layer net + the §4 pins.

## 8. Why this obeys the standing principles

- **Use the deterministic system as a microscope** (the GOAL): this ADR exists
  *because* the microscope dissected the 47 refusals and proved the wall is injection,
  not arithmetic — and that the session had been maturing the wrong organ.
- **Eliminate failures first, then solve:** a sealed injector's first job is to ground
  an unread quantity (eliminate the unread-refusal), earning a solve only when the
  whole case composes.
- **Decode, don't guess** ([[thesis-decoding-not-generating]]): the injector reads a
  quantity the recognizer already identified; it stores no flat answer.
- **wrong=0 > coverage** (ADR-0175): the seal makes serving regression structurally
  impossible; sealed `wrong` is gated at 0.
- **No reinvention** (ADR cross-reference discipline): resumes ADR-0170's own roadmap
  under ADR-0175's seal, rather than inventing a parallel path.
