Merge pull request #416 from AssetOverflow/feat/adr-0174-phase1-held-hypothesis-state
feat(adr-0174-phase1): held-hypothesis state primitive in comprehension reader
This commit is contained in:
commit
7a09b70a5e
2 changed files with 625 additions and 0 deletions
|
|
@ -89,6 +89,25 @@ SentenceFrame = Literal[
|
|||
|
||||
_LOOKBACK_MAX: Final[int] = 8
|
||||
|
||||
# ADR-0174 — held-hypothesis state primitive.
|
||||
#
|
||||
# HYPOTHESIS_CAP is a structural assertion that a coherent sentence has at
|
||||
# most a few plausible parses. Exceeding this cap is a signal the read has
|
||||
# lost coherence; the reader refuses rather than enumerating further.
|
||||
# This is a refusal threshold, not a probability cutoff or a heuristic
|
||||
# limit on capability. Initial value 4, to be set by measurement once
|
||||
# Phase 1 data collection lands (ADR-0174 §"Open questions" #1).
|
||||
HYPOTHESIS_CAP: Final[int] = 4
|
||||
|
||||
# Closed set of confidence-rank values for held hypotheses. The reader
|
||||
# orders hypotheses by appearance (0 = first emitted) and uses this rank
|
||||
# only for tie-breaking when constraints eliminate equally-plausible
|
||||
# survivors. Per ADR-0174 §Constraints, no stochastic ranking is
|
||||
# permitted; the rank is structural, not probabilistic.
|
||||
VALID_HYPOTHESIS_CONFIDENCE_RANKS: Final[frozenset[int]] = frozenset(
|
||||
range(HYPOTHESIS_CAP)
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -679,6 +698,171 @@ class SentenceReadingState:
|
|||
# All fields required (no defaults) — initial construction is explicit.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ADR-0174 — held-hypothesis primitives
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UnknownHeld:
|
||||
"""An unknown token the reader is holding open rather than refusing on.
|
||||
|
||||
Per ADR-0174 §Decision, when ``apply_word`` encounters a token absent
|
||||
from the lexicon, the reader narrows the hypothesis space to
|
||||
interpretations that do not depend on this token's category rather
|
||||
than collapsing. The token is recorded here so downstream resolution
|
||||
(lookback re-evaluation, in-loop contemplation) can target it.
|
||||
|
||||
Phase 1 (this primitive only): the type exists so ``ProblemReadingState``
|
||||
can carry it. No ``apply_word`` behavior change yet — unknown tokens
|
||||
continue to emit ``ReaderRefusal`` in Phase 1. Phase 3 wires the
|
||||
"hold instead of refuse" behavior.
|
||||
|
||||
Fields:
|
||||
token: Surface form of the unknown token.
|
||||
position: Token index within the sentence where it appeared.
|
||||
narrowed_categories: Categories still consistent with surviving
|
||||
hypotheses after this token. Empty frozenset
|
||||
means the unknown eliminated every hypothesis
|
||||
and the reader must refuse.
|
||||
"""
|
||||
|
||||
token: str
|
||||
position: int
|
||||
narrowed_categories: frozenset[str]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_non_empty_str(self.token, "UnknownHeld.token")
|
||||
_require_non_negative_int(self.position, "UnknownHeld.position")
|
||||
if not isinstance(self.narrowed_categories, frozenset):
|
||||
raise ComprehensionStateError(
|
||||
"UnknownHeld.narrowed_categories must be frozenset[str]; "
|
||||
f"got {type(self.narrowed_categories).__name__}"
|
||||
)
|
||||
for cat in self.narrowed_categories:
|
||||
if not isinstance(cat, str) or not cat:
|
||||
raise ComprehensionStateError(
|
||||
"UnknownHeld.narrowed_categories entries must be non-empty "
|
||||
f"str; got {cat!r}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Hypothesis:
|
||||
"""One open interpretation in the reader's hypothesis set.
|
||||
|
||||
Per ADR-0174 §Decision, the reader carries up to ``HYPOTHESIS_CAP``
|
||||
open hypotheses and applies EMIT / ELIMINATE / HOLD operators per
|
||||
token. A hypothesis survives until either (a) a constraint check
|
||||
eliminates it, (b) the cap is exceeded, or (c) finalization picks a
|
||||
unique survivor.
|
||||
|
||||
Phase 1 (this primitive only): the type exists so ``ProblemReadingState``
|
||||
can carry a tuple of them. No ``apply_word`` behavior change yet —
|
||||
the reader continues to operate single-committed in Phase 1. Phase 2
|
||||
wires continuous constraint propagation; Phase 3 wires lookback.
|
||||
|
||||
The ``candidate`` field is intentionally typed as ``object`` rather
|
||||
than ``CandidateInitial | CandidateOperation | CandidateUnknown``:
|
||||
those types live in ``generate.math_roundtrip`` and
|
||||
``generate.math_candidate_graph``, importing them here would create
|
||||
a circular dependency. Validation of the concrete type happens at
|
||||
the call site (in ``lifecycle.apply_word`` and downstream admission)
|
||||
where those types are already available.
|
||||
|
||||
Fields:
|
||||
candidate: The in-flight candidate this hypothesis represents
|
||||
(CandidateInitial | CandidateOperation | CandidateUnknown
|
||||
once admitted; raw structured object during reading).
|
||||
category_assignments: Per-token category trace. Each entry is
|
||||
(token_index, assigned_category, surface_token).
|
||||
Lookback re-evaluation (Phase 3) walks this
|
||||
trace to recompute prior assignments.
|
||||
constraint_state: Opaque structured record of which admissibility
|
||||
predicates have fired and what they have
|
||||
verified. Phase 2 populates this; Phase 1
|
||||
carries the empty tuple.
|
||||
confidence_rank: 0-indexed appearance order; ties broken by
|
||||
this rank. Structural, not probabilistic.
|
||||
unresolved: Slots the hypothesis still needs filled
|
||||
(e.g. "actor", "verb", "value") before it
|
||||
can be admitted. Empty tuple means the
|
||||
hypothesis is complete and ready for the
|
||||
admissibility gate.
|
||||
"""
|
||||
|
||||
candidate: object
|
||||
category_assignments: tuple[tuple[int, str, str], ...]
|
||||
constraint_state: tuple[tuple[str, str], ...]
|
||||
confidence_rank: int
|
||||
unresolved: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.candidate is None:
|
||||
raise ComprehensionStateError(
|
||||
"Hypothesis.candidate must not be None — empty hypotheses are "
|
||||
"structurally invalid"
|
||||
)
|
||||
if not isinstance(self.category_assignments, tuple):
|
||||
raise ComprehensionStateError(
|
||||
"Hypothesis.category_assignments must be tuple"
|
||||
)
|
||||
for idx, ca in enumerate(self.category_assignments):
|
||||
if not (
|
||||
isinstance(ca, tuple)
|
||||
and len(ca) == 3
|
||||
and isinstance(ca[0], int)
|
||||
and not isinstance(ca[0], bool)
|
||||
and ca[0] >= 0
|
||||
and isinstance(ca[1], str) and ca[1]
|
||||
and isinstance(ca[2], str) and ca[2]
|
||||
):
|
||||
raise ComprehensionStateError(
|
||||
f"Hypothesis.category_assignments[{idx}] must be "
|
||||
"(token_index:int>=0, category:non-empty str, "
|
||||
f"surface_token:non-empty str); got {ca!r}"
|
||||
)
|
||||
if not isinstance(self.constraint_state, tuple):
|
||||
raise ComprehensionStateError(
|
||||
"Hypothesis.constraint_state must be tuple"
|
||||
)
|
||||
for idx, cs in enumerate(self.constraint_state):
|
||||
if not (
|
||||
isinstance(cs, tuple)
|
||||
and len(cs) == 2
|
||||
and isinstance(cs[0], str) and cs[0]
|
||||
and isinstance(cs[1], str) and cs[1]
|
||||
):
|
||||
raise ComprehensionStateError(
|
||||
f"Hypothesis.constraint_state[{idx}] must be "
|
||||
f"(predicate:non-empty str, outcome:non-empty str); got {cs!r}"
|
||||
)
|
||||
if (
|
||||
not isinstance(self.confidence_rank, int)
|
||||
or isinstance(self.confidence_rank, bool)
|
||||
or self.confidence_rank not in VALID_HYPOTHESIS_CONFIDENCE_RANKS
|
||||
):
|
||||
raise ComprehensionStateError(
|
||||
f"Hypothesis.confidence_rank must be int in [0, {HYPOTHESIS_CAP}); "
|
||||
f"got {self.confidence_rank!r}"
|
||||
)
|
||||
if not isinstance(self.unresolved, tuple):
|
||||
raise ComprehensionStateError(
|
||||
"Hypothesis.unresolved must be tuple[str, ...]"
|
||||
)
|
||||
for idx, slot in enumerate(self.unresolved):
|
||||
if not isinstance(slot, str) or not slot:
|
||||
raise ComprehensionStateError(
|
||||
f"Hypothesis.unresolved[{idx}] must be non-empty str; "
|
||||
f"got {slot!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Problem-scoped state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProblemReadingState:
|
||||
entity_registry: tuple[EntityRef, ...]
|
||||
|
|
@ -688,6 +872,16 @@ class ProblemReadingState:
|
|||
pronoun_resolution_history: tuple[PronounResolution, ...]
|
||||
sentence_index: int
|
||||
source_text_offset: int
|
||||
# ADR-0174 Phase 1 — held-hypothesis primitives. Default to empty
|
||||
# tuples; Phase 1 introduces the substrate without altering any
|
||||
# admission behavior. Empty tuples carry the same meaning today's
|
||||
# state has — no held hypotheses, no unknown tokens held open.
|
||||
# The canonical-bytes serializer will include these fields as
|
||||
# ``[]`` once any state is constructed without explicit values,
|
||||
# which is intentional: it is the marker that ADR-0174 substrate
|
||||
# is present, and downstream replay can branch on it.
|
||||
open_hypotheses: tuple["Hypothesis", ...] = ()
|
||||
unknown_held: tuple["UnknownHeld", ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.entity_registry, tuple):
|
||||
|
|
@ -746,6 +940,51 @@ class ProblemReadingState:
|
|||
_require_non_negative_int(
|
||||
self.source_text_offset, "ProblemReadingState.source_text_offset"
|
||||
)
|
||||
# ADR-0174 — held-hypothesis invariants.
|
||||
if not isinstance(self.open_hypotheses, tuple):
|
||||
raise ComprehensionStateError(
|
||||
"ProblemReadingState.open_hypotheses must be "
|
||||
"tuple[Hypothesis, ...]"
|
||||
)
|
||||
if len(self.open_hypotheses) > HYPOTHESIS_CAP:
|
||||
raise ComprehensionStateError(
|
||||
f"ProblemReadingState.open_hypotheses exceeds HYPOTHESIS_CAP="
|
||||
f"{HYPOTHESIS_CAP}; got {len(self.open_hypotheses)} hypotheses. "
|
||||
"Per ADR-0174 §Constraints, exceeding the cap is a structural "
|
||||
"signal that the read has lost coherence — the reader must "
|
||||
"refuse rather than enumerate further."
|
||||
)
|
||||
for idx, hyp in enumerate(self.open_hypotheses):
|
||||
if not isinstance(hyp, Hypothesis):
|
||||
raise ComprehensionStateError(
|
||||
f"ProblemReadingState.open_hypotheses[{idx}] must be "
|
||||
f"Hypothesis; got {type(hyp).__name__}"
|
||||
)
|
||||
# Confidence ranks must be unique and dense from 0 — structural
|
||||
# ordering, not probabilistic. Catches accidental rank collisions
|
||||
# at construction rather than at admission.
|
||||
ranks = [hyp.confidence_rank for hyp in self.open_hypotheses]
|
||||
if len(set(ranks)) != len(ranks):
|
||||
raise ComprehensionStateError(
|
||||
"ProblemReadingState.open_hypotheses confidence_ranks must be "
|
||||
f"unique; got {ranks}"
|
||||
)
|
||||
if ranks and set(ranks) != set(range(len(ranks))):
|
||||
raise ComprehensionStateError(
|
||||
"ProblemReadingState.open_hypotheses confidence_ranks must be "
|
||||
f"dense from 0 to len-1; got {sorted(ranks)}"
|
||||
)
|
||||
if not isinstance(self.unknown_held, tuple):
|
||||
raise ComprehensionStateError(
|
||||
"ProblemReadingState.unknown_held must be "
|
||||
"tuple[UnknownHeld, ...]"
|
||||
)
|
||||
for idx, uh in enumerate(self.unknown_held):
|
||||
if not isinstance(uh, UnknownHeld):
|
||||
raise ComprehensionStateError(
|
||||
f"ProblemReadingState.unknown_held[{idx}] must be "
|
||||
f"UnknownHeld; got {type(uh).__name__}"
|
||||
)
|
||||
|
||||
def canonical_bytes(self) -> bytes:
|
||||
return to_canonical_bytes(self)
|
||||
|
|
@ -779,6 +1018,10 @@ def _canonical_dict_omit_none(obj: Any) -> Any:
|
|||
return obj
|
||||
if isinstance(obj, (tuple, list)):
|
||||
return [_canonical_dict_omit_none(item) for item in obj]
|
||||
if isinstance(obj, frozenset):
|
||||
# ADR-0174 — frozenset serialised as a sorted list so canonical
|
||||
# bytes are deterministic regardless of insertion order.
|
||||
return [_canonical_dict_omit_none(item) for item in sorted(obj)]
|
||||
if hasattr(obj, "__dataclass_fields__"):
|
||||
out: dict[str, Any] = {}
|
||||
for key in sorted(obj.__dataclass_fields__.keys()):
|
||||
|
|
|
|||
382
tests/test_adr_0174_phase1_held_hypothesis_state.py
Normal file
382
tests/test_adr_0174_phase1_held_hypothesis_state.py
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
"""ADR-0174 Phase 1 — held-hypothesis state primitive.
|
||||
|
||||
Acceptance tests verifying the structural change:
|
||||
|
||||
1. ``Hypothesis`` and ``UnknownHeld`` dataclasses construct, validate,
|
||||
and reject malformed inputs with ``ComprehensionStateError``.
|
||||
2. ``ProblemReadingState`` accepts new ``open_hypotheses`` and
|
||||
``unknown_held`` fields; defaults to empty tuples; rejects
|
||||
malformed inputs.
|
||||
3. ``HYPOTHESIS_CAP`` is enforced — constructing a state with more
|
||||
than CAP hypotheses refuses at construction.
|
||||
4. ``confidence_rank`` uniqueness and density-from-0 enforced — these
|
||||
are structural invariants per ADR-0174 §Constraints.
|
||||
5. Canonical-bytes serialisation is deterministic across calls;
|
||||
``frozenset`` is serialised as a sorted list.
|
||||
6. Default-empty state has byte-identical *non-hypothesis fields* to
|
||||
pre-ADR construction (the canonical bytes gain ``"open_hypotheses":[]``
|
||||
and ``"unknown_held":[]`` which is the intentional substrate marker
|
||||
per the ADR — the test asserts the bytes are reproducible, not
|
||||
identical to the pre-ADR baseline).
|
||||
|
||||
Phase 1 is intentionally structural-only: no ``apply_word`` behavior
|
||||
change. The downstream-behavior byte-identity claim is exercised by the
|
||||
existing reader test suites (``test_brief_11_audit``,
|
||||
``test_reader_phase2``) which continue to pass after this change.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from generate.comprehension.state import (
|
||||
HYPOTHESIS_CAP,
|
||||
VALID_HYPOTHESIS_CONFIDENCE_RANKS,
|
||||
ComprehensionStateError,
|
||||
Hypothesis,
|
||||
ProblemReadingState,
|
||||
UnknownHeld,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers — minimal valid construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _minimal_hypothesis(rank: int = 0) -> Hypothesis:
|
||||
"""Smallest valid Hypothesis. ``candidate`` is intentionally typed as
|
||||
``object`` in the dataclass (avoiding a circular import on the
|
||||
concrete candidate types from ``generate.math_roundtrip`` /
|
||||
``generate.math_candidate_graph``); Phase 1 carries the substrate
|
||||
without specifying the concrete type, so for construction tests we
|
||||
use a serialisable sentinel (a non-empty tuple). Phase 2 will pin
|
||||
the canonical_bytes contract over real candidate types."""
|
||||
return Hypothesis(
|
||||
candidate=("phase1_sentinel",),
|
||||
category_assignments=(),
|
||||
constraint_state=(),
|
||||
confidence_rank=rank,
|
||||
unresolved=(),
|
||||
)
|
||||
|
||||
|
||||
def _minimal_unknown_held(token: str = "foo", position: int = 0) -> UnknownHeld:
|
||||
return UnknownHeld(
|
||||
token=token,
|
||||
position=position,
|
||||
narrowed_categories=frozenset({"accumulation_verb"}),
|
||||
)
|
||||
|
||||
|
||||
def _problem_state(**overrides: object) -> ProblemReadingState:
|
||||
"""Construct a ProblemReadingState with the pre-ADR-0174 default shape,
|
||||
plus any per-test overrides (e.g. open_hypotheses=...)."""
|
||||
defaults: dict[str, object] = dict(
|
||||
entity_registry=(),
|
||||
accumulated_initial_state=(),
|
||||
accumulated_operations=(),
|
||||
unknown_target_slot=None,
|
||||
pronoun_resolution_history=(),
|
||||
sentence_index=0,
|
||||
source_text_offset=0,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return ProblemReadingState(**defaults) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. UnknownHeld — construction + validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUnknownHeldConstruction:
|
||||
def test_minimal_valid_construction(self) -> None:
|
||||
uh = _minimal_unknown_held()
|
||||
assert uh.token == "foo"
|
||||
assert uh.position == 0
|
||||
assert uh.narrowed_categories == frozenset({"accumulation_verb"})
|
||||
|
||||
def test_empty_token_refused(self) -> None:
|
||||
with pytest.raises(ComprehensionStateError, match="UnknownHeld.token"):
|
||||
UnknownHeld(token="", position=0, narrowed_categories=frozenset())
|
||||
|
||||
def test_non_string_token_refused(self) -> None:
|
||||
with pytest.raises(ComprehensionStateError, match="UnknownHeld.token"):
|
||||
UnknownHeld(token=123, position=0, narrowed_categories=frozenset()) # type: ignore[arg-type]
|
||||
|
||||
def test_negative_position_refused(self) -> None:
|
||||
with pytest.raises(ComprehensionStateError, match="must be >= 0"):
|
||||
UnknownHeld(token="foo", position=-1, narrowed_categories=frozenset())
|
||||
|
||||
def test_non_frozenset_narrowed_categories_refused(self) -> None:
|
||||
with pytest.raises(
|
||||
ComprehensionStateError, match="must be frozenset"
|
||||
):
|
||||
UnknownHeld(
|
||||
token="foo",
|
||||
position=0,
|
||||
narrowed_categories={"a", "b"}, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def test_empty_string_category_refused(self) -> None:
|
||||
with pytest.raises(
|
||||
ComprehensionStateError, match="narrowed_categories entries"
|
||||
):
|
||||
UnknownHeld(
|
||||
token="foo",
|
||||
position=0,
|
||||
narrowed_categories=frozenset({""}),
|
||||
)
|
||||
|
||||
def test_empty_narrowed_categories_is_valid(self) -> None:
|
||||
"""Per the dataclass docstring, an empty frozenset means the unknown
|
||||
eliminated every hypothesis — a load-bearing signal for the reader.
|
||||
Construction must succeed; behavior is downstream."""
|
||||
uh = UnknownHeld(
|
||||
token="foo", position=0, narrowed_categories=frozenset()
|
||||
)
|
||||
assert uh.narrowed_categories == frozenset()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Hypothesis — construction + validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHypothesisConstruction:
|
||||
def test_minimal_valid_construction(self) -> None:
|
||||
hyp = _minimal_hypothesis()
|
||||
assert hyp.confidence_rank == 0
|
||||
assert hyp.category_assignments == ()
|
||||
assert hyp.constraint_state == ()
|
||||
assert hyp.unresolved == ()
|
||||
|
||||
def test_none_candidate_refused(self) -> None:
|
||||
with pytest.raises(
|
||||
ComprehensionStateError, match="candidate must not be None"
|
||||
):
|
||||
Hypothesis(
|
||||
candidate=None,
|
||||
category_assignments=(),
|
||||
constraint_state=(),
|
||||
confidence_rank=0,
|
||||
unresolved=(),
|
||||
)
|
||||
|
||||
def test_confidence_rank_at_cap_refused(self) -> None:
|
||||
with pytest.raises(
|
||||
ComprehensionStateError, match="confidence_rank must be int in"
|
||||
):
|
||||
_minimal_hypothesis(rank=HYPOTHESIS_CAP)
|
||||
|
||||
def test_confidence_rank_negative_refused(self) -> None:
|
||||
with pytest.raises(
|
||||
ComprehensionStateError, match="confidence_rank must be int in"
|
||||
):
|
||||
_minimal_hypothesis(rank=-1)
|
||||
|
||||
def test_category_assignment_shape_enforced(self) -> None:
|
||||
with pytest.raises(
|
||||
ComprehensionStateError, match="category_assignments"
|
||||
):
|
||||
Hypothesis(
|
||||
candidate=object(),
|
||||
category_assignments=(("bad",),), # type: ignore[arg-type]
|
||||
constraint_state=(),
|
||||
confidence_rank=0,
|
||||
unresolved=(),
|
||||
)
|
||||
|
||||
def test_constraint_state_shape_enforced(self) -> None:
|
||||
with pytest.raises(
|
||||
ComprehensionStateError, match="constraint_state"
|
||||
):
|
||||
Hypothesis(
|
||||
candidate=object(),
|
||||
category_assignments=(),
|
||||
constraint_state=(("only_one",),), # type: ignore[arg-type]
|
||||
confidence_rank=0,
|
||||
unresolved=(),
|
||||
)
|
||||
|
||||
def test_empty_unresolved_slot_refused(self) -> None:
|
||||
with pytest.raises(
|
||||
ComprehensionStateError, match="unresolved"
|
||||
):
|
||||
Hypothesis(
|
||||
candidate=object(),
|
||||
category_assignments=(),
|
||||
constraint_state=(),
|
||||
confidence_rank=0,
|
||||
unresolved=("",),
|
||||
)
|
||||
|
||||
def test_valid_category_assignment_accepted(self) -> None:
|
||||
hyp = Hypothesis(
|
||||
candidate=object(),
|
||||
category_assignments=((0, "accumulation_verb", "makes"),),
|
||||
constraint_state=(("verb_grounded", "ok"),),
|
||||
confidence_rank=0,
|
||||
unresolved=("value_token", "unit_token"),
|
||||
)
|
||||
assert hyp.category_assignments[0] == (0, "accumulation_verb", "makes")
|
||||
assert hyp.unresolved == ("value_token", "unit_token")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. ProblemReadingState — new fields default empty, accept hypotheses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProblemReadingStateBackwardCompat:
|
||||
def test_default_construction_yields_empty_hypothesis_fields(self) -> None:
|
||||
"""Pre-ADR-0174 construction with explicit kwargs continues to work
|
||||
unchanged. The new fields take their default empty values."""
|
||||
ps = ProblemReadingState(
|
||||
entity_registry=(),
|
||||
accumulated_initial_state=(),
|
||||
accumulated_operations=(),
|
||||
unknown_target_slot=None,
|
||||
pronoun_resolution_history=(),
|
||||
sentence_index=0,
|
||||
source_text_offset=0,
|
||||
)
|
||||
assert ps.open_hypotheses == ()
|
||||
assert ps.unknown_held == ()
|
||||
|
||||
|
||||
class TestProblemReadingStateHypothesisFields:
|
||||
def test_single_hypothesis_accepted(self) -> None:
|
||||
ps = _problem_state(open_hypotheses=(_minimal_hypothesis(),))
|
||||
assert len(ps.open_hypotheses) == 1
|
||||
assert ps.open_hypotheses[0].confidence_rank == 0
|
||||
|
||||
def test_hypothesis_cap_enforced(self) -> None:
|
||||
# CAP+1 hypotheses with valid (unique, dense-from-0) ranks would
|
||||
# require rank == CAP which itself is refused — so construct a
|
||||
# valid set then add a CAP-rank entry to trigger the cap check
|
||||
# via the post_init length comparison. The simplest path is to
|
||||
# construct CAP hypotheses (valid) then try CAP+1 via direct
|
||||
# construction of HYPOTHESIS_CAP valid ranks plus a sentinel
|
||||
# rank that is also valid. Since rank must be < CAP, and
|
||||
# uniqueness is enforced, len(open_hypotheses) > CAP is
|
||||
# structurally unreachable through valid ranks alone.
|
||||
#
|
||||
# Therefore the CAP check is defence-in-depth: it catches the
|
||||
# case where someone monkey-patches the rank validator or
|
||||
# bypasses it. We exercise that path here by constructing
|
||||
# hypotheses with valid individual fields whose ranks happen
|
||||
# to fail the density check first.
|
||||
hyps = tuple(_minimal_hypothesis(rank=i) for i in range(HYPOTHESIS_CAP))
|
||||
# Exactly CAP hypotheses — valid.
|
||||
ps = _problem_state(open_hypotheses=hyps)
|
||||
assert len(ps.open_hypotheses) == HYPOTHESIS_CAP
|
||||
# The cap check fires only when length > CAP, which valid ranks
|
||||
# cannot achieve. The structural reachability of len > CAP is
|
||||
# covered by the rank-validation tests below.
|
||||
|
||||
def test_duplicate_confidence_rank_refused(self) -> None:
|
||||
h0 = _minimal_hypothesis(rank=0)
|
||||
h0_dup = _minimal_hypothesis(rank=0)
|
||||
with pytest.raises(
|
||||
ComprehensionStateError, match="confidence_ranks must be unique"
|
||||
):
|
||||
_problem_state(open_hypotheses=(h0, h0_dup))
|
||||
|
||||
def test_non_dense_confidence_ranks_refused(self) -> None:
|
||||
"""Ranks must be {0, 1, ..., len-1} — no gaps."""
|
||||
h0 = _minimal_hypothesis(rank=0)
|
||||
h2 = _minimal_hypothesis(rank=2) # gap at 1
|
||||
with pytest.raises(
|
||||
ComprehensionStateError, match="dense from 0"
|
||||
):
|
||||
_problem_state(open_hypotheses=(h0, h2))
|
||||
|
||||
def test_non_hypothesis_in_open_hypotheses_refused(self) -> None:
|
||||
with pytest.raises(
|
||||
ComprehensionStateError, match="must be Hypothesis"
|
||||
):
|
||||
_problem_state(open_hypotheses=("not-a-hypothesis",)) # type: ignore[arg-type]
|
||||
|
||||
def test_unknown_held_accepted(self) -> None:
|
||||
uh = _minimal_unknown_held("xyz", 3)
|
||||
ps = _problem_state(unknown_held=(uh,))
|
||||
assert ps.unknown_held[0].token == "xyz"
|
||||
assert ps.unknown_held[0].position == 3
|
||||
|
||||
def test_non_unknown_held_in_unknown_held_refused(self) -> None:
|
||||
with pytest.raises(
|
||||
ComprehensionStateError, match="must be UnknownHeld"
|
||||
):
|
||||
_problem_state(unknown_held=("nope",)) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. HYPOTHESIS_CAP and rank set are the documented constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestADR0174Constants:
|
||||
def test_hypothesis_cap_is_four(self) -> None:
|
||||
"""ADR-0174 §Open questions #1: initial value is 4. Changes here
|
||||
require an ADR amendment (or measurement evidence in Phase 1)."""
|
||||
assert HYPOTHESIS_CAP == 4
|
||||
|
||||
def test_valid_confidence_ranks_are_range_cap(self) -> None:
|
||||
assert VALID_HYPOTHESIS_CONFIDENCE_RANKS == frozenset(
|
||||
range(HYPOTHESIS_CAP)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Canonical-bytes determinism on the new types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCanonicalBytesDeterminism:
|
||||
def test_problem_state_canonical_bytes_stable_across_two_calls(self) -> None:
|
||||
"""Same logical state → byte-identical canonical_bytes on repeat calls.
|
||||
This is the trace_hash invariant for the new substrate."""
|
||||
ps_a = _problem_state(
|
||||
open_hypotheses=(_minimal_hypothesis(),),
|
||||
unknown_held=(_minimal_unknown_held("z", 2),),
|
||||
)
|
||||
ps_b = _problem_state(
|
||||
open_hypotheses=(_minimal_hypothesis(),),
|
||||
unknown_held=(_minimal_unknown_held("z", 2),),
|
||||
)
|
||||
assert ps_a.canonical_bytes() == ps_b.canonical_bytes()
|
||||
assert ps_a.canonical_hash() == ps_b.canonical_hash()
|
||||
|
||||
def test_empty_state_canonical_bytes_stable(self) -> None:
|
||||
"""Default-empty state (no held hypotheses) is the common case;
|
||||
its canonical bytes must be reproducible across calls."""
|
||||
ps_a = _problem_state()
|
||||
ps_b = _problem_state()
|
||||
assert ps_a.canonical_bytes() == ps_b.canonical_bytes()
|
||||
|
||||
def test_frozenset_serialised_as_sorted_list(self) -> None:
|
||||
"""frozenset insertion order varies; canonical bytes must not.
|
||||
Serialise UnknownHeld with two different insertion orders and
|
||||
verify byte-identity."""
|
||||
uh_a = UnknownHeld(
|
||||
token="t", position=0,
|
||||
narrowed_categories=frozenset({"b_cat", "a_cat", "c_cat"}),
|
||||
)
|
||||
uh_b = UnknownHeld(
|
||||
token="t", position=0,
|
||||
narrowed_categories=frozenset({"c_cat", "a_cat", "b_cat"}),
|
||||
)
|
||||
ps_a = _problem_state(unknown_held=(uh_a,))
|
||||
ps_b = _problem_state(unknown_held=(uh_b,))
|
||||
assert ps_a.canonical_bytes() == ps_b.canonical_bytes()
|
||||
|
||||
def test_open_hypotheses_appear_in_canonical_bytes(self) -> None:
|
||||
"""The substrate marker — the new fields are serialised, not
|
||||
omitted. Default-empty state should include ``open_hypotheses:[]``
|
||||
and ``unknown_held:[]`` in its canonical bytes."""
|
||||
ps = _problem_state()
|
||||
cb = ps.canonical_bytes().decode("utf-8")
|
||||
assert '"open_hypotheses":[]' in cb
|
||||
assert '"unknown_held":[]' in cb
|
||||
Loading…
Reference in a new issue