Merge pull request #429 from AssetOverflow/feat/adr-0174-phase4-contemplate
feat(adr-0174-phase4): in-loop contemplate + en_core_names_v1 pack
This commit is contained in:
commit
d1dbda24fc
7 changed files with 956 additions and 20 deletions
372
generate/comprehension/contemplate.py
Normal file
372
generate/comprehension/contemplate.py
Normal file
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -912,20 +912,101 @@ def parse_and_solve(
|
||||||
}, sort_keys=True))
|
}, sort_keys=True))
|
||||||
injected = ()
|
injected = ()
|
||||||
elif _multi_actor_ambiguous:
|
elif _multi_actor_ambiguous:
|
||||||
# Refusal-preferring discipline: multiple
|
# ADR-0174 Phase 4 — invoke in-loop
|
||||||
# distinct proper-noun subjects in prior
|
# contemplate before declaring ambiguity.
|
||||||
# context means the resolver would be
|
# When the gendered-names pack uniquely
|
||||||
# guessing. Drop the held candidates,
|
# binds the pronoun to one antecedent,
|
||||||
# log the ambiguous-antecedent event.
|
# admit via the resolved binding. When
|
||||||
_statement_trace.append(json.dumps({
|
# contemplate returns None (ambiguous
|
||||||
"layer": "lookback",
|
# evidence, missing names, epicene
|
||||||
"phase": 3,
|
# pronoun), the Phase 3a defense fires
|
||||||
"outcome": "no_antecedent_ambiguous",
|
# cleanly — refusal-preferring discipline.
|
||||||
"pronoun": _held_pronoun,
|
from generate.comprehension.contemplate import (
|
||||||
"candidate_antecedents": sorted(_distinct_priors),
|
contemplate,
|
||||||
"sentence_index": s_idx,
|
)
|
||||||
}, sort_keys=True))
|
from generate.comprehension.state import (
|
||||||
injected = ()
|
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=<name>").
|
||||||
|
_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:
|
else:
|
||||||
_refinement = PronounResolution(
|
_refinement = PronounResolution(
|
||||||
pronoun=_held_pronoun,
|
pronoun=_held_pronoun,
|
||||||
|
|
|
||||||
59
language_packs/data/en_core_names_v1/gender.jsonl
Normal file
59
language_packs/data/en_core_names_v1/gender.jsonl
Normal file
|
|
@ -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"}
|
||||||
24
language_packs/data/en_core_names_v1/manifest.json
Normal file
24
language_packs/data/en_core_names_v1/manifest.json
Normal file
|
|
@ -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."
|
||||||
|
}
|
||||||
|
|
@ -363,12 +363,19 @@ class TestPhase3WiringEndToEnd:
|
||||||
multiple distinct proper-noun subjects appear in prior
|
multiple distinct proper-noun subjects appear in prior
|
||||||
context. Refusal-preferring discipline preserves wrong=0.
|
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
|
from generate.math_candidate_graph import parse_and_solve
|
||||||
text = (
|
text = (
|
||||||
"Alice has 5 Pokemon cards. "
|
"Alice has 5 Pokemon cards. "
|
||||||
"Bob has 3 Pokemon cards. "
|
"Mary has 3 Pokemon cards. "
|
||||||
"She collected 2 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)
|
r = parse_and_solve(text)
|
||||||
# MUST refuse — wrong attribution is the hazard.
|
# MUST refuse — wrong attribution is the hazard.
|
||||||
|
|
@ -386,7 +393,7 @@ class TestPhase3WiringEndToEnd:
|
||||||
)
|
)
|
||||||
ev = ambig[0]
|
ev = ambig[0]
|
||||||
assert "Alice" in ev["candidate_antecedents"]
|
assert "Alice" in ev["candidate_antecedents"]
|
||||||
assert "Bob" in ev["candidate_antecedents"]
|
assert "Mary" in ev["candidate_antecedents"]
|
||||||
assert ev["pronoun"] == "She"
|
assert ev["pronoun"] == "She"
|
||||||
|
|
||||||
def test_single_actor_pronoun_still_resolves(self) -> None:
|
def test_single_actor_pronoun_still_resolves(self) -> None:
|
||||||
|
|
|
||||||
|
|
@ -192,12 +192,18 @@ class TestPronounMultiActorDefenseOnCompound:
|
||||||
path output too. Tracked in
|
path output too. Tracked in
|
||||||
project-adr-0174-multi-actor-pronoun-hazard memory.
|
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
|
from generate.math_candidate_graph import parse_and_solve
|
||||||
text = (
|
text = (
|
||||||
"Alice has 5 followers. "
|
"Alice has 5 followers. "
|
||||||
"Bob has 3 followers. "
|
"Mary has 3 followers. "
|
||||||
"He has 2 followers on Instagram and 4 followers on Facebook. "
|
"She has 2 followers on Instagram and 4 followers on Facebook. "
|
||||||
"How many followers does Bob have?"
|
"How many followers does Alice have?"
|
||||||
)
|
)
|
||||||
r = parse_and_solve(text)
|
r = parse_and_solve(text)
|
||||||
lookback = [
|
lookback = [
|
||||||
|
|
|
||||||
387
tests/test_adr_0174_phase4_contemplate.py
Normal file
387
tests/test_adr_0174_phase4_contemplate.py
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue