feat(ADR-0170/W1): widen inject_from_match return type — no behavior change (#374)

First implementation PR of the ADR-0170 wave. Type-level widening only:
the recognizer-injector dispatch now returns
``tuple[InjectorEmission, ...]`` where
``InjectorEmission = CandidateInitial | CandidateOperation``.

The existing ``inject_discrete_count_statement`` continues to emit only
``CandidateInitial`` — the widening unlocks but does not exercise
operation emission. Subsequent W2-W5 PRs ship the per-injector emission
shapes:

- W2 — DCS-S1 acquisition verbs (CandidateOperation(add))
- W3 — A1 currency_amount (CandidateInitial reimplementation)
- W4 — A3 multiplicative_aggregation (CandidateInitial(product))
- W5 — A4 temporal_aggregation (deferred until apply_rate primitive)

## Changes

### `generate/recognizer_anchor_inject.py`
- New `InjectorEmission = Union[CandidateInitial, CandidateOperation]`
- `inject_from_match` return type widened to
  `tuple[InjectorEmission, ...]`
- `__all__` exports `InjectorEmission`
- Documentation comment names ADR-0170 §"Implementation outline"

### `generate/math_candidate_graph.py` (admissibility dispatch)
The per-statement admission loop now dispatches admissibility on the
concrete candidate type:

  if isinstance(c, CandidateInitial):
      if _initial_admissible(c): admitted.append(c)
  elif isinstance(c, CandidateOperation):
      if roundtrip_admissible(c): admitted.append(c)

No new admission semantics — each type is gated by the predicate it was
already gated by elsewhere in the codebase. The dispatch unifies the
injector path with the parser path.

### `tests/test_adr_0170_w1_injector_type_widening.py` (new)
- Pin: `InjectorEmission` union members are exactly the two candidate types
- Pin: `inject_from_match` return type is widened
- Pin: `inject_discrete_count_statement` still emits CandidateInitial (W1
  is type-level only)
- Hazard pin: case 0050 remains refused
- Hazard pin: unparseable-verb refusal path (#359) unchanged
- Anti-regression: canonical DCS narrow-form extraction still works

## Test plan

- tests/test_adr_0170_w1_injector_type_widening.py: 6 passed (new)
- tests/test_adr_0163_d2_discrete_count_injection.py: 21 passed
  (existing D.2 v1 injector regression)
- tests/test_brief_11b_audit_artifact.py + step2_lexicon +
  recognizer_skip_wrong_zero + brief_11_audit: 55 passed
- tests/test_candidate_graph_recognizer_wiring.py: 7 passed
- tests/test_candidate_domain_partition.py: 5 passed
- tests/test_adr_0131_G2_comparatives + G4 + G5 + S1_rate_events:
  130 passed
- Total: 225 passed
- evals/gsm8k_math/train_sample/v1: counts=correct=3 refused=47 wrong=0
  (unchanged; verified no behavioral regression)

## Hard invariants

- `wrong == 0` preserved (admissibility dispatch is type-aware but
  semantically identical to the parser path's gating)
- ADR-0166: no new eval lanes
- No teaching-store / pack mutation
- Five-layer wrong=0 safety net (ADR-0163.D.2) intact
- Reader path unchanged
This commit is contained in:
Shay 2026-05-27 11:23:08 -07:00 committed by GitHub
parent ecc0072ea1
commit eb452da9be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 196 additions and 8 deletions

View file

@ -727,9 +727,24 @@ def parse_and_solve(
)
injected = inject_from_match(recognizer_match, s)
if injected:
admitted: list[SentenceChoice] = [
c for c in injected if _initial_admissible(c)
]
# ADR-0170 — dispatch admissibility on the
# concrete candidate type. CandidateInitial uses
# the existing _initial_admissible gate;
# CandidateOperation uses the parser's
# roundtrip_admissible gate (same predicate
# operations from the regex path already pass
# through). No new admission semantics — each
# type is gated by the predicate it was always
# gated by; the dispatch just unifies the
# injector path with the parser path.
admitted: list[SentenceChoice] = []
for c in injected:
if isinstance(c, CandidateInitial):
if _initial_admissible(c):
admitted.append(c)
elif isinstance(c, CandidateOperation):
if roundtrip_admissible(c):
admitted.append(c)
if len(admitted) == len(injected) and admitted:
per_sentence_choices.append(
_collapse_per_sentence_ties(admitted)

View file

@ -42,13 +42,23 @@ section) is preserved across this module:
from __future__ import annotations
from typing import Mapping
from typing import Mapping, Union
from evals.refusal_taxonomy.shape_categories import ShapeCategory
from generate.math_candidate_parser import CandidateInitial
from generate.math_candidate_parser import CandidateInitial, CandidateOperation
from generate.math_problem_graph import InitialPossession, MathGraphError, Quantity
from generate.recognizer_match import RecognizerMatch
# ADR-0170 — the widened injector emission type. Per-category injectors
# may emit a tuple of ``CandidateInitial`` (existing) or
# ``CandidateOperation`` (new, ADR-0170). The downstream
# ``per_sentence_choices`` aggregator dispatches admissibility on the
# concrete type (``_initial_admissible`` vs ``roundtrip_admissible``).
# No new admission paths are introduced by the widening itself; new
# emission shapes ship in subsequent per-injector PRs (ADR-0170 §"impl
# outline" W2/W3/W4/W5).
InjectorEmission = Union[CandidateInitial, CandidateOperation]
# ---------------------------------------------------------------------------
# Public surface
@ -58,12 +68,15 @@ from generate.recognizer_match import RecognizerMatch
def inject_from_match(
match: RecognizerMatch,
sentence: str,
) -> tuple[CandidateInitial, ...]:
) -> tuple[InjectorEmission, ...]:
"""Dispatch a recognizer match to its per-category injector.
Returns an empty tuple when the category has no v1 injector or when
the v1 injector refused. Skip-only behavior (the round-2 default)
is the empty-tuple result.
the v1 injector refused. Per ADR-0170, the return type is now
``tuple[InjectorEmission, ...]`` (``CandidateInitial | CandidateOperation``)
so per-category injectors can emit operations as well as initials.
The v1 ``discrete_count_statement`` injector continues to emit only
``CandidateInitial`` the widening is type-level only in this PR.
"""
injector = _INJECTORS.get(match.category)
if injector is None:
@ -260,6 +273,7 @@ _INJECTORS: Mapping[ShapeCategory, "type"] = {
__all__ = [
"InjectorEmission",
"inject_from_match",
"inject_discrete_count_statement",
]

View file

@ -0,0 +1,159 @@
"""ADR-0170 W1 — type widening pinning tests.
These tests pin the no-behavior-change widening of
``inject_from_match``'s return type. The contract becomes
``tuple[CandidateInitial | CandidateOperation, ...]`` so per-category
injectors can emit operations as well as initials. The existing
``inject_discrete_count_statement`` still emits only ``CandidateInitial``;
the widening is type-level only in this PR.
References:
- docs/decisions/ADR-0170-injector-contract-widening.md §"Implementation
outline" W1 (this PR)
- docs/handoff/DCS-S1-FINDING.md the investigation that surfaced the
contract gap
- PR #369 (A2) — the schema-refusal that first observed the gap
"""
from __future__ import annotations
from typing import get_type_hints, Union, get_args, get_origin
import pytest
from generate.math_candidate_parser import CandidateInitial, CandidateOperation
from generate.recognizer_anchor_inject import (
InjectorEmission,
inject_from_match,
inject_discrete_count_statement,
)
# ---------------------------------------------------------------------------
# Type-level contract
# ---------------------------------------------------------------------------
def test_injector_emission_union_includes_both_candidate_types():
"""``InjectorEmission`` is the union of ``CandidateInitial`` and
``CandidateOperation``. Future injector PRs can emit either."""
args = get_args(InjectorEmission)
assert CandidateInitial in args
assert CandidateOperation in args
# No third type smuggled in — the union is exactly two members.
assert len(args) == 2
def test_inject_from_match_return_type_is_widened():
"""The dispatcher returns a tuple of ``InjectorEmission`` (not just
``CandidateInitial``). This pins the W1 widening; reverting to a
narrower return type without an explicit ADR amendment fails this
test."""
hints = get_type_hints(inject_from_match)
return_type = hints["return"]
# tuple[InjectorEmission, ...]
assert get_origin(return_type) is tuple
inner = get_args(return_type)
# tuple[X, ...] reports (X, Ellipsis)
assert inner[-1] is Ellipsis
inner_type = inner[0]
# The element type is either InjectorEmission directly or its
# Union[CandidateInitial, CandidateOperation] expansion.
if get_origin(inner_type) is Union:
members = set(get_args(inner_type))
assert {CandidateInitial, CandidateOperation}.issubset(members)
else:
# Annotated alias case — resolve once.
assert inner_type is InjectorEmission
# ---------------------------------------------------------------------------
# Behavioral pin — existing DCS injector unchanged
# ---------------------------------------------------------------------------
def test_discrete_count_injector_still_emits_only_candidate_initial():
"""W1 is type-level only. The existing
``inject_discrete_count_statement`` returns ``CandidateInitial``
not ``CandidateOperation`` at runtime. This is the byte-identical
behavior guarantee for the W1 PR.
Mechanically: pre-W1 the function returned
``tuple[CandidateInitial, ...]``. Post-W1 it still does. Subsequent
PRs (W2 DCS-S1 acquisition, W3 currency, W4 multiplicative) widen
the per-injector emission shapes; W1 ships only the dispatcher
contract."""
import inspect
sig = inspect.signature(inject_discrete_count_statement)
# With ``from __future__ import annotations`` the return annotation
# is stored as a string. The W1 pin is that the existing DCS
# injector's *narrower* return type is unchanged — only the
# dispatcher (``inject_from_match``) widens.
assert sig.return_annotation == "tuple[CandidateInitial, ...]"
# ---------------------------------------------------------------------------
# Behavioral pin — case 0050 hazard
# ---------------------------------------------------------------------------
def test_case_0050_remains_refused_post_widening():
"""The widening must not weaken the wrong=0 invariant. Case 0050
refuses pre-W1 and must continue to refuse post-W1."""
from generate.math_candidate_graph import parse_and_solve
case_text = (
"Mark does a gig every other day for 2 weeks. "
"For each gig, he plays 3 songs. "
"2 of the songs are 5 minutes long and the last song is twice that long. "
"How many minutes did he play?"
)
result = parse_and_solve(case_text)
assert not result.is_admitted, (
f"case 0050 admitted post-W1 — wrong=0 hazard re-introduced: "
f"answer={result.answer!r} graph={result.selected_graph!r}"
)
def test_unparseable_verb_still_refuses_post_widening():
"""The recognizer-no-injection refusal path (the #359 wrong=0 fix)
is unchanged by the W1 widening. Unparseable verbs still produce
explicit refusals, not silent admissions."""
from generate.math_candidate_graph import parse_and_solve
result = parse_and_solve(
"Sam has 5 apples. Sam contemplates 3 apples. "
"How many apples does Sam have?"
)
assert not result.is_admitted
assert result.refusal_reason is not None
# ---------------------------------------------------------------------------
# Anti-regression: existing DCS path still admits
# ---------------------------------------------------------------------------
def test_existing_dcs_admission_path_unchanged():
"""A canonical narrow-form DCS sentence (proper noun + has + count
+ observed counted_noun) still admits via the existing injector.
The widening must not regress the v1 admission path."""
from generate.recognizer_match import _try_extract_discrete_count_anchor, _padded_lower
from generate.math_candidate_graph import _load_ratified_registry_or_empty
reg = _load_ratified_registry_or_empty()
dcs_specs = [
r.canonical_pattern for r in reg
if r.shape_category.value == "discrete_count_statement"
]
assert dcs_specs, "no ratified DCS recognizer on main"
spec = dcs_specs[0]
stmt = "Nicole has 400 Pokemon cards."
padded = _padded_lower(stmt)
anchor = _try_extract_discrete_count_anchor(stmt, padded, spec)
assert anchor is not None, (
"canonical DCS extraction regressed post-W1 — "
f"'Nicole has 400 Pokemon cards.' should extract"
)
assert anchor["subject_role"] == "Nicole"
assert anchor["count_token"] == "400"
assert anchor["counted_noun"] == "Pokemon cards"