"""ADR-0164 / ADR-0164.3 — two-level immutable comprehension-state types. This module defines the typed state containers the incremental comprehension reader accumulates. It is intentionally pure data: frozen dataclasses, refusal-first validation, and canonical-bytes serialisation for deterministic replay and trace hashing. Two levels (ADR-0164.3 §Decision): - ``ProblemReadingState`` — outer, problem-scoped. Persists across sentence boundaries. Mutated only by ``end_sentence``. - ``SentenceReadingState`` — inner, sentence-scoped. Lifetime = one sentence. Created by ``begin_sentence``, mutated by ``apply_word``. ``ComprehensionState`` is a backward-compatibility alias for ``SentenceReadingState``; existing importers need not change. """ from __future__ import annotations import hashlib import json from dataclasses import dataclass from decimal import Decimal from typing import Any, Final, Literal # --------------------------------------------------------------------------- # Closed-set constants # --------------------------------------------------------------------------- VALID_GENDERS: Final[frozenset[str]] = frozenset( {"female", "male", "neuter", "unknown"} ) VALID_QUESTION_KINDS: Final[frozenset[str]] = frozenset( {"continuous_quantity", "discrete_quantity", "difference", "aggregate"} ) VALID_EXPECTATION_KINDS: Final[frozenset[str]] = frozenset( { "accumulation_verb", "depletion_verb", "transfer_verb", "residual_modifier", "aggregate_modifier", "state_continuation_verb", "unit_noun", "entity", "quantity", } ) VALID_SENTENCE_FRAME_KINDS: Final[frozenset[str]] = frozenset( { "initial_state_frame", "operation_frame", "question_frame", "descriptive_frame", } ) # ADR-0164.3 §ReaderRefusal — closed, ADR-tracked. # New reasons require an ADR amendment. READER_REFUSAL_REASONS: Final[frozenset[str]] = frozenset( { # apply_word — token-level "unknown_word", "unexpected_category", "expectation_collision", "unresolved_pronoun", "ambiguous_pronoun_referent", # end_sentence — sentence-level "unfinished_frame", "unattached_quantity", "incomplete_operation", # finalization predicate — problem-level "no_question_target", "dangling_entity", "graph_construction_failure", } ) # SentenceFrame is a Literal over the four discriminator values. SentenceFrame = Literal[ "initial_state_frame", "operation_frame", "question_frame", "descriptive_frame", ] _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 (or, for compound-clause sentences per Phase # 3b, at most a few enumerated anchors). 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. # # Raised from 4 to 8 in ADR-0174 Phase 3b: case 0040 ("He now has 2 horses, # 5 dogs, 7 cats, 3 turtles, and 1 goat") emits 5 anchors via compound- # clause held hypotheses. 8 gives headroom (e.g. comma-separated list of # 7 items) without becoming a permissive cap. HYPOTHESIS_CAP: Final[int] = 8 # 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 # --------------------------------------------------------------------------- class ComprehensionStateError(ValueError): """Raised on invalid comprehension-state construction.""" # --------------------------------------------------------------------------- # Internal validators (unchanged from #321) # --------------------------------------------------------------------------- def _require_non_empty_str(value: object, field_name: str) -> None: if not isinstance(value, str) or value == "": raise ComprehensionStateError( f"{field_name} must be a non-empty str; got {value!r}" ) def _require_optional_str(value: object, field_name: str) -> None: if value is not None and (not isinstance(value, str) or value == ""): raise ComprehensionStateError( f"{field_name} must be None or a non-empty str; got {value!r}" ) def _require_int(value: object, field_name: str) -> None: if not isinstance(value, int) or isinstance(value, bool): raise ComprehensionStateError( f"{field_name} must be int; got {type(value).__name__}" ) def _require_non_negative_int(value: object, field_name: str) -> None: _require_int(value, field_name) if value < 0: # type: ignore[operator] raise ComprehensionStateError(f"{field_name} must be >= 0; got {value}") def _require_decimal(value: object, field_name: str) -> None: if not isinstance(value, Decimal): raise ComprehensionStateError( f"{field_name} must be Decimal; got {type(value).__name__}" ) if not value.is_finite(): raise ComprehensionStateError( f"{field_name} must be finite; got {value!r}" ) def _canonical_decimal(value: Decimal) -> str: normalized = value.normalize() if normalized == normalized.to_integral(): return format(normalized.quantize(Decimal("1")), "f") return format(normalized, "f") # --------------------------------------------------------------------------- # Shared leaf types (unchanged from #321) # --------------------------------------------------------------------------- @dataclass(frozen=True, slots=True) class EntityRef: canonical_name: str gender: Literal["female", "male", "neuter", "unknown"] first_mention_position: int def __post_init__(self) -> None: _require_non_empty_str(self.canonical_name, "EntityRef.canonical_name") if self.gender not in VALID_GENDERS: raise ComprehensionStateError( "EntityRef.gender must be one of " f"{sorted(VALID_GENDERS)}; got {self.gender!r}" ) _require_non_negative_int( self.first_mention_position, "EntityRef.first_mention_position" ) def as_canonical(self) -> dict[str, Any]: return { "canonical_name": self.canonical_name, "first_mention_position": self.first_mention_position, "gender": self.gender, } @dataclass(frozen=True, slots=True) class QuantityRef: value: Decimal unit: str | None unit_class: str | None owner_entity: str | None mention_position: int def __post_init__(self) -> None: _require_decimal(self.value, "QuantityRef.value") _require_optional_str(self.unit, "QuantityRef.unit") _require_optional_str(self.unit_class, "QuantityRef.unit_class") _require_optional_str(self.owner_entity, "QuantityRef.owner_entity") _require_non_negative_int( self.mention_position, "QuantityRef.mention_position" ) if self.unit is None and self.unit_class is None: raise ComprehensionStateError( "QuantityRef.unit and QuantityRef.unit_class cannot both be None" ) def as_canonical(self) -> dict[str, Any]: return { "mention_position": self.mention_position, "owner_entity": self.owner_entity, "unit": self.unit, "unit_class": self.unit_class, "value": _canonical_decimal(self.value), } @dataclass(frozen=True, slots=True) class PartialOp: operator_kind: str subject_entity: str | None object_entity: str | None quantity_index: int | None position: int def __post_init__(self) -> None: _require_non_empty_str(self.operator_kind, "PartialOp.operator_kind") _require_optional_str(self.subject_entity, "PartialOp.subject_entity") _require_optional_str(self.object_entity, "PartialOp.object_entity") if self.quantity_index is not None: _require_non_negative_int(self.quantity_index, "PartialOp.quantity_index") _require_non_negative_int(self.position, "PartialOp.position") def as_canonical(self) -> dict[str, Any]: return { "object_entity": self.object_entity, "operator_kind": self.operator_kind, "position": self.position, "quantity_index": self.quantity_index, "subject_entity": self.subject_entity, } @dataclass(frozen=True, slots=True) class QuestionTargetSlot: kind: Literal[ "continuous_quantity", "discrete_quantity", "difference", "aggregate", ] entity: str | None unit_class: str | None position: int unit: str | None = None def __post_init__(self) -> None: if self.kind not in VALID_QUESTION_KINDS: raise ComprehensionStateError( "QuestionTargetSlot.kind must be one of " f"{sorted(VALID_QUESTION_KINDS)}; got {self.kind!r}" ) _require_optional_str(self.entity, "QuestionTargetSlot.entity") _require_optional_str(self.unit_class, "QuestionTargetSlot.unit_class") _require_non_negative_int(self.position, "QuestionTargetSlot.position") _require_optional_str(self.unit, "QuestionTargetSlot.unit") def as_canonical(self) -> dict[str, Any]: d: dict[str, Any] = { "entity": self.entity, "kind": self.kind, "position": self.position, "unit_class": self.unit_class, } if self.unit is not None: d["unit"] = self.unit return d @dataclass(frozen=True, slots=True) class ExpectationFrame: allowed_categories: tuple[str, ...] reason: str def __post_init__(self) -> None: if not isinstance(self.allowed_categories, tuple): raise ComprehensionStateError( "ExpectationFrame.allowed_categories must be tuple[str, ...]" ) if not self.allowed_categories: raise ComprehensionStateError( "ExpectationFrame.allowed_categories must not be empty" ) for idx, category in enumerate(self.allowed_categories): if category not in VALID_EXPECTATION_KINDS: raise ComprehensionStateError( "ExpectationFrame.allowed_categories must contain only " f"{sorted(VALID_EXPECTATION_KINDS)}; got " f"{category!r} at index {idx}" ) _require_non_empty_str(self.reason, "ExpectationFrame.reason") def as_canonical(self) -> dict[str, Any]: return { "allowed_categories": list(self.allowed_categories), "reason": self.reason, } # --------------------------------------------------------------------------- # New leaf types for SentenceReadingState (ADR-0164.3) # --------------------------------------------------------------------------- @dataclass(frozen=True, slots=True) class VerbReference: """The verb captured at frame-determining position, awaiting completion.""" surface: str kind: str position: int def __post_init__(self) -> None: _require_non_empty_str(self.surface, "VerbReference.surface") _require_non_empty_str(self.kind, "VerbReference.kind") _require_non_negative_int(self.position, "VerbReference.position") def as_canonical(self) -> dict[str, Any]: return { "kind": self.kind, "position": self.position, "surface": self.surface, } @dataclass(frozen=True, slots=True) class AppliedCategory: """One entry in the lookback window: a category applied at a position.""" category: str position: int def __post_init__(self) -> None: _require_non_empty_str(self.category, "AppliedCategory.category") _require_non_negative_int(self.position, "AppliedCategory.position") def as_canonical(self) -> dict[str, Any]: return {"category": self.category, "position": self.position} @dataclass(frozen=True, slots=True) class FramePayload: """Stub container for the in-construction frame payload. The reader (Brief 5 Phase 1) populates sub-fields specific to each frame kind. This stub carries only the frame_kind discriminator so the two-level state model can be typed and tested without coupling to the reader implementation. """ frame_kind: str def __post_init__(self) -> None: if self.frame_kind not in VALID_SENTENCE_FRAME_KINDS: raise ComprehensionStateError( "FramePayload.frame_kind must be one of " f"{sorted(VALID_SENTENCE_FRAME_KINDS)}; got {self.frame_kind!r}" ) def as_canonical(self) -> dict[str, Any]: return {"frame_kind": self.frame_kind} # --------------------------------------------------------------------------- # New leaf types for ProblemReadingState (ADR-0164.3) # --------------------------------------------------------------------------- @dataclass(frozen=True, slots=True) class PartialInitialPossession: """Precursor to ADR-0115 InitialPossession during reader construction. Every field is nullable: the reader builds this incrementally as tokens arrive. A fully-specified instance (no None fields) projects to a strict ``InitialPossession`` at ``end_sentence``. """ entity: str | None quantity: QuantityRef | None def __post_init__(self) -> None: if self.entity is not None: _require_non_empty_str(self.entity, "PartialInitialPossession.entity") if self.quantity is not None and not isinstance(self.quantity, QuantityRef): raise ComprehensionStateError( "PartialInitialPossession.quantity must be QuantityRef | None; " f"got {type(self.quantity).__name__}" ) def as_canonical(self) -> dict[str, Any]: d: dict[str, Any] = {} if self.entity is not None: d["entity"] = self.entity if self.quantity is not None: d["quantity"] = self.quantity.as_canonical() return d @dataclass(frozen=True, slots=True) class PartialOperation: """Precursor to ADR-0115 Operation during reader construction. Every field is nullable: the reader builds this incrementally as tokens arrive. A fully-specified instance projects to a strict ``Operation`` at ``end_sentence``. """ actor: str | None kind: str | None operand: QuantityRef | None target: str | None def __post_init__(self) -> None: if self.actor is not None: _require_non_empty_str(self.actor, "PartialOperation.actor") if self.kind is not None: _require_non_empty_str(self.kind, "PartialOperation.kind") if self.target is not None: _require_non_empty_str(self.target, "PartialOperation.target") if self.operand is not None and not isinstance(self.operand, QuantityRef): raise ComprehensionStateError( "PartialOperation.operand must be QuantityRef | None; " f"got {type(self.operand).__name__}" ) def as_canonical(self) -> dict[str, Any]: d: dict[str, Any] = {} if self.actor is not None: d["actor"] = self.actor if self.kind is not None: d["kind"] = self.kind if self.operand is not None: d["operand"] = self.operand.as_canonical() if self.target is not None: d["target"] = self.target return d @dataclass(frozen=True, slots=True) class PronounResolution: """Replay-deterministic record of one pronoun resolution event. Per ADR-0164.2. Appended to ``ProblemReadingState.pronoun_resolution_history`` only when the containing sentence closes successfully. """ pronoun: str resolved_to: str at_sentence: int at_position: int def __post_init__(self) -> None: _require_non_empty_str(self.pronoun, "PronounResolution.pronoun") _require_non_empty_str(self.resolved_to, "PronounResolution.resolved_to") _require_non_negative_int(self.at_sentence, "PronounResolution.at_sentence") _require_non_negative_int(self.at_position, "PronounResolution.at_position") def as_canonical(self) -> dict[str, Any]: return { "at_position": self.at_position, "at_sentence": self.at_sentence, "pronoun": self.pronoun, "resolved_to": self.resolved_to, } # --------------------------------------------------------------------------- # ReaderRefusal (ADR-0164.3 §ReaderRefusal) # --------------------------------------------------------------------------- @dataclass(frozen=True, slots=True) class ReaderRefusal: """Typed refusal record. Carries one of the closed READER_REFUSAL_REASONS. ``token_text`` may be empty string for sentence-level or problem-level refusals where no single token is in question. """ reason: str detail: str sentence_index: int token_index: int token_text: str def __post_init__(self) -> None: if self.reason not in READER_REFUSAL_REASONS: raise ComprehensionStateError( "ReaderRefusal.reason must be a member of READER_REFUSAL_REASONS; " f"got {self.reason!r}" ) _require_non_empty_str(self.detail, "ReaderRefusal.detail") _require_non_negative_int(self.sentence_index, "ReaderRefusal.sentence_index") _require_non_negative_int(self.token_index, "ReaderRefusal.token_index") if not isinstance(self.token_text, str): raise ComprehensionStateError( "ReaderRefusal.token_text must be str; " f"got {type(self.token_text).__name__}" ) def as_canonical(self) -> dict[str, Any]: return { "detail": self.detail, "reason": self.reason, "sentence_index": self.sentence_index, "token_index": self.token_index, "token_text": self.token_text, } def canonical_bytes(self) -> bytes: return to_canonical_bytes(self) def canonical_hash(self) -> str: return hashlib.sha256(self.canonical_bytes()).hexdigest() # --------------------------------------------------------------------------- # SentenceReadingState (inner, sentence-scoped) — ADR-0164.3 §Decision # Renamed from ComprehensionState (#321). Original five fields stay verbatim. # Seven new fields added (all with defaults) per ADR-0164.3 §SentenceReadingState. # --------------------------------------------------------------------------- @dataclass(frozen=True, slots=True) class SentenceReadingState: # --- original five fields (verbatim from #321) --- entities: tuple[EntityRef, ...] quantities: tuple[QuantityRef, ...] operations: tuple[PartialOp, ...] question_target: QuestionTargetSlot | None = None expectation: ExpectationFrame | None = None # --- ADR-0164.3 §SentenceReadingState new fields --- frame: SentenceFrame | None = None pending_quantities: tuple[QuantityRef, ...] = () pending_entity_ref: EntityRef | None = None pending_verb: VerbReference | None = None token_index: int = 0 lookback: tuple[AppliedCategory, ...] = () partial_frame_payload: FramePayload | None = None def __post_init__(self) -> None: # --- validate original fields --- if not isinstance(self.entities, tuple): raise ComprehensionStateError( "SentenceReadingState.entities must be tuple[EntityRef, ...]" ) if not isinstance(self.quantities, tuple): raise ComprehensionStateError( "SentenceReadingState.quantities must be tuple[QuantityRef, ...]" ) if not isinstance(self.operations, tuple): raise ComprehensionStateError( "SentenceReadingState.operations must be tuple[PartialOp, ...]" ) for idx, entity in enumerate(self.entities): if not isinstance(entity, EntityRef): raise ComprehensionStateError( f"SentenceReadingState.entities[{idx}] must be EntityRef; " f"got {type(entity).__name__}" ) for idx, quantity in enumerate(self.quantities): if not isinstance(quantity, QuantityRef): raise ComprehensionStateError( f"SentenceReadingState.quantities[{idx}] must be QuantityRef; " f"got {type(quantity).__name__}" ) for idx, operation in enumerate(self.operations): if not isinstance(operation, PartialOp): raise ComprehensionStateError( f"SentenceReadingState.operations[{idx}] must be PartialOp; " f"got {type(operation).__name__}" ) if self.question_target is not None and not isinstance( self.question_target, QuestionTargetSlot ): raise ComprehensionStateError( "SentenceReadingState.question_target must be " f"QuestionTargetSlot | None; got {type(self.question_target).__name__}" ) if self.expectation is not None and not isinstance( self.expectation, ExpectationFrame ): raise ComprehensionStateError( "SentenceReadingState.expectation must be " f"ExpectationFrame | None; got {type(self.expectation).__name__}" ) # --- validate new fields --- if self.frame is not None and self.frame not in VALID_SENTENCE_FRAME_KINDS: raise ComprehensionStateError( "SentenceReadingState.frame must be a SentenceFrame literal or None; " f"got {self.frame!r}" ) if not isinstance(self.pending_quantities, tuple): raise ComprehensionStateError( "SentenceReadingState.pending_quantities must be tuple[QuantityRef, ...]" ) for idx, pq in enumerate(self.pending_quantities): if not isinstance(pq, QuantityRef): raise ComprehensionStateError( f"SentenceReadingState.pending_quantities[{idx}] must be " f"QuantityRef; got {type(pq).__name__}" ) if self.pending_entity_ref is not None and not isinstance( self.pending_entity_ref, EntityRef ): raise ComprehensionStateError( "SentenceReadingState.pending_entity_ref must be EntityRef | None; " f"got {type(self.pending_entity_ref).__name__}" ) if self.pending_verb is not None and not isinstance( self.pending_verb, VerbReference ): raise ComprehensionStateError( "SentenceReadingState.pending_verb must be VerbReference | None; " f"got {type(self.pending_verb).__name__}" ) _require_non_negative_int(self.token_index, "SentenceReadingState.token_index") if not isinstance(self.lookback, tuple): raise ComprehensionStateError( "SentenceReadingState.lookback must be tuple[AppliedCategory, ...]" ) if len(self.lookback) > _LOOKBACK_MAX: raise ComprehensionStateError( f"SentenceReadingState.lookback must be ≤{_LOOKBACK_MAX} entries; " f"got {len(self.lookback)}" ) for idx, ac in enumerate(self.lookback): if not isinstance(ac, AppliedCategory): raise ComprehensionStateError( f"SentenceReadingState.lookback[{idx}] must be AppliedCategory; " f"got {type(ac).__name__}" ) if self.partial_frame_payload is not None and not isinstance( self.partial_frame_payload, FramePayload ): raise ComprehensionStateError( "SentenceReadingState.partial_frame_payload must be " f"FramePayload | None; got {type(self.partial_frame_payload).__name__}" ) # --- backward-compatible serialisation (original 5 fields only, null for None) --- def as_canonical(self) -> dict[str, Any]: return { "entities": [entity.as_canonical() for entity in self.entities], "expectation": ( self.expectation.as_canonical() if self.expectation is not None else None ), "operations": [ operation.as_canonical() for operation in self.operations ], "quantities": [ quantity.as_canonical() for quantity in self.quantities ], "question_target": ( self.question_target.as_canonical() if self.question_target is not None else None ), } def canonical_bytes(self) -> bytes: return json.dumps( self.as_canonical(), ensure_ascii=False, sort_keys=True, separators=(",", ":"), ).encode("utf-8") def canonical_hash(self) -> str: return hashlib.sha256(self.canonical_bytes()).hexdigest() # --------------------------------------------------------------------------- # ProblemReadingState (outer, problem-scoped) — ADR-0164.3 §Decision # Field order matches ADR-0164.3 §ProblemReadingState table exactly. # 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, ...] accumulated_initial_state: tuple[PartialInitialPossession, ...] accumulated_operations: tuple[PartialOperation, ...] unknown_target_slot: QuestionTargetSlot | None 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): raise ComprehensionStateError( "ProblemReadingState.entity_registry must be tuple[EntityRef, ...]" ) for idx, e in enumerate(self.entity_registry): if not isinstance(e, EntityRef): raise ComprehensionStateError( f"ProblemReadingState.entity_registry[{idx}] must be EntityRef; " f"got {type(e).__name__}" ) if not isinstance(self.accumulated_initial_state, tuple): raise ComprehensionStateError( "ProblemReadingState.accumulated_initial_state must be " "tuple[PartialInitialPossession, ...]" ) for idx, pip in enumerate(self.accumulated_initial_state): if not isinstance(pip, PartialInitialPossession): raise ComprehensionStateError( f"ProblemReadingState.accumulated_initial_state[{idx}] must be " f"PartialInitialPossession; got {type(pip).__name__}" ) if not isinstance(self.accumulated_operations, tuple): raise ComprehensionStateError( "ProblemReadingState.accumulated_operations must be " "tuple[PartialOperation, ...]" ) for idx, po in enumerate(self.accumulated_operations): if not isinstance(po, PartialOperation): raise ComprehensionStateError( f"ProblemReadingState.accumulated_operations[{idx}] must be " f"PartialOperation; got {type(po).__name__}" ) if self.unknown_target_slot is not None and not isinstance( self.unknown_target_slot, QuestionTargetSlot ): raise ComprehensionStateError( "ProblemReadingState.unknown_target_slot must be " f"QuestionTargetSlot | None; got {type(self.unknown_target_slot).__name__}" ) if not isinstance(self.pronoun_resolution_history, tuple): raise ComprehensionStateError( "ProblemReadingState.pronoun_resolution_history must be " "tuple[PronounResolution, ...]" ) for idx, pr in enumerate(self.pronoun_resolution_history): if not isinstance(pr, PronounResolution): raise ComprehensionStateError( f"ProblemReadingState.pronoun_resolution_history[{idx}] must be " f"PronounResolution; got {type(pr).__name__}" ) _require_non_negative_int( self.sentence_index, "ProblemReadingState.sentence_index" ) _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) def canonical_hash(self) -> str: return hashlib.sha256(self.canonical_bytes()).hexdigest() # --------------------------------------------------------------------------- # Canonical-bytes serialisation — ADR-0164.3 §Canonical-bytes # Handles ProblemReadingState, SentenceReadingState, and ReaderRefusal. # Rules: sort keys, compact separators, tuple→list, Decimal→str, # None→OMITTED (not null), dataclass→sorted-key dict. # --------------------------------------------------------------------------- def _canonical_dict_omit_none(obj: Any) -> Any: """Recursively convert to a canonical JSON-serialisable value. None values are returned as the sentinel _OMIT; callers drop them from dict outputs. This matches ADR-0164.3 §Canonical-bytes rule 7. """ if obj is None: return _OMIT if isinstance(obj, bool): return obj if isinstance(obj, int): return obj if isinstance(obj, Decimal): return _canonical_decimal(obj) if isinstance(obj, str): 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()): val = _canonical_dict_omit_none(getattr(obj, key)) if val is not _OMIT: out[key] = val return out raise ComprehensionStateError( f"to_canonical_bytes: cannot serialise {type(obj).__name__}" ) class _OmitSentinel: """Sentinel returned by _canonical_dict_omit_none for None values.""" __slots__ = () _OMIT = _OmitSentinel() def to_canonical_bytes( state: ProblemReadingState | SentenceReadingState | ReaderRefusal, ) -> bytes: """Sorted-keys, compact-separators JSON per ADR-0164.3 §Canonical-bytes. Optional fields whose value is None are OMITTED from the output (not serialised as ``null``). Tuples become JSON arrays. Decimal values are serialised as strings to preserve precision. Identical state → byte-identical output (determinism gate). """ d = _canonical_dict_omit_none(state) return json.dumps( d, ensure_ascii=False, sort_keys=True, separators=(",", ":"), ).encode("utf-8") # --------------------------------------------------------------------------- # Backward-compatibility alias # --------------------------------------------------------------------------- #: Alias for code that imported ComprehensionState from #321. #: ``SentenceReadingState`` is the canonical name per ADR-0164.3. ComprehensionState = SentenceReadingState