feat(adr-0174-phase3a): lookback re-evaluation operator + pronoun resolution substrate
ADR-0174 Phase 3a — substrate for held-hypothesis lookback.
Score unchanged at 3/47/0 (this PR is correctly-engineered
infrastructure; eval impact gated on ADR-0163.x recognizer expansion
documented in the follow-up brief).
Adds generate/comprehension/lookback.py:
- VALID_REFINEMENT_KINDS, VALID_UNRESOLVED_SLOTS — closed sets
contracted with reader_trace consumer
- PronounResolution refinement dataclass (pronoun + resolved_to +
evidence_source, all validated)
- Refinement Union (Phase 3b will widen with CompoundClauseExpansion)
- ReevaluateResult dataclass with admit/eliminate consistency
- reevaluate(hypothesis, refinement) operator — applies refinement,
re-runs check_constraints, returns refined Hypothesis or None.
- _rebuild_candidate_with_resolved_actor — rebuilds
CandidateOperation / CandidateInitial replacing the semantic actor
field (op.actor / initial.entity) while preserving matched_actor_token
/ matched_entity_token as the pronoun (so grounding still passes
against the held statement's source span).
Modifies generate/recognizer_match.py:
- _try_extract_discrete_count_anchor: pronoun-subject statements now
emit anchors with subject_role=<pronoun> + requires_pronoun_resolution
marker, rather than refusing at the _REFUSED_SUBJECT_TOKENS check.
The other narrowness layers (clause split, verb whitelist) still
refuse; only the pronoun layer changes.
Modifies generate/math_candidate_graph.py:
- After inject_from_match, when any parsed_anchor carries
requires_pronoun_resolution, the candidates are held as Hypothesis
objects with unresolved=('actor_pronoun',). The lookback path then
resolves via the existing _discourse_prior_subjects map and runs
PronounResolution refinements through reevaluate. Resolved
hypotheses flow into per_sentence_choices as if the regex parser
had produced them; unresolved hypotheses drop cleanly (refusal-
preferring). Emits 'lookback' JSON trace events with
outcome ∈ {admitted, eliminated, no_antecedent}.
Tests:
- tests/test_adr_0174_phase3_lookback.py — 17 acceptance tests
covering operator semantics on Operation/Initial, dataclass
invariants, closed-set constants, end-to-end wiring on synthetic
problems, and wrong=0 preservation on train_sample.
Phase 3.1 follow-up brief:
- docs/handoff/PHASE-3.1-FOLLOWUP-RECOGNIZER-EXPANSION.md documents
the empirical finding that the train_sample bottleneck is
verb-coverage (recognizer scope, ADR-0163.x) not lookback
(ADR-0174 scope). 11 verbs identified for HITL contemplation pass.
Recommends sequencing: Phase 3a now (substrate), ADR-0163.x verb
expansion next, Phase 3b after coverage matures.
Acceptance verified:
- 17/17 Phase 3a tests pass
- 95/95 existing tests pass (Phase 1 + Phase 2 + brief_11 + reader_phase2)
- Smoke 67/67, packs 141/141, lanes 8/8
- wrong=0 preserved, score unchanged 3/47/0 (intentional per brief)
Stacks on Phase 2 (PR #420). Rebases onto main after #416 + #420 land.
This commit is contained in:
parent
54d719fbdb
commit
5d1f1001f4
5 changed files with 1093 additions and 3 deletions
179
docs/handoff/PHASE-3.1-FOLLOWUP-RECOGNIZER-EXPANSION.md
Normal file
179
docs/handoff/PHASE-3.1-FOLLOWUP-RECOGNIZER-EXPANSION.md
Normal 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."
|
||||
387
generate/comprehension/lookback.py
Normal file
387
generate/comprehension/lookback.py
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
"""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.
|
||||
if isinstance(refinement, PronounResolution):
|
||||
return _apply_pronoun_resolution(hypothesis, refinement)
|
||||
raise ComprehensionStateError(
|
||||
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",
|
||||
]
|
||||
|
|
@ -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,106 @@ 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
|
||||
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 = ()
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
410
tests/test_adr_0174_phase3_lookback.py
Normal file
410
tests/test_adr_0174_phase3_lookback.py
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
"""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_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"
|
||||
]
|
||||
# Either we emitted a no_antecedent event, OR the sentence
|
||||
# refused before reaching the lookback path. Both preserve
|
||||
# wrong=0; the assertion captures the intent.
|
||||
no_antecedent = any(
|
||||
ev.get("outcome") == "no_antecedent"
|
||||
for ev in lookback_events
|
||||
)
|
||||
# The problem should refuse cleanly regardless.
|
||||
assert r.refusal_reason is not None or r.answer is None
|
||||
# If lookback fired at all, it must have been no_antecedent.
|
||||
for ev in lookback_events:
|
||||
assert ev.get("outcome") in ("no_antecedent", "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']}"
|
||||
)
|
||||
Loading…
Reference in a new issue