diff --git a/generate/comprehension/contemplate.py b/generate/comprehension/contemplate.py new file mode 100644 index 00000000..6357ea59 --- /dev/null +++ b/generate/comprehension/contemplate.py @@ -0,0 +1,372 @@ +"""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", +] diff --git a/generate/math_candidate_graph.py b/generate/math_candidate_graph.py index 02ba546d..7b9f9da0 100644 --- a/generate/math_candidate_graph.py +++ b/generate/math_candidate_graph.py @@ -912,20 +912,101 @@ def parse_and_solve( }, sort_keys=True)) injected = () elif _multi_actor_ambiguous: - # Refusal-preferring discipline: multiple - # distinct proper-noun subjects in prior - # context means the resolver would be - # guessing. Drop the held candidates, - # log the ambiguous-antecedent event. - _statement_trace.append(json.dumps({ - "layer": "lookback", - "phase": 3, - "outcome": "no_antecedent_ambiguous", - "pronoun": _held_pronoun, - "candidate_antecedents": sorted(_distinct_priors), - "sentence_index": s_idx, - }, sort_keys=True)) - injected = () + # ADR-0174 Phase 4 — invoke in-loop + # contemplate before declaring ambiguity. + # When the gendered-names pack uniquely + # binds the pronoun to one antecedent, + # admit via the resolved binding. When + # contemplate returns None (ambiguous + # evidence, missing names, epicene + # pronoun), the Phase 3a defense fires + # cleanly — refusal-preferring discipline. + from generate.comprehension.contemplate import ( + contemplate, + ) + from generate.comprehension.state import ( + ProblemReadingState as _PRS, + ) + # Phase 4 invocation requires Hypothesis + # residuals so contemplate can match its + # contract. Build the held hypotheses + # here (mirrors the post-resolution path + # below) so contemplate has well-typed + # input even though Phase 4a only uses + # candidate_antecedents. + _ps_stub = _PRS( + entity_registry=(), + accumulated_initial_state=(), + accumulated_operations=(), + unknown_target_slot=None, + pronoun_resolution_history=(), + sentence_index=s_idx, + source_text_offset=0, + ) + _ant_tuple = tuple(sorted(_distinct_priors)) + _residual: tuple[Hypothesis, ...] = tuple( + Hypothesis( + candidate=(ant,), # sentinel; Phase 4a uses candidate_antecedents + category_assignments=(), + constraint_state=(), + confidence_rank=i, + unresolved=("actor_pronoun",), + ) + for i, ant in enumerate(_ant_tuple) + ) + _resolution = contemplate( + _ps_stub, _residual, + pronoun_hint=_held_pronoun, + candidate_antecedents=_ant_tuple, + ) + if _resolution is not None and _resolution.source == "pack": + # Pack adapter encoded the chosen + # antecedent in evidence[-1] as + # ("en_core_names_v1", "chosen="). + _chosen: str | None = None + for _src, _fact in _resolution.evidence: + if _fact.startswith("chosen="): + _chosen = _fact.split("=", 1)[1] + break + if _chosen is not None: + _statement_trace.append(json.dumps({ + "layer": "contemplate", + "phase": 4, + "outcome": "resolved", + "source": _resolution.source, + "pronoun": _held_pronoun, + "resolved_to": _chosen, + "evidence": [list(e) for e in _resolution.evidence], + "sub_question": _resolution.sub_question, + "sentence_index": s_idx, + }, sort_keys=True)) + # Override _antecedent for the + # downstream PronounResolution + # path below; the multi-actor + # branch becomes admit-via-evidence + # instead of refuse-on-ambiguity. + _antecedent = _chosen + _multi_actor_ambiguous = False # admit path proceeds + if _multi_actor_ambiguous: + # Contemplate didn't disambiguate — + # original Phase 3a defense fires. + _statement_trace.append(json.dumps({ + "layer": "contemplate", + "phase": 4, + "outcome": "ambiguous_unresolvable", + "pronoun": _held_pronoun, + "candidate_antecedents": sorted(_distinct_priors), + "sentence_index": s_idx, + }, sort_keys=True)) + _statement_trace.append(json.dumps({ + "layer": "lookback", + "phase": 3, + "outcome": "no_antecedent_ambiguous", + "pronoun": _held_pronoun, + "candidate_antecedents": sorted(_distinct_priors), + "sentence_index": s_idx, + }, sort_keys=True)) + injected = () else: _refinement = PronounResolution( pronoun=_held_pronoun, diff --git a/language_packs/data/en_core_names_v1/gender.jsonl b/language_packs/data/en_core_names_v1/gender.jsonl new file mode 100644 index 00000000..2faf3fc5 --- /dev/null +++ b/language_packs/data/en_core_names_v1/gender.jsonl @@ -0,0 +1,59 @@ +{"name":"Alice","gender":"female"} +{"name":"Allison","gender":"female"} +{"name":"Amanda","gender":"female"} +{"name":"Andrew","gender":"male"} +{"name":"Anthony","gender":"male"} +{"name":"Barbara","gender":"female"} +{"name":"Bob","gender":"male"} +{"name":"Carl","gender":"male"} +{"name":"Cassie","gender":"female"} +{"name":"Charles","gender":"male"} +{"name":"Christopher","gender":"male"} +{"name":"Cindy","gender":"female"} +{"name":"Daniel","gender":"male"} +{"name":"David","gender":"male"} +{"name":"Dorothy","gender":"female"} +{"name":"Edward","gender":"male"} +{"name":"Elizabeth","gender":"female"} +{"name":"Erica","gender":"female"} +{"name":"Francine","gender":"female"} +{"name":"Frank","gender":"male"} +{"name":"George","gender":"male"} +{"name":"Helen","gender":"female"} +{"name":"Henry","gender":"male"} +{"name":"James","gender":"male"} +{"name":"Jan","gender":"female"} +{"name":"Jason","gender":"male"} +{"name":"Jen","gender":"female"} +{"name":"Jennifer","gender":"female"} +{"name":"Jeremie","gender":"male"} +{"name":"Jessica","gender":"female"} +{"name":"John","gender":"male"} +{"name":"Joseph","gender":"male"} +{"name":"Karen","gender":"female"} +{"name":"Karl","gender":"male"} +{"name":"Linda","gender":"female"} +{"name":"Lisa","gender":"female"} +{"name":"Malcolm","gender":"male"} +{"name":"Mandy","gender":"female"} +{"name":"Margaret","gender":"female"} +{"name":"Mark","gender":"male"} +{"name":"Martha","gender":"female"} +{"name":"Mary","gender":"female"} +{"name":"Michael","gender":"male"} +{"name":"Nancy","gender":"female"} +{"name":"Nicole","gender":"female"} +{"name":"Orlando","gender":"male"} +{"name":"Patricia","gender":"female"} +{"name":"Rachel","gender":"female"} +{"name":"Richard","gender":"male"} +{"name":"Robert","gender":"male"} +{"name":"Rudolph","gender":"male"} +{"name":"Sandra","gender":"female"} +{"name":"Sarah","gender":"female"} +{"name":"Susan","gender":"female"} +{"name":"Thomas","gender":"male"} +{"name":"Tina","gender":"female"} +{"name":"Troy","gender":"male"} +{"name":"William","gender":"male"} +{"name":"Xavier","gender":"male"} diff --git a/language_packs/data/en_core_names_v1/manifest.json b/language_packs/data/en_core_names_v1/manifest.json new file mode 100644 index 00000000..15a85e0b --- /dev/null +++ b/language_packs/data/en_core_names_v1/manifest.json @@ -0,0 +1,24 @@ +{ + "pack_id": "en_core_names_v1", + "schema_version": 1, + "description": "English first-name gender pack for ADR-0174 Phase 4 in-loop contemplate. Closed-set, refusal-preferring on uncovered names. Expansion via standard HITL corridor (ADR-0150/0152).", + "adr": "ADR-0174", + "phase": "Phase 4", + "files": [ + { + "path": "gender.jsonl", + "schema": { + "name": "str — proper-noun first name, surface form", + "gender": "literal: female | male — unambiguous gendered association" + }, + "entry_count": 59, + "checksum": "f65836e7a25a9db8aae984d259b60e161574ff3b4bb135a924aa767a794fbd21" + } + ], + "discipline": { + "ambiguity": "Names with ambiguous gender (Jordan, Alex, Casey, Pat, Taylor, Morgan, Sam, Chris, Robin, Riley) MUST NOT be added without explicit HITL review. Refusal-preferring discipline: when the consult returns no entry or contradictory entries, the contemplate caller refuses cleanly rather than guessing.", + "expansion": "New entries enter via core teaching propose-from-exemplars when Phase 4 wiring produces refusal evidence on uncovered names. Each addition reviewed against the unambiguous-gender criterion. Cultural / regional name variations evaluated case-by-case.", + "regional_scope": "v1 covers common English (US/UK/CA/AU) first names. Cross-cultural names with English-language gendered associations included only when unambiguous (e.g. 'Malcolm' is consistently male in English-language usage). Non-English-language name packs are separate packs (en_*_v2 or distinct pack IDs)." + }, + "wrong_zero_protection": "This pack is a Phase 4 evidence source. The contemplate adapter MUST return None (caller refuses) when: (a) any antecedent in the multi-actor set lacks an entry; (b) all antecedents share the same gender (still ambiguous); (c) the pronoun's required gender doesn't match exactly one antecedent." +} diff --git a/tests/test_adr_0174_phase3_lookback.py b/tests/test_adr_0174_phase3_lookback.py index 108f1d9f..7859667c 100644 --- a/tests/test_adr_0174_phase3_lookback.py +++ b/tests/test_adr_0174_phase3_lookback.py @@ -363,12 +363,19 @@ class TestPhase3WiringEndToEnd: multiple distinct proper-noun subjects appear in prior context. Refusal-preferring discipline preserves wrong=0. """ + # ADR-0174 Phase 4 amendment: when Phase 4's contemplate can + # disambiguate the pronoun via gendered-names pack (mixed- + # gender antecedents), the defense correctly does NOT fire — + # the case admits via Phase 4. To test the defense in + # isolation, use SAME-GENDER antecedents so Phase 4 returns + # None (no disambiguation possible) and the defense still + # fires. from generate.math_candidate_graph import parse_and_solve text = ( "Alice has 5 Pokemon cards. " - "Bob has 3 Pokemon cards. " + "Mary has 3 Pokemon cards. " "She collected 2 Pokemon cards. " - "How many Pokemon cards does Bob have?" + "How many Pokemon cards does Alice have?" ) r = parse_and_solve(text) # MUST refuse — wrong attribution is the hazard. @@ -386,7 +393,7 @@ class TestPhase3WiringEndToEnd: ) ev = ambig[0] assert "Alice" in ev["candidate_antecedents"] - assert "Bob" in ev["candidate_antecedents"] + assert "Mary" in ev["candidate_antecedents"] assert ev["pronoun"] == "She" def test_single_actor_pronoun_still_resolves(self) -> None: diff --git a/tests/test_adr_0174_phase3b_compound_clause.py b/tests/test_adr_0174_phase3b_compound_clause.py index dd159cf9..a31be1bc 100644 --- a/tests/test_adr_0174_phase3b_compound_clause.py +++ b/tests/test_adr_0174_phase3b_compound_clause.py @@ -192,12 +192,18 @@ class TestPronounMultiActorDefenseOnCompound: path output too. Tracked in project-adr-0174-multi-actor-pronoun-hazard memory. """ + # ADR-0174 Phase 4 amendment: with mixed-gender antecedents, + # Phase 4's contemplate would resolve the pronoun via the + # gendered-names pack (Alice=female, Bob=male). To test the + # Phase 3a defense in isolation on a COMPOUND case, use + # same-gender antecedents so Phase 4 returns None and the + # defense still fires. from generate.math_candidate_graph import parse_and_solve text = ( "Alice has 5 followers. " - "Bob has 3 followers. " - "He has 2 followers on Instagram and 4 followers on Facebook. " - "How many followers does Bob have?" + "Mary has 3 followers. " + "She has 2 followers on Instagram and 4 followers on Facebook. " + "How many followers does Alice have?" ) r = parse_and_solve(text) lookback = [ diff --git a/tests/test_adr_0174_phase4_contemplate.py b/tests/test_adr_0174_phase4_contemplate.py new file mode 100644 index 00000000..4ba7380c --- /dev/null +++ b/tests/test_adr_0174_phase4_contemplate.py @@ -0,0 +1,387 @@ +"""ADR-0174 Phase 4 — in-loop contemplate acceptance tests. + +Covers: + 1. contemplate() primitive — direct unit tests + 2. Resolution dataclass invariants + 3. Names pack + pronoun gender lookups + 4. Gendered-pronoun resolution (the load-bearing Phase 4a use case) + 5. End-to-end wiring through parse_and_solve — verifies the + contemplate trace event fires when the recognizer-injection + path encounters a multi-actor pronoun with disambiguating gender + evidence + 6. Determinism — pure function contract + 7. Closed-set contract assertions + 8. wrong=0 invariant + case 0050 canary + +Note on end-to-end answer correctness: + Phase 4 wires contemplate into the recognizer-injection branch's + multi-actor defense site. When the regex parser ALSO produces + candidates for the same sentence (simpler shapes without intervening + prepositional phrases), the regex-path candidates compete in the + Cartesian product and the contemplate-resolved candidates may be + shadowed. Phase 4 trace events fire correctly; full answer lift + requires regex-path defense (Phase 5 regex retirement work). + Tracked in project-adr-0174-multi-actor-pronoun-hazard memory. +""" + +from __future__ import annotations + +import json + +import pytest + +from generate.comprehension.contemplate import ( + Resolution, + VALID_RESOLUTION_KINDS, + VALID_RESOLUTION_SOURCES, + _load_names_pack, + _pronoun_required_gender, + contemplate, +) +from generate.comprehension.state import ( + ComprehensionStateError, + Hypothesis, + ProblemReadingState, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _stub_state() -> ProblemReadingState: + return ProblemReadingState( + entity_registry=(), accumulated_initial_state=(), + accumulated_operations=(), unknown_target_slot=None, + pronoun_resolution_history=(), sentence_index=0, + source_text_offset=0, + ) + + +def _residual_two(antecedents: tuple[str, str]) -> tuple[Hypothesis, ...]: + return tuple( + Hypothesis( + candidate=(ant,), + category_assignments=(), + constraint_state=(), + confidence_rank=i, + unresolved=("actor_pronoun",), + ) + for i, ant in enumerate(antecedents) + ) + + +# --------------------------------------------------------------------------- +# 1. contemplate() primitive contract +# --------------------------------------------------------------------------- + + +class TestContemplatePrimitive: + def test_empty_residual_returns_none(self) -> None: + assert contemplate(_stub_state(), residual=()) is None + + def test_single_survivor_returns_none(self) -> None: + h = Hypothesis( + candidate=("sentinel",), category_assignments=(), + constraint_state=(), confidence_rank=0, unresolved=(), + ) + assert contemplate(_stub_state(), residual=(h,)) is None + + def test_no_pronoun_hint_returns_none(self) -> None: + r = contemplate( + _stub_state(), + residual=_residual_two(("Alice", "Bob")), + ) + assert r is None + + +# --------------------------------------------------------------------------- +# 2. Resolution dataclass invariants +# --------------------------------------------------------------------------- + + +class TestResolutionDataclass: + def test_valid_resolution_constructs(self) -> None: + r = Resolution( + kind="eliminate", target_hypothesis_id=1, + sub_question="which antecedent is female-gendered?", + source="pack", + evidence=(("en_core_names_v1", "Alice=female"),), + ) + assert r.kind == "eliminate" + assert r.source == "pack" + + def test_invalid_kind_refused(self) -> None: + with pytest.raises(ComprehensionStateError, match="kind"): + Resolution( + kind="guess", # type: ignore[arg-type] + target_hypothesis_id=0, sub_question="x", + source="pack", evidence=(), + ) + + def test_invalid_source_refused(self) -> None: + with pytest.raises(ComprehensionStateError, match="source"): + Resolution( + kind="eliminate", target_hypothesis_id=0, + sub_question="x", + source="llm", # type: ignore[arg-type] + evidence=(), + ) + + def test_negative_target_id_refused(self) -> None: + with pytest.raises( + ComprehensionStateError, match="target_hypothesis_id" + ): + Resolution( + kind="eliminate", target_hypothesis_id=-1, + sub_question="x", source="pack", evidence=(), + ) + + def test_empty_sub_question_refused(self) -> None: + with pytest.raises(ComprehensionStateError, match="sub_question"): + Resolution( + kind="eliminate", target_hypothesis_id=0, + sub_question="", source="pack", evidence=(), + ) + + def test_invalid_evidence_shape_refused(self) -> None: + with pytest.raises(ComprehensionStateError, match="evidence"): + Resolution( + kind="eliminate", target_hypothesis_id=0, + sub_question="x", source="pack", + evidence=(("only_one",),), # type: ignore[arg-type] + ) + + +# --------------------------------------------------------------------------- +# 3. Names pack + pronoun gender lookups +# --------------------------------------------------------------------------- + + +class TestNamesPackLoad: + def test_pack_loads_non_empty(self) -> None: + pack = _load_names_pack() + assert len(pack) >= 30 + assert pack.get("alice") == "female" + assert pack.get("bob") == "male" + assert pack.get("daniel") == "male" + assert pack.get("malcolm") == "male" + + def test_unknown_name_returns_none(self) -> None: + pack = _load_names_pack() + assert pack.get("xqzqzy") is None + + +class TestPronounGenderLookup: + def test_female_pronouns(self) -> None: + assert _pronoun_required_gender("She") == "female" + assert _pronoun_required_gender("her") == "female" + assert _pronoun_required_gender("HERS") == "female" + + def test_male_pronouns(self) -> None: + assert _pronoun_required_gender("He") == "male" + assert _pronoun_required_gender("him") == "male" + assert _pronoun_required_gender("HIS") == "male" + + def test_epicene_pronouns_return_none(self) -> None: + """They/them/it are deliberately epicene — refusal-preferring.""" + assert _pronoun_required_gender("They") is None + assert _pronoun_required_gender("them") is None + assert _pronoun_required_gender("it") is None + + +# --------------------------------------------------------------------------- +# 4. Gendered-pronoun resolution — the load-bearing Phase 4a use case +# --------------------------------------------------------------------------- + + +class TestGenderedPronounResolution: + def test_she_resolves_to_female_antecedent(self) -> None: + r = contemplate( + _stub_state(), + residual=_residual_two(("Alice", "Bob")), + pronoun_hint="She", + candidate_antecedents=("Alice", "Bob"), + ) + assert r is not None + assert r.source == "pack" + assert r.kind == "admit_unknown" + chosen_facts = [ + f for _src, f in r.evidence if f.startswith("chosen=") + ] + assert len(chosen_facts) == 1 + assert chosen_facts[0] == "chosen=Alice" + + def test_he_resolves_to_male_antecedent(self) -> None: + r = contemplate( + _stub_state(), + residual=_residual_two(("Alice", "Bob")), + pronoun_hint="He", + candidate_antecedents=("Alice", "Bob"), + ) + assert r is not None + chosen_facts = [ + f for _src, f in r.evidence if f.startswith("chosen=") + ] + assert chosen_facts[0] == "chosen=Bob" + + def test_same_gender_returns_none(self) -> None: + r = contemplate( + _stub_state(), + residual=_residual_two(("Alice", "Mary")), + pronoun_hint="She", + candidate_antecedents=("Alice", "Mary"), + ) + assert r is None + + def test_unknown_name_returns_none(self) -> None: + r = contemplate( + _stub_state(), + residual=_residual_two(("Xqzqzy", "Bob")), + pronoun_hint="She", + candidate_antecedents=("Xqzqzy", "Bob"), + ) + assert r is None + + def test_epicene_pronoun_returns_none(self) -> None: + r = contemplate( + _stub_state(), + residual=_residual_two(("Alice", "Bob")), + pronoun_hint="They", + candidate_antecedents=("Alice", "Bob"), + ) + assert r is None + + def test_no_matching_gender_returns_none(self) -> None: + r = contemplate( + _stub_state(), + residual=_residual_two(("John", "Bob")), + pronoun_hint="She", + candidate_antecedents=("John", "Bob"), + ) + assert r is None + + +# --------------------------------------------------------------------------- +# 5. End-to-end wiring — trace event fires through parse_and_solve +# --------------------------------------------------------------------------- + + +class TestPhase4EndToEnd: + def test_contemplate_trace_event_fires(self) -> None: + """Verifies the contemplate resolved event appears in + reader_trace when a multi-actor pronoun + gendered-name + evidence is present AND the regex parser refuses the held + sentence (multi-PP shape defeats regex).""" + from generate.math_candidate_graph import parse_and_solve + text = ( + "Alice has 100 followers. " + "Bob has 50 followers. " + "She has 5 followers on Instagram and 3 followers on Facebook. " + "How many followers does Alice have?" + ) + r = parse_and_solve(text) + contemplate_events = [ + json.loads(e) for e in r.reader_trace + if json.loads(e).get("layer") == "contemplate" + ] + resolved = [ + e for e in contemplate_events if e.get("outcome") == "resolved" + ] + assert resolved, ( + f"expected contemplate resolved event; got events={contemplate_events}" + ) + ev = resolved[0] + assert ev["pronoun"] == "She" + assert ev["resolved_to"] == "Alice" + assert ev["source"] == "pack" + assert ev["phase"] == 4 + + +# --------------------------------------------------------------------------- +# 6. Determinism — pure function contract +# --------------------------------------------------------------------------- + + +class TestDeterminism: + def test_two_calls_produce_identical_resolution(self) -> None: + a = contemplate( + _stub_state(), + residual=_residual_two(("Alice", "Bob")), + pronoun_hint="She", + candidate_antecedents=("Alice", "Bob"), + ) + b = contemplate( + _stub_state(), + residual=_residual_two(("Alice", "Bob")), + pronoun_hint="She", + candidate_antecedents=("Alice", "Bob"), + ) + assert a == b + + def test_resolution_evidence_sorted_for_determinism(self) -> None: + """Evidence is sorted (Alice before Bob) regardless of input + order — canonical bytes / trace hash stay stable.""" + a = contemplate( + _stub_state(), + residual=_residual_two(("Alice", "Bob")), + pronoun_hint="She", + candidate_antecedents=("Alice", "Bob"), + ) + b = contemplate( + _stub_state(), + residual=_residual_two(("Bob", "Alice")), + pronoun_hint="She", + candidate_antecedents=("Bob", "Alice"), + ) + assert a is not None and b is not None + # First two evidence entries (sorted antecedent name=gender pairs) + # should be identical regardless of input order. + assert a.evidence[0] == b.evidence[0] + assert a.evidence[1] == b.evidence[1] + + +# --------------------------------------------------------------------------- +# 7. Closed-set contract assertions +# --------------------------------------------------------------------------- + + +class TestClosedSetContracts: + def test_valid_resolution_kinds_membership(self) -> None: + assert VALID_RESOLUTION_KINDS == frozenset( + {"eliminate", "admit_unknown"} + ) + + def test_valid_resolution_sources_membership(self) -> None: + assert VALID_RESOLUTION_SOURCES == frozenset( + {"vault", "pack", "audit_history"} + ) + + +# --------------------------------------------------------------------------- +# 8. wrong=0 invariant + case 0050 canary +# --------------------------------------------------------------------------- + + +class TestWrongZeroPreservation: + def test_train_sample_wrong_is_zero(self) -> None: + 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() if line.strip() + ] + report = build_report(cases, use_reader=True) + assert report["counts"]["wrong"] == 0 + + def test_case_0050_remains_refused(self) -> None: + from generate.math_candidate_graph import parse_and_solve + text = ( + "Mark does a gig every other day for 2 weeks. " + "He gets paid $50 per gig. He then gets a 50% raise. " + "How much money does he make per week?" + ) + r = parse_and_solve(text) + assert r.answer is None