core/generate/comprehension/lifecycle_runtime_adapter.py
Shay 800cf6591e
feat(ADR-0164.P1): reader/regex hybrid coexistence + Phase 1 measurement gate (#331)
Phase A — RuntimeConfig flag:
  core/config.py: adds `comprehension_reader_questions: bool = False`
  Default OFF preserves byte-identical behaviour with today.

Phase B — Hybrid wiring in candidate-graph path:
  generate/math_candidate_graph.py:
    - _try_reader_for_question() dispatches to the comprehension reader
      BEFORE the regex question parser; refusal falls through to regex
    - reader_trace: tuple[str, ...] field on CandidateGraphResult captures
      JSON-encoded admit/fallthrough events for audit
  generate/comprehension/lifecycle_runtime_adapter.py (new):
    - build_problem_state_from_candidates(): converts regex-parser output
      to ProblemReadingState for the reader's pronoun-resolution step
    - invoke_reader_for_question(): tokenises sentence, drives lifecycle
    - project_to_candidate_unknown(): QuestionTargetSlot → CandidateUnknown
    - trace-event constructors for admit and fallthrough

Phase C — Capability-axis regression:
  All existing tests pass with flag OFF and ON; zero new regressions.
  Two pre-existing failures on main are unrelated to this PR.

Phase D — GSM8K train_sample measurement:
  evals/gsm8k_math/train_sample/v1/runner.py: --use-reader flag triggers
    baseline-off + reader-on runs and writes reader_phase1_delta.json
  evals/gsm8k_math/train_sample/v1/reader_phase1_delta.json (new):
    baseline-off: correct=3 refused=47 wrong=0
    reader-on:    correct=3 refused=47 wrong=0
    delta: all zeros — Mixed result expected (Phase 2 scope)
    wrong=0 invariant preserved in both modes.

Phase E — Coexistence tests:
  tests/test_reader_coexistence.py (new): 13 tests covering
    flag-OFF byte-identity, flag-ON determinism, wrong=0 invariant,
    trace shape validation, Brief-8 target admission, and fallthrough
    preservation for unknown-unit words.

Admission gate result: Mixed (correct=3, below the ≥10 bar).
All statement-side barriers remain in place; Phase 2 (reader for
statement sentences) is required to drive correct≥10. Documented in
reader_phase1_delta.json and train_sample/v1/runner.py docstring.
2026-05-26 21:14:11 -07:00

402 lines
15 KiB
Python

"""ADR-0164 Phase 1 — bridge from regex-parser candidates to reader state.
Converts CandidateInitial / SentenceChoice tuples produced by the existing
regex parser into a ProblemReadingState that the comprehension reader can
consume for question-sentence processing.
This module is the only place where the Phase 1 coexistence wiring knows
about both worlds simultaneously. It is intentionally a stopgap: Phase 3
(per ADR-0164 §Phasing Phase 3) removes the regex question parser entirely,
at which point this adapter either shrinks to a pure statement-candidate
helper or is deleted.
All public functions are pure and deterministic (same inputs → same outputs,
no I/O, no global state mutation).
"""
from __future__ import annotations
import json
import re
from typing import TYPE_CHECKING, Final, Union
from generate.comprehension.lifecycle import (
_classify,
_get_lexicon,
apply_word,
begin_sentence,
end_sentence,
)
from generate.comprehension.state import (
EntityRef,
PartialInitialPossession,
PartialOperation,
ProblemReadingState,
QuestionTargetSlot,
QuantityRef,
ReaderRefusal,
SentenceReadingState,
)
if TYPE_CHECKING:
from generate.math_candidate_parser import CandidateInitial
from generate.math_roundtrip import CandidateOperation
# Union type for statement-sentence choices (mirrors math_candidate_graph).
SentenceChoice = Union["CandidateInitial", "CandidateOperation"]
# ---------------------------------------------------------------------------
# Gender inference via lexicon
# ---------------------------------------------------------------------------
_FEMALE_CATEGORIES: Final[frozenset[str]] = frozenset({"proper_noun_entity_female"})
_MALE_CATEGORIES: Final[frozenset[str]] = frozenset({"proper_noun_entity_male"})
_UNIT_CLASS_CATEGORIES: Final[dict[str, str]] = {
"count_unit_noun": "count",
"currency_unit_noun": "currency",
"time_unit_noun": "time",
}
def _infer_gender(entity_name: str) -> str:
"""Return 'female', 'male', or 'unknown' for a proper-noun entity.
Consults the en_core_math_v1 lexicon (via the lifecycle's cached loader)
per ADR-0164.2 gender-inference policy. Defaults to 'unknown' when the
name is absent from the lexicon.
"""
lex = _get_lexicon()
key = entity_name.lower()
entry = lex.get(key)
if entry is None:
return "unknown"
if entry.category in _FEMALE_CATEGORIES:
return "female"
if entry.category in _MALE_CATEGORIES:
return "male"
return "unknown"
# ---------------------------------------------------------------------------
# Build ProblemReadingState from regex-parser output
# ---------------------------------------------------------------------------
def build_problem_state_from_candidates(
statement_choices: list[SentenceChoice],
statement_sentence_count: int,
) -> ProblemReadingState:
"""Convert regex-parser output into a ProblemReadingState for reader consumption.
Args:
statement_choices: Admissible CandidateInitial / CandidateOperation
tuples produced by the existing regex parser, in source-text order.
statement_sentence_count: Number of statement sentences already
processed (sets ``ProblemReadingState.sentence_index``).
Returns:
A ProblemReadingState with entity_registry, accumulated_initial_state,
and accumulated_operations populated from the candidates.
unknown_target_slot is None (the question hasn't been processed yet).
This function is the glue layer for Phase 1 coexistence. It does NOT
attempt to reproduce the reader's full incremental behaviour for statement
sentences — that is Phase 2 scope. It produces only what the reader's
pronoun-resolution step needs: an ordered entity registry.
"""
from generate.math_candidate_parser import CandidateInitial as _CI
from generate.math_roundtrip import CandidateOperation as _CO
entity_registry: list[EntityRef] = []
seen_names: set[str] = set()
accumulated_initials: list[PartialInitialPossession] = []
accumulated_ops: list[PartialOperation] = []
char_offset = 0
for choice in statement_choices:
if isinstance(choice, _CI):
entity_name = choice.initial.entity
if entity_name not in seen_names:
gender = _infer_gender(entity_name)
entity_registry.append(
EntityRef(
canonical_name=entity_name,
gender=gender,
first_mention_position=len(seen_names),
)
)
seen_names.add(entity_name)
# Convert InitialPossession to PartialInitialPossession
from decimal import Decimal
qty_val = choice.initial.quantity.value
qty = QuantityRef(
value=Decimal(str(qty_val)),
unit=choice.initial.quantity.unit,
unit_class=None,
owner_entity=entity_name,
mention_position=len(accumulated_initials),
)
accumulated_initials.append(
PartialInitialPossession(entity=entity_name, quantity=qty)
)
elif isinstance(choice, _CO):
actor = choice.op.actor
if actor not in seen_names:
gender = _infer_gender(actor)
entity_registry.append(
EntityRef(
canonical_name=actor,
gender=gender,
first_mention_position=len(seen_names),
)
)
seen_names.add(actor)
if choice.op.target is not None and choice.op.target not in seen_names:
tgt = choice.op.target
gender_t = _infer_gender(tgt)
entity_registry.append(
EntityRef(
canonical_name=tgt,
gender=gender_t,
first_mention_position=len(seen_names),
)
)
seen_names.add(tgt)
# Operand — may be Quantity or Comparison; only carry scalar Quantity
from generate.math_problem_graph import Quantity
operand_ref: QuantityRef | None = None
if hasattr(choice.op, "operand") and isinstance(choice.op.operand, Quantity):
from decimal import Decimal
operand_ref = QuantityRef(
value=Decimal(str(choice.op.operand.value)),
unit=choice.op.operand.unit,
unit_class=None,
owner_entity=actor,
mention_position=len(accumulated_ops),
)
accumulated_ops.append(
PartialOperation(
actor=actor,
kind=choice.op.kind,
operand=operand_ref,
target=choice.op.target,
)
)
return ProblemReadingState(
entity_registry=tuple(entity_registry),
accumulated_initial_state=tuple(accumulated_initials),
accumulated_operations=tuple(accumulated_ops),
unknown_target_slot=None,
pronoun_resolution_history=(),
sentence_index=statement_sentence_count,
source_text_offset=char_offset,
)
# ---------------------------------------------------------------------------
# Tokenisation (matches the reader's apply_word loop convention)
# ---------------------------------------------------------------------------
_TOKEN_SPLIT_RE: Final[re.Pattern[str]] = re.compile(r"\s+")
_PUNCT_STRIP_RE: Final[re.Pattern[str]] = re.compile(r"^[\"'()\[\]{}<>]+|[\"'()\[\]{}<>]+$")
def _tokenise_sentence(sentence: str) -> list[str]:
"""Split a sentence into tokens, emitting punctuation as separate tokens.
Trailing ``?`` and ``.`` become their own token (matched by primitive scanner
as ``question_terminator`` / ``statement_terminator``). Leading/trailing
matched-pair punctuation is stripped per word. Empty strings are dropped.
"""
tokens: list[str] = []
for raw in _TOKEN_SPLIT_RE.split(sentence.strip()):
if not raw:
continue
# Separate a trailing '?' or '.' from the word body.
if len(raw) > 1 and raw[-1] in "?.!":
body = raw[:-1]
tail = raw[-1]
else:
body = raw
tail = None
body = _PUNCT_STRIP_RE.sub("", body)
if body:
tokens.append(body)
if tail:
tokens.append(tail)
return tokens
# ---------------------------------------------------------------------------
# Unit extraction from question sentence
# ---------------------------------------------------------------------------
def _extract_unit_from_question(question_sentence: str, unit_class: str) -> str | None:
"""Scan question tokens for a unit-noun surface word matching ``unit_class``.
After the reader produces a QuestionTargetSlot with unit_class set, this
helper re-tokenises the question to find the specific unit word. This lets
the projected Unknown carry the actual unit string (e.g. 'apples') rather
than the abstract class ('count'), maximising match probability against
statement candidates' unit strings.
Returns the canonicalised unit string, or None when no unit noun is found
with the expected class.
"""
from generate.math_candidate_parser import _canonicalize_unit # type: ignore[attr-defined]
target_categories = {
"count": frozenset({"count_unit_noun"}),
"currency": frozenset({"currency_unit_noun"}),
"time": frozenset({"time_unit_noun"}),
}.get(unit_class, frozenset())
if not target_categories:
return None
for tok in _tokenise_sentence(question_sentence):
cat, _surface = _classify(tok)
if cat in target_categories:
return _canonicalize_unit(tok)
return None
# ---------------------------------------------------------------------------
# Run the reader over a question sentence
# ---------------------------------------------------------------------------
def invoke_reader_for_question(
question_sentence: str,
problem_state: ProblemReadingState,
) -> tuple[QuestionTargetSlot, str] | ReaderRefusal:
"""Run the Phase-1 reader over one question sentence.
Returns:
On success: ``(QuestionTargetSlot, canonical_unit)`` where
``canonical_unit`` is the actual unit string extracted from the
question tokens (may differ from ``slot.unit_class``).
On refusal: ``ReaderRefusal``.
The caller is responsible for wrapping the result in a CandidateUnknown
and for emitting the trace event.
"""
tokens = _tokenise_sentence(question_sentence)
sentence_state: SentenceReadingState = begin_sentence(
problem_state, source_text_offset=problem_state.source_text_offset
)
for tok in tokens:
result = apply_word(sentence_state, problem_state, tok)
if isinstance(result, ReaderRefusal):
return result
sentence_state = result
end_result = end_sentence(sentence_state, problem_state)
if isinstance(end_result, ReaderRefusal):
return end_result
# end_sentence succeeded — extract QuestionTargetSlot from the new
# problem_state (it was just committed as unknown_target_slot).
slot = end_result.unknown_target_slot
if slot is None:
return ReaderRefusal(
reason="no_question_target",
detail="end_sentence succeeded but no unknown_target_slot set",
sentence_index=problem_state.sentence_index,
token_index=len(tokens),
token_text="",
)
# Extract the canonical unit string from the question surface.
unit_class = slot.unit_class or "unknown"
canonical_unit = _extract_unit_from_question(question_sentence, unit_class)
if canonical_unit is None:
# Fall back to unit_class as the unit string per ADR-0164 Brief-9 spec.
canonical_unit = unit_class
return slot, canonical_unit
# ---------------------------------------------------------------------------
# Project QuestionTargetSlot → CandidateUnknown
# ---------------------------------------------------------------------------
def project_to_candidate_unknown(
slot: QuestionTargetSlot,
canonical_unit: str,
question_sentence: str,
problem_state: ProblemReadingState,
) -> "CandidateUnknown | None": # type: ignore[name-defined]
"""Convert a QuestionTargetSlot into a CandidateUnknown for the candidate graph.
Returns None if the projection would produce an invalid Unknown (e.g., the
entity is set but not in the problem_state entity registry, which would
cause _build_graph to reject it).
Modifier flags (aggregate, comparative, residual) from the reader's
lookback are not threaded into Unknown (Unknown has only entity + unit
fields per ADR-0115). Deferral documented here; a follow-up ADR will
extend BoundUnknown resolution to consume these flags via side-channel.
"""
from generate.math_candidate_parser import CandidateUnknown, _canonicalize_unit
from generate.math_problem_graph import Unknown
entity: str | None = slot.entity
# Validate entity against the registry when set.
if entity is not None:
known = {e.canonical_name for e in problem_state.entity_registry}
if entity not in known:
return None
matched_unit_token = canonical_unit
matched_entity_token = entity
try:
unknown = Unknown(entity=entity, unit=canonical_unit)
except Exception:
return None
try:
return CandidateUnknown(
unknown=unknown,
source_span=question_sentence,
matched_unit_token=matched_unit_token,
matched_entity_token=matched_entity_token,
)
except Exception:
return None
# ---------------------------------------------------------------------------
# Trace-event construction
# ---------------------------------------------------------------------------
def make_admit_trace_event(
slot: QuestionTargetSlot,
canonical_unit: str,
) -> str:
"""Build a JSON-encoded admit trace event for the reader."""
return json.dumps(
{
"layer": "comprehension_reader",
"phase": 1,
"outcome": "admit",
"entity": slot.entity,
"unit": canonical_unit,
"question_form": slot.kind,
},
sort_keys=True,
separators=(",", ":"),
)
def make_fallthrough_trace_event(refusal: ReaderRefusal) -> str:
"""Build a JSON-encoded fallthrough trace event for the reader."""
return json.dumps(
{
"layer": "comprehension_reader",
"phase": 1,
"outcome": "fallthrough_to_regex",
"refusal_reason": refusal.reason,
"refusal_token": refusal.token_text,
},
sort_keys=True,
separators=(",", ":"),
)