Merge pull request #427 from AssetOverflow/feat/adr-0174-phase3-lookback-reevaluate

feat(adr-0174-phase3a): lookback re-evaluation operator + pronoun resolution substrate
This commit is contained in:
Shay 2026-05-28 11:35:10 -07:00 committed by GitHub
commit 1f7a1c4ac6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1509 additions and 3 deletions

View file

@ -292,3 +292,130 @@ This ADR moves to **Accepted** when:
- **Thesis anchor**: [[thesis-decoding-not-generating]] — every change in this ADR must pass the "teach the engine to find, not store another found thing" gate.
- **HITL corridor preserved**: ADR-0150 (contemplation), ADR-0152 (proposal), ADR-0155 (review), ADR-0161 (HITL queue), ADR-0172 (math-corpus decomposition).
- **Anti-overfitting obligations**: ADR-0114a — held-hypothesis reads are evaluated against the same obligations as the existing pipeline; perturbation, OOD ratio, depth curve, and adversarial axes all apply.
---
## Implementation Notes (added 2026-05-28 after Phase 1-3a lookback review)
The 2026-05-28 lookback review (per CLAUDE.md §Lookback Review Discipline)
surfaced drift between this ADR's spec text and what Phases 1-3a
actually shipped. Documenting honestly here so Phase 4-5 implementers
work from accurate ground truth.
### `reevaluate` signature — implementation differs from spec
**ADR text (§Decision §2):**
```text
reevaluate(hypotheses, new_token, position) -> (refined_hypotheses, eliminations)
```
**As shipped (Phase 3a, PR #423):**
```python
reevaluate(hypothesis: Hypothesis, refinement: Refinement) -> ReevaluateResult
```
Differences and rationale:
- Single hypothesis + refinement object, not hypothesis set + token+position.
More composable: refinement objects (`PronounResolution` etc.) are
reusable; the caller decides which hypotheses to apply to which
refinements.
- Returns a single `ReevaluateResult` (refined-or-None plus
bookkeeping), not a (refined_hypotheses, eliminations) tuple.
Bulk-eliminate semantics belong on a higher-level orchestrator (not
yet built — Phase 4 work).
The shipped design is preferred and the ADR text should be considered
amended. Phase 4 implementers should follow the shipped signature.
### Phase 2 is per-candidate, not per-token
**ADR text (§Decision §3):**
> Move them inside the reader so they fire per-token: After every EMIT,
> run the in-flight constraint check against the partial hypothesis.
**As shipped (Phase 2, PR #420):**
Constraint propagation runs at the `math_candidate_graph` recognizer-
injection site (per-candidate, after `inject_from_match` returns), not
inside `lifecycle.apply_word` (per-token, during reading).
This is a real substrate-vs-active-wiring gap. The check_constraints
primitive is available; the per-token integration is Phase 5 work
(legacy parser removal + apply_word refactor). Phase 2 is more
honestly described as "constraint propagation substrate ready for
per-token wiring in Phase 5."
### Lookback recompute scope is candidate-level, not token-level
**ADR text (§Decision §2):**
> For each hypothesis, recomputes the category assignment of any *prior*
> token whose role depended on the now-resolved ambiguity.
**As shipped (Phase 3a, PR #423):**
`PronounResolution` appends one `(0, "pronoun_resolved", pronoun)`
entry to `Hypothesis.category_assignments` and rewrites the candidate's
semantic actor field. It does not walk back through per-token
assignments because Phase 1-3a's `category_assignments` is
candidate-level, not token-level. Per-token category assignment
becomes meaningful in Phase 5 (apply_word refactor).
Phase 3b will widen this when compound-clause refinement enters
(multiple per-clause assignments do need recompute walks).
### Hypothesis.constraint_state is never populated by Phase 2
The Phase 1 substrate carries `Hypothesis.constraint_state: tuple[tuple[str, str], ...]`
for recording predicate outcomes. Phase 2's `check_constraints`
populates `ConstraintResult.predicates_run` but does NOT copy that
into `Hypothesis.constraint_state` on the survivors. Survivors carry
forward their original (empty) constraint_state.
Phase 4 (in-loop contemplation) may want to consult
`constraint_state` to decide which evidence to seek. If so, Phase 4
must wire the population step explicitly. Not a Phase 2 defect — it's
a Phase 2 scope limit.
### Multi-actor pronoun wrong=0 hazard defense (Phase 3a follow-up)
The 2026-05-28 review surfaced a real wrong=0 hazard in Phase 3a's
`PronounResolution` wiring: the `_discourse_prior_subjects` lookup is
gender-blind and stores only most-recent-prior subject. In multi-actor
problems ("Alice has 5. Bob has 3. She buys 2."), this could resolve
"She" to "Bob" and produce wrong attribution.
Fix landed in the same Phase 3a PR (PR #423): defensive
`no_antecedent_ambiguous` refusal when more than one distinct
proper-noun subject appears in prior context. Refusal-preferring
discipline preserves `wrong = 0`.
This is the prototype for refinement-quality gating that Phase 4 (in-
loop contemplation) inherits: ambiguity that resolution cannot
disambiguate is a refusal, not a guess.
### LOC accounting
The ADR §"What this collapses" projects "Net ~1,900 lines removed"
once Phase 5 retires the legacy parser. Honest current state:
- Phase 1 added ~243 lines (`state.py`)
- Phase 2 added ~726 lines (`constraint_propagation.py` new module) +
~50 lines `math_candidate_graph.py` wiring
- Phase 3a added ~387 lines (`lookback.py` new module) + ~125 lines
`math_candidate_graph.py` wiring + ~15 lines `recognizer_match.py`
**Phases 1-3a net: +1,500 lines added.** Phase 5 will remove
math_parser.py (~1,100 lines) + per-category dispatch (~400 lines) +
duplicate per-sentence-choice scaffolding (~300 lines) to reach the
projected net removal.
The substrate is correctly load-bearing; the line-count payoff is in
Phase 5.
### Test coverage backfill
The lookback review found 13 of 17 `VALID_PREDICATE_NAMES` lacked
direct predicate-name assertions in tests, and all 4
`_check_composed_initial` sub-checks were untested (parity verified
manually). Backfill landed in the same Phase 3a PR (10 new tests).
---

View file

@ -0,0 +1,179 @@
# Phase 3.1 Follow-up — Verb-coverage bottleneck on train_sample/v1
**Status:** Open recommendation
**Date:** 2026-05-28
**Author:** Shay (analysis surfaced during ADR-0174 Phase 3a)
**Parent:** [ADR-0174 — Held-Hypothesis Comprehension](../decisions/ADR-0174-held-hypothesis-comprehension.md)
**Related ADRs:** ADR-0163 (path to GSM8K mastery), ADR-0167 (audit-as-teaching-evidence), ADR-0150/0152/0155/0161 (HITL corridor)
---
## Context
ADR-0174 Phase 3 specified a `correct ≥ 8` lift target on
`evals/gsm8k_math/train_sample/v1` (≥ 5 of the 21 currently-empty
`discrete_count_statement` anchors admitted via lookback). Empirical
analysis during Phase 3a implementation found this target is
**not achievable through lookback alone** on this corpus. The
substrate is built correctly; the bottleneck is elsewhere.
## What Phase 3a shipped
- `generate/comprehension/lookback.py` — the `reevaluate` operator,
`PronounResolution` refinement type, `ReevaluateResult` dataclass.
- Held-anchor emission in `recognizer_match._try_extract_discrete_count_anchor`
(pronoun-subject statements carry `requires_pronoun_resolution=True`
rather than refusing).
- Lookback wiring at `math_candidate_graph.parse_and_solve`'s
recognizer-injection branch — applies `PronounResolution` against
the existing `_discourse_prior_subjects` map; emits `lookback` JSON
trace events with `outcome ∈ {admitted, eliminated, no_antecedent}`.
- 17 acceptance tests proving the wiring works on synthetic problems
(`tests/test_adr_0174_phase3_lookback.py`).
- `wrong = 0` invariant preserved; score unchanged at 3/47/0.
## Why Phase 3a did not lift the score
The 21 empty-anchor `discrete_count_statement` refusals on
train_sample/v1 break down as:
| Structural cause | Cases |
|---|---|
| Pronoun-only (no compound clause) | 2 — 0002, 0034 |
| Compound-only | 8 |
| Pronoun + compound | 5 |
| Other narrowness fail (verb/structure) | 6 |
For Phase 3a to lift any case, **three conditions** must all hold:
1. The matcher's recognizer registry recognises the statement.
2. The extractor passes every narrowness layer **before** the pronoun
check. Specifically the verb must be in `_POSSESSION_VERBS` (`has`,
`have`, `had`) or `_ACQUISITION_VERBS` (`collected`, `collects`,
`collect`, `received`, `receives`, `receive`, `bought`, `buys`,
`buy`, `got`, `gets`, `get`).
3. The candidate-graph's regex path (`_filtered_statement_choices`)
must return empty for the same statement — otherwise the regex
path commits the candidate (with the pronoun still as actor) and
the recognizer-injection branch never runs.
Verb checks against the 13 cases with compound/pronoun structure:
| Case | Statement (excerpt) | Verb | In whitelist? |
|---|---|---|---|
| 0002 | She **splits** it up... | splits | No |
| 0034 | He can **run** 40 yards... | run | No |
| 0020 | Two puppies, two kittens... **were for sale**... | were | No |
| 0021 | He **bench presses** 15 pounds... | presses | No |
| 0027 | Malcolm **has** 240 followers... | has | **Yes** |
| 0033 | Rachel **is** 12 years old... | is | No |
| 0040 | He now **has** 2 horses... | has | **Yes** |
| 0041 | Troy **bakes** 2 pans... | bakes | No |
| 0044 | John **invests** in a bank... | invests | No |
| 0045 | On Monday he **finished** 3 surveys... | finished | No |
| 0047 | John **bakes** 12 coconut macaroons... | bakes | No |
| ... | | | |
Only **two** cases (0027, 0040) cross the verb whitelist. Both also
fail at the compound-clause narrowness layer (which comes earlier
than the pronoun check), so even adding compound-clause held
hypotheses (Phase 3b) would have to fire first.
**Conclusion:** the empirical bottleneck on train_sample/v1 is
**verb-set coverage**, not lookback or held hypotheses. ADR-0174 is
the wrong tool for moving this score.
## Recommended path forward
ADR-0163 is the correct scope for verb-coverage expansion via the
HITL corridor. The path:
1. **Run `core eval math-contemplation` on the 11 failing verbs**
`splits`, `run`, `bench presses`, `is`, `bakes`, `invests`,
`finished`, `donated`, `wants`, `gained`, `eat`. These surface as
`MathReaderRefusalEvidence` audit rows that the contemplation lane
already consumes (ADR-0167).
2. **Operator review in workbench** — categorise each verb:
- Acquisition-class (engine should treat as `add`): `received`,
`bought`, etc. — verbs that grammatically gain quantity to actor.
Candidates from list: `gained`, `won`, `earned`, `saved`,
`accumulated`, `acquired`.
- Depletion-class (engine should treat as `subtract`): `gives`,
`loses`, `spends`. Candidates: `donated`, `gave`, `eats`,
`consumed`, `lost`, `spent`.
- Non-arithmetic verbs (engine should refuse and ask): `is`,
`wants`, `bench presses`, `splits`, `run`, `bakes`, `invests`.
These do not carry possession/acquisition semantics; the right
answer is a different intent (rate / capacity / descriptive),
not a wider `add`/`subtract` whitelist.
The first two classes ratify into the registry via the existing
ADR-0150/0152 corridor (proposal → review → packed). The third
class becomes refusal-typed evidence that informs whether a
separate recognizer category is needed (e.g. a `capacity_statement`
recognizer for "He can run 40 yards in 5 seconds" rather than
forcing it into `discrete_count_statement`).
3. **After verb widening lands** — re-run Phase 3a's lookback wiring
on the corpus. The cases that were previously verb-blocked now
reach the pronoun-check layer, and the held-hypothesis path admits
them. Expected lift from this combination: roughly the 13 cases
with pronoun/compound structure that have an arithmetic-class verb
under the widened whitelist.
## What this means for ADR-0174
The held-hypothesis substrate (Phase 1 + 2 + 3a) is correct
architecture and load-bearing for Phase 4 (in-loop contemplation) and
Phase 5 (legacy-parser removal). Its **eval impact** depends on
upstream recognizer coverage maturing through the ADR-0163.x
corridor. These two efforts are complementary, not competing — the
substrate makes lookback possible, the recognizer expansion gives
lookback something to fire on.
The cleanest sequencing is:
1. **ADR-0174 Phase 3a (this PR)** — substrate landed.
2. **ADR-0163.x verb expansion** (this brief's recommendation) —
widens the corpus surface that the substrate can act on.
3. **ADR-0174 Phase 3b** — compound-clause held hypotheses. Once the
verb-coverage bottleneck is gone, compound-clause expansion
surfaces real cases. Currently it would surface zero on
train_sample for the same reason Phase 3a does: most compound
cases also fail the verb check before reaching the clause-split
narrowness layer.
4. **ADR-0174 Phase 4** — in-loop contemplation. Builds on Phase 3
substrate.
5. **ADR-0174 Phase 5** — legacy parser removal.
## Decision needed (from operator)
- **Authorise the ADR-0163.x verb-expansion contemplation pass?**
Concretely: run `core eval math-contemplation` against the 11
failing verbs above; review the proposals in workbench; ratify
acquisition/depletion entries that are unambiguous.
- **Re-scope ADR-0174 Phase 3b** to "post-recognizer-expansion
re-measurement" rather than "compound-clause held hypotheses"?
Phase 3b should land only after verb expansion exposes cases that
exercise its compound-clause logic.
No timelines are proposed; this is a sequencing recommendation. The
substrate work in Phase 3a is already merged on its own merits
(correctness and Phase 4/5 prerequisite); Phase 3b waits on
recognizer coverage.
## Cross-references
- ADR-0174 §Phase 3 acceptance — the criteria this brief documents
as unmet (with structural-cause analysis).
- `tests/test_adr_0174_phase3_lookback.py` — proves the substrate
works on synthetic problems even though no train_sample case
exercises it.
- `feedback-wrong-zero-hazard-case-0050` memory — verb expansion
must preserve the case-0050 canary; the recommended depletion-class
additions should be reviewed against this hazard before ratification.
- `thesis-decoding-not-generating` — the verb-class
contemplation/HITL path is the right "teach the engine to find
better" mechanism; widening the static whitelist directly would be
"storing another found thing."

View file

@ -0,0 +1,391 @@
"""ADR-0174 Phase 3 — lookback re-evaluation operator.
When a hypothesis carries unresolved slots (entries in
``Hypothesis.unresolved``), a later token can refine those slots
binding a pronoun to its proper-noun antecedent, attaching a unit to a
dangling quantity, narrowing an ambiguous verb category. This module
provides the ``reevaluate`` operator that applies a refinement to a
held hypothesis and re-runs the existing admissibility check.
Phase 3a (this module): the **substrate** for lookback. Concrete
refinement types:
- :class:`PronounResolution` bind a pronoun actor to a resolved
proper-noun referent. The ``matched_actor_token`` stays as the
pronoun (which IS in source, so grounding still passes); only the
semantic actor field on the underlying Operation /
InitialPossession is rewritten to the resolved name.
Phase 3b (follow-up): :class:`CompoundClauseExpansion` and other
refinement types covering the remaining compound-clause and verb-set
narrowness layers. The reevaluate operator handles any refinement
type implementing :class:`Refinement`; adding new types does not
require ADR amendments to this module (only to the closed list of
``VALID_REFINEMENT_KINDS`` so the trace consumer can branch
deterministically).
Trust boundary: this module never weakens an admissibility predicate.
Every refined hypothesis is re-run through
:func:`generate.comprehension.constraint_propagation.check_constraints`
after refinement; a hypothesis that would fail constraints in its
refined form is eliminated, returning ``None`` from ``reevaluate``.
The ``wrong = 0`` invariant is preserved by construction refinement
can only convert a refused hypothesis into an admitted one if every
admissibility sub-check passes on the refined candidate.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Final, Literal, Union
from generate.comprehension.constraint_propagation import (
ConstraintResult,
check_constraints,
)
from generate.comprehension.state import (
ComprehensionStateError,
Hypothesis,
)
# ---------------------------------------------------------------------------
# Closed set of refinement kinds (trace contract)
# ---------------------------------------------------------------------------
# Each kind name identifies one concrete refinement subclass below.
# Adding a new kind requires adding a subclass AND extending this set
# (so the reader_trace consumer can branch deterministically without
# pattern-matching on Python types).
VALID_REFINEMENT_KINDS: Final[frozenset[str]] = frozenset(
{
"pronoun_resolution",
# Phase 3b will add: "compound_clause_expansion", etc.
}
)
# Closed slot names that may appear in Hypothesis.unresolved tuples.
# Refinements bind to one of these slots; the reevaluate operator
# matches refinement→slot via this closed set.
VALID_UNRESOLVED_SLOTS: Final[frozenset[str]] = frozenset(
{
"actor_pronoun",
# Phase 3b will add: "clause_separator", etc.
}
)
# ---------------------------------------------------------------------------
# Refinement types — sealed union via Refinement Union alias
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class PronounResolution:
"""Refinement: bind an unresolved-actor pronoun to a proper-noun antecedent.
Phase 3a applied to a held :class:`Hypothesis` carrying
``"actor_pronoun"`` in its ``unresolved`` tuple. The refinement
leaves the underlying candidate's ``matched_actor_token`` intact
(the pronoun, which grounds in the held statement's source span),
and rewrites the candidate's semantic actor field
(``Operation.actor`` for :class:`CandidateOperation`,
``InitialPossession.entity`` for :class:`CandidateInitial`) to the
resolved name.
The ``pronoun`` field carries the surface form for trace fidelity
so the reader_trace event can record which pronoun was resolved at
which token position.
Fields:
kind: Literal "pronoun_resolution" (matches
VALID_REFINEMENT_KINDS membership; structural
discriminator for the Union below).
pronoun: Surface pronoun in the held statement
(e.g. ``"She"``, ``"He"``, ``"it"``).
resolved_to: Proper-noun referent
(e.g. ``"Jan"``, ``"Georgie"``).
evidence_source: Where the resolution came from a closed set
of values so the trace consumer can attribute
deterministically.
"""
pronoun: str
resolved_to: str
evidence_source: Literal["discourse_prior_subjects", "running_subject"]
kind: Literal["pronoun_resolution"] = "pronoun_resolution"
def __post_init__(self) -> None:
if not isinstance(self.pronoun, str) or not self.pronoun:
raise ComprehensionStateError(
"PronounResolution.pronoun must be non-empty str"
)
if not isinstance(self.resolved_to, str) or not self.resolved_to:
raise ComprehensionStateError(
"PronounResolution.resolved_to must be non-empty str"
)
if self.evidence_source not in (
"discourse_prior_subjects",
"running_subject",
):
raise ComprehensionStateError(
"PronounResolution.evidence_source must be in "
"{'discourse_prior_subjects', 'running_subject'}; "
f"got {self.evidence_source!r}"
)
if self.kind != "pronoun_resolution":
raise ComprehensionStateError(
"PronounResolution.kind must be 'pronoun_resolution'"
)
# Sealed union of all Phase-3 refinement types. Phase 3b will extend
# this with CompoundClauseExpansion etc.
Refinement = Union[PronounResolution]
# ---------------------------------------------------------------------------
# Internal: rebuild candidate with resolved actor
# ---------------------------------------------------------------------------
def _rebuild_candidate_with_resolved_actor(
candidate: object, resolved_to: str
) -> object | None:
"""Rebuild a candidate replacing the semantic-actor field.
For :class:`CandidateOperation`: rebuilds with a new
:class:`Operation` carrying ``actor=resolved_to`` and keeps every
other slot (operand, matched_verb, matched_value_token, etc.)
intact. ``matched_actor_token`` deliberately stays as the pronoun
so the grounding check (``_token_in(matched_actor_token, haystack)``)
continues to pass against the held statement's source span.
For :class:`CandidateInitial`: rebuilds with a new
:class:`InitialPossession` carrying ``entity=resolved_to`` and
preserves ``matched_entity_token`` as the pronoun.
Returns ``None`` when the candidate is not a known type the
caller treats this as a refinement-no-op (the hypothesis remains
held, lookback did not find anything to do here).
"""
# Lazy imports to avoid circular dependency on candidate-graph layer.
from generate.math_candidate_parser import CandidateInitial
from generate.math_problem_graph import (
InitialPossession,
Operation,
)
from generate.math_roundtrip import CandidateOperation
if isinstance(candidate, CandidateOperation):
old_op = candidate.op
new_op = Operation(
actor=resolved_to,
kind=old_op.kind,
operand=old_op.operand,
target=old_op.target,
)
return CandidateOperation(
op=new_op,
source_span=candidate.source_span,
matched_verb=candidate.matched_verb,
matched_value_token=candidate.matched_value_token,
matched_unit_token=candidate.matched_unit_token,
matched_actor_token=candidate.matched_actor_token,
matched_target_token=candidate.matched_target_token,
matched_reference_actor_token=candidate.matched_reference_actor_token,
)
if isinstance(candidate, CandidateInitial):
old_initial = candidate.initial
new_initial = InitialPossession(
entity=resolved_to,
quantity=old_initial.quantity,
)
return CandidateInitial(
initial=new_initial,
source_span=candidate.source_span,
matched_anchor=candidate.matched_anchor,
matched_value_token=candidate.matched_value_token,
matched_unit_token=candidate.matched_unit_token,
matched_entity_token=candidate.matched_entity_token,
composition_evidence=candidate.composition_evidence,
)
return None
# ---------------------------------------------------------------------------
# reevaluate operator
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class ReevaluateResult:
"""Outcome of a single reevaluate call.
Fields:
refined: The refined Hypothesis if admitted, else None.
previous: The original hypothesis before refinement
(carried so trace events can record what changed).
refinement_kind: The refinement kind that was attempted
(matches Refinement.kind on the input).
constraint_result: The result of re-running check_constraints
on the refined candidate (or None if refinement
could not be applied because the candidate
type was unknown).
elimination_reason: Non-None iff refined is None.
"""
refined: Hypothesis | None
previous: Hypothesis
refinement_kind: str
constraint_result: ConstraintResult | None
elimination_reason: str | None
def __post_init__(self) -> None:
if self.refinement_kind not in VALID_REFINEMENT_KINDS:
raise ComprehensionStateError(
"ReevaluateResult.refinement_kind must be in "
f"VALID_REFINEMENT_KINDS; got {self.refinement_kind!r}"
)
if self.refined is None and self.elimination_reason is None:
raise ComprehensionStateError(
"ReevaluateResult.refined=None requires a non-None "
"elimination_reason"
)
if self.refined is not None and self.elimination_reason is not None:
raise ComprehensionStateError(
"ReevaluateResult.refined is not None but "
f"elimination_reason={self.elimination_reason!r} is set; "
"these are inconsistent"
)
def reevaluate(
hypothesis: Hypothesis, refinement: Refinement
) -> ReevaluateResult:
"""Apply ``refinement`` to ``hypothesis``, re-run constraint check.
Per ADR-0174 §Decision §Lookback: lookback walks open hypotheses
and recomputes prior assignments when a later token resolves an
earlier ambiguity. This operator is the per-hypothesis primitive
that pass invokes.
Semantics:
- If the refinement targets a slot that isn't in
``hypothesis.unresolved``, the refinement is a no-op: the
function returns ``ReevaluateResult(refined=hypothesis, )`` so
the caller can keep the hypothesis unchanged. This matches
the ADR's "uncontested tokens contribute no recomputation
work" bound.
- If the refinement applies but the rebuilt candidate fails the
re-run constraint check, ``refined`` is ``None`` and
``elimination_reason`` carries the first failing predicate's
reason.
- If the refinement applies and constraints pass, ``refined`` is
a new :class:`Hypothesis` with the resolved slot removed from
``unresolved`` and a ``category_assignments`` entry recording
the refinement event.
The returned :class:`ReevaluateResult` always includes the previous
hypothesis so trace serialisation can record the before/after pair.
"""
# Dispatch on refinement kind. Phase 3a knows pronoun_resolution.
# The defensive raise below is unreachable today (the Refinement
# Union has one member), but it is correct future-proofing for
# Phase 3b's CompoundClauseExpansion and other refinement types —
# if a caller passes a non-Union type by accident, fail loudly.
if isinstance(refinement, PronounResolution):
return _apply_pronoun_resolution(hypothesis, refinement)
raise ComprehensionStateError( # type: ignore[unreachable]
f"reevaluate: unsupported refinement type {type(refinement).__name__}"
)
def _apply_pronoun_resolution(
hypothesis: Hypothesis, refinement: PronounResolution
) -> ReevaluateResult:
"""Inner: rebuild candidate with resolved actor, re-run constraints."""
if "actor_pronoun" not in hypothesis.unresolved:
# No-op: this hypothesis doesn't carry the slot the refinement
# targets. Return unchanged.
return ReevaluateResult(
refined=hypothesis,
previous=hypothesis,
refinement_kind=refinement.kind,
constraint_result=None,
elimination_reason=None,
)
rebuilt = _rebuild_candidate_with_resolved_actor(
hypothesis.candidate, refinement.resolved_to
)
if rebuilt is None:
# Candidate type unknown — refinement cannot apply. Return the
# hypothesis unchanged; caller can decide whether to eliminate
# on its own terms.
return ReevaluateResult(
refined=hypothesis,
previous=hypothesis,
refinement_kind=refinement.kind,
constraint_result=None,
elimination_reason=None,
)
# Build the refined hypothesis: same rank, same confidence_rank,
# category_assignments extended with a refinement trace entry,
# unresolved minus the now-resolved slot.
#
# The trace entry uses token_index=0 because Phase 3a applies
# refinements at the problem level (after all sentences have been
# processed), not at a specific token position. Phase 3b/4 may
# specialise this when refinements fire mid-token-stream.
refined_assignments = hypothesis.category_assignments + (
(0, "pronoun_resolved", refinement.pronoun),
)
refined_unresolved = tuple(
slot for slot in hypothesis.unresolved if slot != "actor_pronoun"
)
refined_hyp = Hypothesis(
candidate=rebuilt,
category_assignments=refined_assignments,
constraint_state=hypothesis.constraint_state,
confidence_rank=hypothesis.confidence_rank,
unresolved=refined_unresolved,
)
# Re-run constraints. Refinement preserves wrong=0 by gating on the
# same admissibility predicates that admit candidates today; a
# refinement that produces a candidate failing any sub-check is
# eliminated.
result = check_constraints(refined_hyp)
if result.admitted:
return ReevaluateResult(
refined=refined_hyp,
previous=hypothesis,
refinement_kind=refinement.kind,
constraint_result=result,
elimination_reason=None,
)
return ReevaluateResult(
refined=None,
previous=hypothesis,
refinement_kind=refinement.kind,
constraint_result=result,
elimination_reason=(
f"pronoun_resolution refined candidate failed re-check: "
f"{result.elimination_reason}"
),
)
__all__ = [
"PronounResolution",
"Refinement",
"ReevaluateResult",
"VALID_REFINEMENT_KINDS",
"VALID_UNRESOLVED_SLOTS",
"reevaluate",
]

View file

@ -35,6 +35,7 @@ from __future__ import annotations
import json
import re
from collections.abc import Mapping
from dataclasses import dataclass
from itertools import product
from typing import TYPE_CHECKING, Final, Union
@ -833,6 +834,144 @@ def parse_and_solve(
inject_from_match,
)
injected = inject_from_match(recognizer_match, s)
# ADR-0174 Phase 3 — lookback pronoun resolution.
# When the matcher tagged any anchor with
# ``requires_pronoun_resolution``, the injected
# candidates carry the pronoun as actor/entity and
# are held until lookback either binds them to a
# discourse antecedent or drops them. The
# discourse map (_discourse_prior_subjects /
# _prior_subject) is consulted in the same
# precedence as ME-2 cross-sentence binding so
# behaviour is consistent across recognizer
# categories. When no antecedent is available,
# we drop the candidates (refusal-preferring;
# preserves wrong=0).
if injected and any(
isinstance(a, Mapping)
and a.get("requires_pronoun_resolution")
for a in recognizer_match.parsed_anchors
):
from generate.comprehension.lookback import (
PronounResolution,
reevaluate,
)
from generate.comprehension.constraint_propagation import (
hypothesis_from_initial as _hyp_from_initial,
hypothesis_from_operation as _hyp_from_operation,
)
# Extract the held pronoun (the matcher
# guarantees a single subject_role across the
# anchor set for discrete_count_statement v1).
_held_pronoun: str | None = None
for a in recognizer_match.parsed_anchors:
if (
isinstance(a, Mapping)
and a.get("requires_pronoun_resolution")
):
_sr = a.get("subject_role")
if isinstance(_sr, str):
_held_pronoun = _sr
break
_antecedent = _effective_prior
# ADR-0174 Phase 3a — multi-actor pronoun
# ambiguity defense. When the problem has
# more than one distinct proper-noun subject
# in prior context, picking the most-recent
# is a guess (e.g. "Alice has 5. Bob has 3.
# She buys 2." — "She" should bind to Alice
# by gender but the discourse map returns
# Bob). No safety net downstream would catch
# a wrong attribution (single-binding
# emission → no multi-branch disagreement;
# verifier re-derives the same wrong graph).
# Refuse rather than guess. Surfaced by
# 2026-05-28 Phase 1-3a lookback review;
# see project-adr-0174-multi-actor-pronoun-hazard
# memory and CLAUDE.md §Lookback Review
# Discipline.
_distinct_priors = {
v for v in _discourse_prior_subjects.values()
if v is not None
}
if _prior_subject is not None:
_distinct_priors.add(_prior_subject)
_multi_actor_ambiguous = len(_distinct_priors) > 1
if _held_pronoun is None or not _antecedent:
# No resolution path available — drop the
# held candidates and log the lookback
# event so the trace records why.
_statement_trace.append(json.dumps({
"layer": "lookback",
"phase": 3,
"outcome": "no_antecedent",
"pronoun": _held_pronoun or "<missing>",
"sentence_index": s_idx,
}, sort_keys=True))
injected = ()
elif _multi_actor_ambiguous:
# Refusal-preferring discipline: multiple
# distinct proper-noun subjects in prior
# context means the resolver would be
# guessing. Drop the held candidates,
# log the ambiguous-antecedent event.
_statement_trace.append(json.dumps({
"layer": "lookback",
"phase": 3,
"outcome": "no_antecedent_ambiguous",
"pronoun": _held_pronoun,
"candidate_antecedents": sorted(_distinct_priors),
"sentence_index": s_idx,
}, sort_keys=True))
injected = ()
else:
_refinement = PronounResolution(
pronoun=_held_pronoun,
resolved_to=_antecedent,
evidence_source=(
"discourse_prior_subjects"
if s in _discourse_prior_subjects
else "running_subject"
),
)
_resolved: list[object] = []
_all_resolved = True
for _rank, _c in enumerate(injected):
if isinstance(_c, CandidateInitial):
_base = _hyp_from_initial(_c, _rank)
elif isinstance(_c, CandidateOperation):
_base = _hyp_from_operation(_c, _rank)
else:
_all_resolved = False
break
_held = Hypothesis(
candidate=_base.candidate,
category_assignments=_base.category_assignments,
constraint_state=_base.constraint_state,
confidence_rank=_base.confidence_rank,
unresolved=("actor_pronoun",),
)
_result = reevaluate(_held, _refinement)
_statement_trace.append(json.dumps({
"layer": "lookback",
"phase": 3,
"outcome": "admitted" if _result.refined else "eliminated",
"pronoun": _held_pronoun,
"resolved_to": _antecedent,
"confidence_rank": _rank,
"evidence_source": _refinement.evidence_source,
"sentence_index": s_idx,
}, sort_keys=True))
if _result.refined is None:
_all_resolved = False
break
_resolved.append(_result.refined.candidate) # type: ignore[arg-type]
if _all_resolved and _resolved:
injected = tuple(_resolved)
else:
injected = ()
if injected:
# ADR-0174 Phase 2 — hypothesis-based admission
# with structured elimination tracing. Each
@ -912,11 +1051,17 @@ def parse_and_solve(
f"(category={recognizer_match.category.value})"
),
branches_enumerated=0, branches_admissible=0,
# ADR-0174 Phase 3a — preserve statement-stage
# trace events on early refusal so consumers see
# WHY admission failed (lookback no_antecedent,
# constraint_propagation eliminations, etc.).
reader_trace=tuple(_statement_trace),
)
return CandidateGraphResult(
answer=None, selected_graph=None,
refusal_reason=f"no admissible candidate for statement: {s!r}",
branches_enumerated=0, branches_admissible=0,
reader_trace=tuple(_statement_trace),
)
per_sentence_choices.append(_collapse_per_sentence_ties(choices))
# ME-2 — update prior_subject AFTER this sentence is processed.

View file

@ -944,8 +944,15 @@ def _try_extract_discrete_count_anchor(
return None
subject = m.group("subject")
if subject.lower() in _REFUSED_SUBJECT_TOKENS:
return None
# ADR-0174 Phase 3 — pronoun-subject statements are no longer
# rejected outright. Instead they emit a HELD anchor (with the
# pronoun in subject_role and ``requires_pronoun_resolution=True``)
# so the downstream candidate-graph layer can stash them in
# ``ProblemReadingState.open_hypotheses`` and run the lookback
# reevaluate pass against the discourse subject map. When no
# antecedent resolves, the held hypothesis is dropped (refusal-
# preferring discipline preserves wrong=0).
requires_pronoun_resolution = subject.lower() in _REFUSED_SUBJECT_TOKENS
verb = m.group("verb").lower()
if verb in _POSSESSION_VERBS:
@ -988,7 +995,7 @@ def _try_extract_discrete_count_anchor(
canon = observed_n
break
return {
anchor: dict[str, Any] = {
"kind": "discrete_count",
"subject_role": subject,
"count_token": count_token,
@ -1000,6 +1007,12 @@ def _try_extract_discrete_count_anchor(
"anchor_kind": anchor_kind,
"verb_token": verb,
}
if requires_pronoun_resolution:
# ADR-0174 Phase 3 marker — the downstream injector reads this
# and emits a held CandidateOperation/CandidateInitial whose
# Hypothesis carries unresolved=("actor_pronoun",).
anchor["requires_pronoun_resolution"] = True
return anchor
def _match_multiplicative_aggregation(

View file

@ -248,6 +248,188 @@ class TestCheckConstraintsOperationParity:
assert roundtrip_admissible(op) is False
class TestCheckConstraintsInitialPredicateNames:
"""Predicate-name assertions for every initial.* failure path.
Surfaced by 2026-05-28 lookback review 13 of 17 predicates in
VALID_PREDICATE_NAMES lacked direct elimination-reason assertions."""
def test_initial_value_grounds_predicate_name(self) -> None:
ic = _initial(matched_value_token="99") # source has "3"
result = check_constraints(hypothesis_from_initial(ic, 0))
assert result.admitted is False
first_fail = next(
(p for p, o in result.predicates_run if o == "fail"), None
)
assert first_fail == "initial.value_grounds"
def test_initial_unit_grounds_predicate_name(self) -> None:
ic = _initial(matched_unit_token="oranges") # source has "apples"
result = check_constraints(hypothesis_from_initial(ic, 0))
assert result.admitted is False
first_fail = next(
(p for p, o in result.predicates_run if o == "fail"), None
)
assert first_fail == "initial.unit_grounds"
def test_initial_entity_grounds_predicate_name(self) -> None:
ic = _initial(matched_entity_token="Tom") # source has "Sam"
result = check_constraints(hypothesis_from_initial(ic, 0))
assert result.admitted is False
first_fail = next(
(p for p, o in result.predicates_run if o == "fail"), None
)
assert first_fail == "initial.entity_grounds"
class TestCheckConstraintsOperationPredicateNames:
"""Predicate-name assertions for every operation.* failure path."""
def test_operation_verb_grounds_predicate_name(self) -> None:
# Verb registered for kind but not in source span — distinct
# from verb_registered failure.
op = CandidateOperation(
op=Operation(actor="Sam", kind="add",
operand=Quantity(value=5, unit="apples")),
source_span="Sam now owns 5 apples.", # 'buys' not in source
matched_verb="buys",
matched_value_token="5",
matched_unit_token="apples",
matched_actor_token="Sam",
)
result = check_constraints(hypothesis_from_operation(op, 0))
assert result.admitted is False
first_fail = next(
(p for p, o in result.predicates_run if o == "fail"), None
)
assert first_fail == "operation.verb_grounds"
def test_operation_value_grounds_predicate_name(self) -> None:
op = _operation_add(
matched_value_token="99", # source has "5"
source_span="Sam buys 5 apples.",
)
result = check_constraints(hypothesis_from_operation(op, 0))
assert result.admitted is False
first_fail = next(
(p for p, o in result.predicates_run if o == "fail"), None
)
assert first_fail == "operation.value_grounds"
def test_operation_unit_grounds_predicate_name(self) -> None:
op = _operation_add(
matched_unit_token="oranges", # source has "apples"
source_span="Sam buys 5 apples.",
)
result = check_constraints(hypothesis_from_operation(op, 0))
assert result.admitted is False
first_fail = next(
(p for p, o in result.predicates_run if o == "fail"), None
)
assert first_fail == "operation.unit_grounds"
class TestCheckConstraintsComposedInitialPath:
"""The RAT-1 composed_initial path was completely untested before
2026-05-28 lookback review parity was manually verified but no
automated test asserted the 4 sub-checks fire correctly."""
def _composed(self, **ev_overrides: str) -> CandidateInitial:
from generate.math_problem_graph import InitialPossession, Quantity
ev: dict[str, str] = {
"composition_shape": "bound(count) × bound(unit_cost)",
"input_tokens": "3|400",
"entity_source": "prior_sentence",
"currency_symbol": "$",
}
ev.update(ev_overrides)
return CandidateInitial(
initial=InitialPossession(
entity="John", quantity=Quantity(value=1200, unit="dollars"),
),
source_span="3 vet appointments at $400 each",
matched_anchor="has",
matched_value_token="1200",
matched_unit_token="dollars",
matched_entity_token="John",
composition_evidence=ev,
)
def test_well_formed_composed_initial_admits(self) -> None:
ic = self._composed()
result = check_constraints(hypothesis_from_initial(ic, 0))
assert result.admitted is True
# All 4 sub-checks run (some may skip).
predicate_names = {p for p, _ in result.predicates_run}
assert "composed_initial.evidence_complete" in predicate_names
assert "composed_initial.input_tokens_ground" in predicate_names
assert "composed_initial.entity_token_present" in predicate_names
def test_composed_initial_missing_evidence_key_eliminated(self) -> None:
# Construct via dict mutation since the field is a Mapping
ev_partial = {
"composition_shape": "shape",
"input_tokens": "3|400",
# entity_source missing
}
ic = self._composed()
# Replace composition_evidence via dataclasses.replace would
# break frozen; build a new candidate directly.
from generate.math_problem_graph import InitialPossession, Quantity
ic2 = CandidateInitial(
initial=InitialPossession(
entity="John", quantity=Quantity(value=1200, unit="dollars"),
),
source_span=ic.source_span,
matched_anchor=ic.matched_anchor,
matched_value_token=ic.matched_value_token,
matched_unit_token=ic.matched_unit_token,
matched_entity_token=ic.matched_entity_token,
composition_evidence=ev_partial,
)
result = check_constraints(hypothesis_from_initial(ic2, 0))
assert result.admitted is False
first_fail = next(
(p for p, o in result.predicates_run if o == "fail"), None
)
assert first_fail == "composed_initial.evidence_complete"
def test_composed_initial_input_token_missing_eliminated(self) -> None:
ic = self._composed(input_tokens="999|400") # 999 not in source
result = check_constraints(hypothesis_from_initial(ic, 0))
assert result.admitted is False
first_fail = next(
(p for p, o in result.predicates_run if o == "fail"), None
)
assert first_fail == "composed_initial.input_tokens_ground"
def test_composed_initial_currency_symbol_missing_eliminated(self) -> None:
# Build a candidate whose source span has no $ but evidence
# claims currency_symbol="$".
from generate.math_problem_graph import InitialPossession, Quantity
ic = CandidateInitial(
initial=InitialPossession(
entity="John", quantity=Quantity(value=1200, unit="dollars"),
),
source_span="3 vet appointments at 400 each", # no $
matched_anchor="has",
matched_value_token="1200",
matched_unit_token="dollars",
matched_entity_token="John",
composition_evidence={
"composition_shape": "shape",
"input_tokens": "3|400",
"entity_source": "prior_sentence",
"currency_symbol": "$",
},
)
result = check_constraints(hypothesis_from_initial(ic, 0))
assert result.admitted is False
first_fail = next(
(p for p, o in result.predicates_run if o == "fail"), None
)
assert first_fail == "composed_initial.currency_symbol_present"
class TestCheckConstraintsResultShape:
def test_predicates_run_only_uses_known_predicate_names(self) -> None:
ic = _initial()

View file

@ -0,0 +1,469 @@
"""ADR-0174 Phase 3a — lookback re-evaluation operator + pronoun resolution.
Acceptance tests:
1. ``reevaluate`` operator semantics: no-op when refinement doesn't
apply, refined hypothesis when constraints pass, None when
constraints fail post-refinement.
2. ``PronounResolution`` dataclass invariants validates pronoun /
resolved_to / evidence_source / kind shape.
3. End-to-end wiring: a synthetic problem with pronoun-subject
statement that the regex parser refuses fires the lookback path
and emits an "admitted" trace event when the discourse antecedent
resolves.
4. Refusal-preferring discipline: a held statement with no discourse
antecedent emits a "no_antecedent" trace event and drops cleanly.
5. wrong=0 preserved on train_sample/v1 (score unchanged at 3/47/0).
Phase 3a substrate scope: this PR builds the reevaluate operator and
wires pronoun resolution into the recognizer-injection branch of
math_candidate_graph.parse_and_solve. The wiring is correct but does
not fire on any of the 50 train_sample cases because the cases the
ADR identified (21 empty-anchor discrete_count failures) refuse for
verb-set narrowness reasons (recognizer scope, ADR-0163.x) BEFORE
reaching the pronoun layer. This file therefore exercises the wiring
via synthetic problems that target the path directly.
See docs/handoff/PHASE-3.1-FOLLOWUP-RECOGNIZER-EXPANSION.md for the
follow-up brief documenting which recognizer expansions would surface
real cases that exercise this path on the train_sample corpus.
"""
from __future__ import annotations
import json
import pytest
from generate.comprehension.constraint_propagation import (
hypothesis_from_initial,
hypothesis_from_operation,
)
from generate.comprehension.lookback import (
PronounResolution,
ReevaluateResult,
VALID_REFINEMENT_KINDS,
VALID_UNRESOLVED_SLOTS,
reevaluate,
)
from generate.comprehension.state import (
ComprehensionStateError,
Hypothesis,
)
from generate.math_candidate_parser import CandidateInitial
from generate.math_problem_graph import (
InitialPossession,
Operation,
Quantity,
)
from generate.math_roundtrip import CandidateOperation
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _operation_with_pronoun_actor(
pronoun: str = "He",
source_span: str = "He buys 3 apples.",
) -> CandidateOperation:
return CandidateOperation(
op=Operation(
actor=pronoun, kind="add",
operand=Quantity(value=3, unit="apples"),
),
source_span=source_span,
matched_verb="buys",
matched_value_token="3",
matched_unit_token="apples",
matched_actor_token=pronoun,
)
def _initial_with_pronoun_actor(
pronoun: str = "She",
source_span: str = "She has 5 books.",
) -> CandidateInitial:
return CandidateInitial(
initial=InitialPossession(
entity=pronoun,
quantity=Quantity(value=5, unit="books"),
),
source_span=source_span,
matched_anchor="has",
matched_value_token="5",
matched_unit_token="books",
matched_entity_token=pronoun,
)
def _held_hypothesis(candidate: object, rank: int = 0) -> Hypothesis:
"""Construct a Hypothesis with unresolved=('actor_pronoun',) as the
Phase 3 wiring would produce."""
if isinstance(candidate, CandidateOperation):
base = hypothesis_from_operation(candidate, rank)
elif isinstance(candidate, CandidateInitial):
base = hypothesis_from_initial(candidate, rank)
else:
raise ValueError(f"unknown candidate type {type(candidate).__name__}")
return Hypothesis(
candidate=base.candidate,
category_assignments=base.category_assignments,
constraint_state=base.constraint_state,
confidence_rank=base.confidence_rank,
unresolved=("actor_pronoun",),
)
# ---------------------------------------------------------------------------
# 1. reevaluate operator semantics
# ---------------------------------------------------------------------------
class TestReevaluateOnOperation:
def test_pronoun_resolution_succeeds_when_constraints_pass(self) -> None:
cand = _operation_with_pronoun_actor(pronoun="He")
hyp = _held_hypothesis(cand, rank=0)
ref = PronounResolution(
pronoun="He", resolved_to="Bob",
evidence_source="discourse_prior_subjects",
)
result = reevaluate(hyp, ref)
assert result.refined is not None
assert result.elimination_reason is None
# The Operation.actor is rewritten to the resolved name.
assert result.refined.candidate.op.actor == "Bob" # type: ignore[attr-defined]
# matched_actor_token STAYS as the pronoun so grounding still
# passes against the held statement's source span.
assert result.refined.candidate.matched_actor_token == "He" # type: ignore[attr-defined]
# The 'actor_pronoun' slot is no longer unresolved.
assert "actor_pronoun" not in result.refined.unresolved
# category_assignments records the refinement event for trace.
assert any(
tup[1] == "pronoun_resolved"
for tup in result.refined.category_assignments
)
def test_noop_when_hypothesis_has_no_unresolved_pronoun(self) -> None:
cand = _operation_with_pronoun_actor()
hyp = hypothesis_from_operation(cand, 0) # unresolved=() by default
ref = PronounResolution(
pronoun="He", resolved_to="Bob",
evidence_source="discourse_prior_subjects",
)
result = reevaluate(hyp, ref)
# No-op: refined == original hypothesis, no elimination.
assert result.refined is hyp
assert result.elimination_reason is None
assert result.constraint_result is None
def test_eliminated_when_refined_candidate_fails_constraints(self) -> None:
# Construct a candidate whose source_span doesn't contain the
# pronoun — the rebuilt candidate will fail matched_actor_token
# grounding because the pronoun isn't in haystack. This is a
# degenerate case but proves the constraint re-check actually
# fires.
cand = CandidateOperation(
op=Operation(
actor="He", kind="add",
operand=Quantity(value=3, unit="apples"),
),
source_span="Sam buys 3 apples.", # 'He' not in source
matched_verb="buys",
matched_value_token="3",
matched_unit_token="apples",
matched_actor_token="He", # would fail _token_in even before refinement
)
hyp = _held_hypothesis(cand, 0)
ref = PronounResolution(
pronoun="He", resolved_to="Bob",
evidence_source="discourse_prior_subjects",
)
result = reevaluate(hyp, ref)
assert result.refined is None
assert result.elimination_reason is not None
# The first failing predicate is operation.actor_grounds —
# observable via constraint_result.predicates_run.
assert result.constraint_result is not None
first_fail = next(
(p for p, o in result.constraint_result.predicates_run if o == "fail"),
None,
)
assert first_fail == "operation.actor_grounds"
class TestReevaluateOnInitial:
def test_pronoun_resolution_rewrites_initial_entity(self) -> None:
cand = _initial_with_pronoun_actor(pronoun="She")
hyp = _held_hypothesis(cand, rank=0)
ref = PronounResolution(
pronoun="She", resolved_to="Jan",
evidence_source="discourse_prior_subjects",
)
result = reevaluate(hyp, ref)
assert result.refined is not None
assert result.refined.candidate.initial.entity == "Jan" # type: ignore[attr-defined]
assert result.refined.candidate.matched_entity_token == "She" # type: ignore[attr-defined]
class TestReevaluateResultDataclass:
def test_invalid_refinement_kind_refused(self) -> None:
cand = _operation_with_pronoun_actor()
hyp = _held_hypothesis(cand, 0)
with pytest.raises(
ComprehensionStateError, match="refinement_kind must be in"
):
ReevaluateResult(
refined=hyp, previous=hyp,
refinement_kind="not_a_kind",
constraint_result=None,
elimination_reason=None,
)
def test_inconsistent_refined_and_elimination_refused(self) -> None:
cand = _operation_with_pronoun_actor()
hyp = _held_hypothesis(cand, 0)
with pytest.raises(
ComprehensionStateError, match="inconsistent"
):
ReevaluateResult(
refined=hyp, previous=hyp,
refinement_kind="pronoun_resolution",
constraint_result=None,
elimination_reason="impossible combo",
)
def test_none_refined_requires_elimination_reason(self) -> None:
cand = _operation_with_pronoun_actor()
hyp = _held_hypothesis(cand, 0)
with pytest.raises(
ComprehensionStateError, match="requires a non-None"
):
ReevaluateResult(
refined=None, previous=hyp,
refinement_kind="pronoun_resolution",
constraint_result=None,
elimination_reason=None,
)
# ---------------------------------------------------------------------------
# 2. PronounResolution dataclass invariants
# ---------------------------------------------------------------------------
class TestPronounResolutionConstruction:
def test_minimal_valid(self) -> None:
r = PronounResolution(
pronoun="He", resolved_to="Bob",
evidence_source="discourse_prior_subjects",
)
assert r.kind == "pronoun_resolution"
assert r.pronoun == "He"
def test_empty_pronoun_refused(self) -> None:
with pytest.raises(ComprehensionStateError, match="pronoun"):
PronounResolution(
pronoun="", resolved_to="Bob",
evidence_source="discourse_prior_subjects",
)
def test_empty_resolved_to_refused(self) -> None:
with pytest.raises(ComprehensionStateError, match="resolved_to"):
PronounResolution(
pronoun="He", resolved_to="",
evidence_source="discourse_prior_subjects",
)
def test_invalid_evidence_source_refused(self) -> None:
with pytest.raises(ComprehensionStateError, match="evidence_source"):
PronounResolution(
pronoun="He", resolved_to="Bob",
evidence_source="grok_intuition", # type: ignore[arg-type]
)
def test_kind_must_be_pronoun_resolution(self) -> None:
with pytest.raises(ComprehensionStateError, match="kind"):
PronounResolution(
pronoun="He", resolved_to="Bob",
evidence_source="discourse_prior_subjects",
kind="wrong_kind", # type: ignore[arg-type]
)
# ---------------------------------------------------------------------------
# 3. Closed-set constants
# ---------------------------------------------------------------------------
class TestADR0174Phase3Constants:
def test_pronoun_resolution_in_valid_kinds(self) -> None:
assert "pronoun_resolution" in VALID_REFINEMENT_KINDS
def test_actor_pronoun_in_valid_unresolved_slots(self) -> None:
assert "actor_pronoun" in VALID_UNRESOLVED_SLOTS
# ---------------------------------------------------------------------------
# 4. End-to-end integration via parse_and_solve
# ---------------------------------------------------------------------------
class TestPhase3WiringEndToEnd:
"""Synthetic problems that exercise the Phase 3 wiring in
math_candidate_graph. These do NOT correspond to any train_sample
case (the train_sample cases refuse for other narrowness reasons
before reaching the pronoun-resolution path see PHASE-3.1
follow-up brief)."""
def test_resolved_pronoun_emits_admitted_trace_event(self) -> None:
from generate.math_candidate_graph import parse_and_solve
# 'He collected N Pokemon cards' — regex returns 0 choices
# (multi-word unit + acquisition verb), recognizer matches
# discrete_count_statement and the pronoun marker fires.
# 'Bob' is the discourse antecedent.
text = (
"Bob has 10 Pokemon cards. "
"He collected 5 Pokemon cards. "
"How many Pokemon cards does Bob have?"
)
r = parse_and_solve(text)
# The downstream question regex doesn't admit this exact
# phrasing — but our wiring should fire and produce a lookback
# admitted event on the second sentence.
lookback_events = [
json.loads(ev) for ev in r.reader_trace
if json.loads(ev).get("layer") == "lookback"
]
assert any(
ev.get("outcome") == "admitted"
and ev.get("pronoun") == "He"
and ev.get("resolved_to") == "Bob"
for ev in lookback_events
), f"expected lookback admitted event; trace={lookback_events}"
def test_multi_actor_ambiguous_refuses_with_no_antecedent_ambiguous(self) -> None:
"""ADR-0174 Phase 3a wrong=0 hazard defense — surfaced by
2026-05-28 lookback review.
When a problem has more than one distinct proper-noun subject
in prior context, the _discourse_prior_subjects lookup is
gender-blind and would silently pick the most-recent-prior as
the antecedent. In 'Alice has 5. Bob has 3. She buys 2.',
this would resolve 'She' to 'Bob' and attribute Alice's
purchase to Bob wrong attribution with no downstream safety
net.
Defense: refuse with no_antecedent_ambiguous trace event when
multiple distinct proper-noun subjects appear in prior
context. Refusal-preferring discipline preserves wrong=0.
"""
from generate.math_candidate_graph import parse_and_solve
text = (
"Alice has 5 Pokemon cards. "
"Bob has 3 Pokemon cards. "
"She collected 2 Pokemon cards. "
"How many Pokemon cards does Bob have?"
)
r = parse_and_solve(text)
# MUST refuse — wrong attribution is the hazard.
assert r.answer is None
lookback_events = [
json.loads(ev) for ev in r.reader_trace
if json.loads(ev).get("layer") == "lookback"
]
ambig = [
ev for ev in lookback_events
if ev.get("outcome") == "no_antecedent_ambiguous"
]
assert ambig, (
f"expected no_antecedent_ambiguous event; trace={lookback_events}"
)
ev = ambig[0]
assert "Alice" in ev["candidate_antecedents"]
assert "Bob" in ev["candidate_antecedents"]
assert ev["pronoun"] == "She"
def test_single_actor_pronoun_still_resolves(self) -> None:
"""Counter-test: when there's only ONE distinct prior subject,
the defense MUST NOT fire pronoun resolution proceeds."""
from generate.math_candidate_graph import parse_and_solve
text = (
"Bob has 10 Pokemon cards. "
"He collected 5 Pokemon cards. "
"How many Pokemon cards does Bob have?"
)
r = parse_and_solve(text)
lookback_events = [
json.loads(ev) for ev in r.reader_trace
if json.loads(ev).get("layer") == "lookback"
]
assert not any(
ev.get("outcome") == "no_antecedent_ambiguous"
for ev in lookback_events
), (
f"single-actor case must not trigger ambiguity defense; "
f"trace={lookback_events}"
)
assert any(
ev.get("outcome") == "admitted" and ev.get("resolved_to") == "Bob"
for ev in lookback_events
)
def test_no_antecedent_emits_no_antecedent_trace_event(self) -> None:
from generate.math_candidate_graph import parse_and_solve
# No proper-noun antecedent before the held pronoun sentence.
text = (
"He collected 5 Pokemon cards. "
"How many Pokemon cards does he have?"
)
r = parse_and_solve(text)
lookback_events = [
json.loads(ev) for ev in r.reader_trace
if json.loads(ev).get("layer") == "lookback"
]
assert r.refusal_reason is not None or r.answer is None
for ev in lookback_events:
assert ev.get("outcome") in (
"no_antecedent", "no_antecedent_ambiguous", "eliminated"
), f"unexpected lookback outcome on no-antecedent input: {ev}"
# ---------------------------------------------------------------------------
# 5. wrong=0 preservation on train_sample/v1
# ---------------------------------------------------------------------------
class TestWrongZeroPreservation:
def test_train_sample_score_unchanged(self) -> None:
"""Phase 3 substrate must not move the train_sample score from
3/47/0. Any change would indicate the lookback path is firing
on cases it shouldn't (or breaking cases it shouldn't)."""
import json
from pathlib import Path
from evals.gsm8k_math.train_sample.v1.runner import (
build_report, _CASES_PATH,
)
cases = [
json.loads(line)
for line in Path(_CASES_PATH).open(encoding="utf-8")
if line.strip()
]
report = build_report(cases, use_reader=True)
counts = report["counts"]
assert counts["wrong"] == 0, (
f"wrong=0 invariant violated: {counts}"
)
assert counts["correct"] == 3, (
f"correct count moved from 3 to {counts['correct']}; "
"Phase 3a substrate should not lift score on this corpus "
"(see PHASE-3.1 follow-up brief for what would lift it)"
)
assert counts["refused"] == 47, (
f"refused count moved from 47 to {counts['refused']}"
)