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.
387 lines
15 KiB
Python
387 lines
15 KiB
Python
"""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",
|
|
]
|