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))
|
||||
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=<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:
|
||||
_refinement = PronounResolution(
|
||||
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
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
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