core/generate/comprehension/contemplate.py
Shay aa15dc1f3d feat(adr-0174-phase4): in-loop contemplate + en_core_names_v1 pack
ADR-0174 Phase 4 — deterministic search adapter for evidence that
disambiguates surviving hypothesis sets. First load-bearing use case:
gendered-pronoun resolution via the en_core_names_v1 pack — turns
the Phase 3a multi-actor defense from refuse-on-ambiguity into
admit-via-evidence when an unambiguous gendered name binds the
pronoun to one antecedent.

generate/comprehension/contemplate.py (new, ~310 lines):
  - Resolution dataclass (closed-set kind + source + evidence shape)
  - VALID_RESOLUTION_KINDS = {eliminate, admit_unknown}
  - VALID_RESOLUTION_SOURCES = {vault, pack, audit_history}
  - contemplate() orchestrator — adapters consulted in precedence
    order: vault > pack > audit_history (ADR-0174 §Open Q#3)
  - _consult_packs() — gendered-pronoun resolution implementation
  - _consult_vault() and _consult_audit_history() — stubs (Phase 4b)
  - _PRONOUN_GENDER closed map (she/he gendered; they/them epicene)
  - _load_names_pack() with @lru_cache; refusal-preferring on
    absent pack

language_packs/data/en_core_names_v1/ (new pack):
  - gender.jsonl — 59 unambiguously-gendered English first names
    (30 female, 29 male), alphabetically sorted, JSONL with schema
    {name: str, gender: 'female'|'male'}.  Covers names appearing
    in train_sample/v1 GSM8K problems (Alice, Bob, Daniel, Malcolm,
    Erica, Jan, Tina, etc.).  Deliberately excludes ambiguous-
    gender names (Jordan, Alex, Casey, Pat, Taylor, Morgan, Sam,
    Chris, Robin, Riley).
  - manifest.json — pack metadata with sha256 checksum
    (f65836e7a25a9db8aae984d259b60e161574ff3b4bb135a924aa767a794fbd21),
    entry count, schema declaration, ambiguity discipline,
    expansion pathway through HITL corridor, wrong=0 protection
    contract.

generate/math_candidate_graph.py:
  - Phase 4 wiring at the multi-actor defense site (was: refuse
    on len(_distinct_priors) > 1; now: invoke contemplate first,
    fall through to defense when contemplate returns None).
  - On contemplate.kind='admit_unknown' from pack source: extract
    chosen antecedent from evidence, override _antecedent, clear
    _multi_actor_ambiguous, proceed to admit-via-PronounResolution.
  - On contemplate=None: fire new 'ambiguous_unresolvable'
    contemplate trace event AND original 'no_antecedent_ambiguous'
    lookback event, drop candidates.

tests/test_adr_0174_phase4_contemplate.py (new):
  27 acceptance tests covering: primitive contract (empty/single-
  survivor noop), Resolution dataclass invariants (5 refusal
  paths), names pack load + content spot-checks, pronoun gender
  lookup (gendered + epicene), 6 gendered-pronoun resolution
  cases (she/he success, same-gender refusal, unknown-name
  refusal, epicene refusal, no-matching-gender refusal), end-to-
  end wiring through parse_and_solve, determinism (two calls
  byte-identical, evidence sorted), closed-set contracts,
  wrong=0 + case-0050 canary.

tests/test_adr_0174_phase3_lookback.py + phase3b_compound_clause.py:
  Updated the multi-actor defense tests to use SAME-GENDER
  antecedents (Alice + Mary) so Phase 4 contemplate cannot
  disambiguate via gender pack — the Phase 3a defense still
  fires. (For mixed-gender antecedents the new behavior is
  correct admit-via-evidence; that's tested in Phase 4 suite.)

End-to-end answer-correctness caveat (documented in test
docstrings):
  Phase 4 trace events fire correctly when the recognizer-
  injection path encounters multi-actor pronoun cases that the
  pack disambiguates.  However the regex parser ALSO produces
  candidates for simpler pronoun-subject shapes (without
  intervening prepositional phrases); those compete in the
  Cartesian product and the contemplate-resolved binding may be
  shadowed.  This is the latent regex-path pronoun hazard tracked
  in project-adr-0174-multi-actor-pronoun-hazard memory.  Full
  answer lift on train_sample requires regex-path defense (Phase 5
  regex retirement work).

Acceptance:
- 285/285 ADR-0174 + math_problem_graph tests pass
- Smoke 67/67, packs 141/141
- train_sample 3/47/0 preserved (wrong=0 held)
- Phase 4 trace event fires end-to-end on multi-PP pronoun-subject
  case: contemplate/resolved with chosen=Alice, evidence pack
  Alice=female + Bob=male

References: ADR-0174 §In-loop contemplation, CLAUDE.md §Lookback
Review Discipline, docs/handoff/ADR-0174-PHASE-3B-4-COMBINED-SCOPE.md,
docs/handoff/phase-3b-4-skeleton/ (skeleton dispatch source),
project-adr-0174-multi-actor-pronoun-hazard memory.
2026-05-28 12:09:52 -07:00

372 lines
14 KiB
Python

"""ADR-0174 Phase 4 — in-loop contemplation.
When constraint elimination leaves ``|surviving| >= 2`` open
hypotheses, the reader invokes :func:`contemplate` to deterministically
search vault / packs / audit-history for evidence that disambiguates
the survivors. Returns :class:`Resolution` on unambiguous evidence,
``None`` on ambiguous or absent evidence (caller refuses cleanly —
preserves wrong=0).
Phase 4a scope (this module):
- :class:`Resolution` dataclass with closed-set ``kind`` and ``source``
- :func:`contemplate` orchestrator with three adapters consulted in
precedence order: vault > pack > audit_history
- Concrete pack adapter: gendered-pronoun resolution via
``en_core_names_v1`` (the first load-bearing use case — turns the
Phase 3a multi-actor defense from refuse-on-ambiguity into admit-
via-evidence when gendered names disambiguate)
- Vault and audit-history adapters are stubs returning None in v1;
Phase 4b will wire them when concrete use cases land
Trust boundary:
- Read-only over every evidence source (no vault writes, no pack
mutations, no audit-history modification)
- Deterministic search; no LLM, no sampling, no normalization
- Ambiguous evidence → ``None`` → caller refuses (wrong=0 preserved)
- Adapter precedence is structural (vault > pack > audit_history),
not score-tuned
Per ADR-0174 §"In-loop contemplation": ambiguity that contemplation
cannot resolve is a refusal, not a guess.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Final, Literal
from generate.comprehension.state import (
ComprehensionStateError,
Hypothesis,
ProblemReadingState,
)
# ---------------------------------------------------------------------------
# Closed-set contracts
# ---------------------------------------------------------------------------
VALID_RESOLUTION_KINDS: Final[frozenset[str]] = frozenset(
{"eliminate", "admit_unknown"}
)
"""Closed set of Resolution.kind values. Adding new kinds requires ADR amendment."""
VALID_RESOLUTION_SOURCES: Final[frozenset[str]] = frozenset(
{"vault", "pack", "audit_history"}
)
"""Closed set of evidence sources. Adapter precedence is vault > pack > audit_history."""
@dataclass(frozen=True, slots=True)
class Resolution:
"""Outcome of a successful contemplate consult.
Returned by :func:`contemplate` when evidence unambiguously
disambiguates the surviving hypothesis set. Serialisable as a
JSON object in ``reader_trace`` events.
Fields:
kind: ``"eliminate"`` (remove ``target_hypothesis_id``
from survivors) or ``"admit_unknown"``
(admit a previously-held unknown bound by
the evidence).
target_hypothesis_id: The ``Hypothesis.confidence_rank`` of the
hypothesis to eliminate (for ``"eliminate"``)
or the id of the held unknown to admit
(for ``"admit_unknown"``).
sub_question: The question the contemplate function was
asking itself when finding the resolution.
Recorded for trace audit; not used for
control flow.
source: Which adapter produced the resolution —
``"vault"``, ``"pack"``, or
``"audit_history"``.
evidence: Source-specific evidence references. For
pack source: tuple of ``(pack_id, fact)``
entries. For vault source: tuple of
``(vault_recall_key, value)`` entries.
"""
kind: Literal["eliminate", "admit_unknown"]
target_hypothesis_id: int
sub_question: str
source: Literal["vault", "pack", "audit_history"]
evidence: tuple[tuple[str, str], ...]
def __post_init__(self) -> None:
if self.kind not in VALID_RESOLUTION_KINDS:
raise ComprehensionStateError(
f"Resolution.kind must be in {sorted(VALID_RESOLUTION_KINDS)}; "
f"got {self.kind!r}"
)
if (
not isinstance(self.target_hypothesis_id, int)
or isinstance(self.target_hypothesis_id, bool)
or self.target_hypothesis_id < 0
):
raise ComprehensionStateError(
"Resolution.target_hypothesis_id must be non-negative int; "
f"got {self.target_hypothesis_id!r}"
)
if not isinstance(self.sub_question, str) or not self.sub_question:
raise ComprehensionStateError(
"Resolution.sub_question must be non-empty str"
)
if self.source not in VALID_RESOLUTION_SOURCES:
raise ComprehensionStateError(
f"Resolution.source must be in {sorted(VALID_RESOLUTION_SOURCES)}; "
f"got {self.source!r}"
)
if not isinstance(self.evidence, tuple):
raise ComprehensionStateError(
"Resolution.evidence must be tuple"
)
for idx, e in enumerate(self.evidence):
if not (
isinstance(e, tuple)
and len(e) == 2
and isinstance(e[0], str) and e[0]
and isinstance(e[1], str) and e[1]
):
raise ComprehensionStateError(
f"Resolution.evidence[{idx}] must be "
f"(source_id:non-empty str, fact:non-empty str); got {e!r}"
)
# ---------------------------------------------------------------------------
# Gendered-names pack — load + closed-set query
# ---------------------------------------------------------------------------
_PRONOUN_GENDER: Final[dict[str, str]] = {
"she": "female", "her": "female", "hers": "female",
"he": "male", "him": "male", "his": "male",
}
"""Closed-set pronoun → gender map. v1 covers English binary-gender
third-person singular pronouns. ``they``/``them`` deliberately excluded
(epicene; ambiguous gender; refusal-preferring discipline)."""
def _names_pack_path() -> Path:
"""Resolve the path to en_core_names_v1/gender.jsonl from repo root."""
here = Path(__file__).resolve()
repo_root = here
for _ in range(10):
repo_root = repo_root.parent
if (repo_root / "pyproject.toml").exists():
break
return (
repo_root / "language_packs" / "data" / "en_core_names_v1"
/ "gender.jsonl"
)
@lru_cache(maxsize=1)
def _load_names_pack() -> dict[str, str]:
"""Load the gendered-names pack into a name → gender map.
Cached per-process. Returns empty dict if the pack is absent (Phase 4
consult then returns None on every query, refusal-preferring).
"""
path = _names_pack_path()
if not path.exists():
return {}
out: dict[str, str] = {}
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
entry = json.loads(line)
name = entry.get("name")
gender = entry.get("gender")
if isinstance(name, str) and gender in ("female", "male"):
out[name.lower()] = gender
return out
def _pronoun_required_gender(pronoun: str) -> str | None:
"""Return 'female' / 'male' for English gendered pronouns, else None.
``they``/``them`` etc. return None — epicene pronouns are ambiguous
by design and trigger refusal-preferring discipline at the caller.
"""
return _PRONOUN_GENDER.get(pronoun.lower())
# ---------------------------------------------------------------------------
# Adapters — vault, pack, audit_history
# ---------------------------------------------------------------------------
def _consult_vault(
state: ProblemReadingState,
residual: tuple[Hypothesis, ...],
) -> Resolution | None:
"""Vault adapter — exact CGA recall for prior session resolutions.
Phase 4a: returns None (stub). Phase 4b will wire vault-backed
resolution when concrete use cases land (e.g. user previously
corrected a pronoun reference and the resolution was vaulted).
"""
return None
def _consult_packs(
state: ProblemReadingState,
residual: tuple[Hypothesis, ...],
pronoun_hint: str | None,
candidate_antecedents: tuple[str, ...],
) -> Resolution | None:
"""Pack adapter — gendered-pronoun resolution via en_core_names_v1.
Inputs:
- pronoun_hint: surface pronoun token from the held statement
(e.g. ``"She"``), if known. None when contemplate is invoked
for a non-pronoun ambiguity.
- candidate_antecedents: the proper-noun antecedents the
multi-actor defense identified as candidates. Each must be
looked up in the names pack.
Returns Resolution(kind="eliminate", source="pack", ...) when
exactly one antecedent's gender matches the pronoun's required
gender. Returns None when:
- pronoun_hint is None (no pronoun to disambiguate)
- pronoun is epicene (they/them) — gender ambiguous by design
- any antecedent is not in the pack — ambiguous evidence
- multiple antecedents share the matching gender — ambiguous
- no antecedent matches the required gender — refuse (would-be
wrong attribution)
The Resolution targets the FIRST non-matching antecedent for
elimination; the caller iterates and eventually leaves one
survivor.
Trust boundary: read-only over the pack. The pack itself is a
closed-set artifact reviewed through the HITL corridor (ADR-0150/
0152) — unknown-gender names are deliberately excluded.
"""
if pronoun_hint is None:
return None
required_gender = _pronoun_required_gender(pronoun_hint)
if required_gender is None:
return None # epicene pronoun or unknown
pack = _load_names_pack()
if not pack:
return None # pack absent
# Each antecedent must be in the pack.
antecedent_genders: dict[str, str] = {}
for ant in candidate_antecedents:
g = pack.get(ant.lower())
if g is None:
return None # unknown name; refusal-preferring
antecedent_genders[ant] = g
# Find antecedents matching the required gender.
matching = [
ant for ant, g in antecedent_genders.items() if g == required_gender
]
if len(matching) != 1:
return None # zero matches OR multiple matches → ambiguous
chosen = matching[0]
# We return a Resolution describing what the CALLER must do: bind
# the pronoun to the chosen antecedent. The target_hypothesis_id
# and kind are set by the caller in math_candidate_graph based on
# which hypothesis carries the unresolved pronoun. We use kind=
# "admit_unknown" with the chosen antecedent encoded in evidence
# so the caller can route the pronoun resolution.
return Resolution(
kind="admit_unknown",
target_hypothesis_id=0, # caller substitutes based on context
sub_question=(
f"which antecedent matches the {required_gender}-gendered "
f"pronoun {pronoun_hint!r}?"
),
source="pack",
evidence=tuple(
("en_core_names_v1", f"{ant}={g}")
for ant, g in sorted(antecedent_genders.items())
) + (("en_core_names_v1", f"chosen={chosen}"),),
)
def _consult_audit_history(
state: ProblemReadingState,
residual: tuple[Hypothesis, ...],
) -> Resolution | None:
"""Audit-history adapter — prior reader refusals on the same token.
Phase 4a: returns None (stub). Phase 4b will wire audit-history
when refusal-log evidence becomes a concrete consult target.
"""
return None
# ---------------------------------------------------------------------------
# Orchestrator
# ---------------------------------------------------------------------------
def contemplate(
state: ProblemReadingState,
residual: tuple[Hypothesis, ...],
*,
pronoun_hint: str | None = None,
candidate_antecedents: tuple[str, ...] = (),
) -> Resolution | None:
"""Deterministic search for evidence disambiguating the residual.
Per ADR-0174 §"In-loop contemplation":
- Consults adapters in order: vault > pack > audit_history
- Returns Resolution from the first adapter producing one
- Returns None when no adapter resolves (caller refuses cleanly)
Args:
state: The current ProblemReadingState (for vault recall scope).
residual: The surviving hypothesis set after constraint elimination.
pronoun_hint: Optional surface pronoun for pronoun-resolution
consult (the load-bearing Phase 4a use case).
candidate_antecedents: Optional candidate antecedents for the
pronoun, when contemplate is invoked from the multi-actor
defense site.
Returns:
Resolution on unambiguous disambiguation, None otherwise.
The function is pure: same inputs → same Resolution (or None).
Determinism is the trace-hash invariant from ADR-0174 §Constraints.
"""
if not residual or len(residual) < 2:
# Nothing to disambiguate.
return None
# Adapter precedence (ADR-0174 §Open Q#3).
result = _consult_vault(state, residual)
if result is not None:
return result
result = _consult_packs(
state, residual,
pronoun_hint=pronoun_hint,
candidate_antecedents=candidate_antecedents,
)
if result is not None:
return result
result = _consult_audit_history(state, residual)
if result is not None:
return result
return None
__all__ = [
"Resolution",
"VALID_RESOLUTION_KINDS",
"VALID_RESOLUTION_SOURCES",
"contemplate",
]