ADR-0174 Phase 3b — emit N anchors for compound-clause discrete-count
sentences sharing one subject + one verb. Architectural substrate;
score on train_sample preserved at 3/47/0 (compound cases like 0027
admit past the recognizer-injection refusal but the rest of the
problem still has downstream complexity — fractions, percent — that
needs Phase 4 + solver work).
generate/comprehension/state.py:
HYPOTHESIS_CAP raised 4 → 8. Case 0040 emits 5 anchors; cap=8
gives headroom (7-item lists) without becoming permissive.
generate/recognizer_match.py:
_try_extract_compound_discrete_count_anchors() — new extractor
emitting tuple of anchors for compound sentences. Refusal-
preferring on:
- no conjunctive separator (single-anchor path)
- multiplicative/percent/fraction markers
- head verb not in whitelist
- any tail clause without grounded (count, observed_noun) pair
- exceeding HYPOTHESIS_CAP
- unaccounted digit in tail (wrong=0 hazard defense surfaced by
2026-05-28 implementation review: bogusnoun would silently fail
to produce anchor while leaving the digit unaccounted, admitting
partial state)
Wired into _match_discrete_count_statement dispatch as fallback when
single-anchor extraction fails.
tests/test_adr_0174_phase3b_compound_clause.py:
11 acceptance tests passing — pure conjunctive lists (proper-noun
+ pronoun-subject + single-actor antecedent), refusal-preferring
discipline (mixed-verb, multiplicative-tail, non-whitelisted-head,
partial-grounding all-or-nothing), HYPOTHESIS_CAP enforcement,
multi-actor pronoun defense preserved on compound, wrong=0 +
case-0050 canary.
tests/test_adr_0174_phase1_held_hypothesis_state.py:
Updated test_hypothesis_cap_is_four → test_hypothesis_cap_is_eight
with rationale for the raise.
Phase 3b implementation lookback review (per CLAUDE.md doctrine):
- Surfaced silent-partial-admission hazard in tail extraction;
fixed with digit-accounting check before commit
- Surfaced LATENT regex-path multi-actor pronoun hazard (not
introduced by Phase 3b; documented in test docstring with
cross-reference to project-adr-0174-multi-actor-pronoun-hazard
memory for follow-up)
- case 0040 ('He now has...') remains refused — 'now' adverb between
subject and verb defeats the existing canonical regex. Adverb-
stripping is separate scope (not Phase 3b).
Acceptance:
- 258/258 ADR-0174 + math_problem_graph tests pass
- Smoke 67/67, packs 141/141
- train_sample 3/47/0 preserved (wrong=0 held)
- Case 0027 'Malcolm has 240 followers on Instagram and 500 followers
on Facebook' now admits via the compound extractor — verified by
refusal moving to the next sentence (which has 'half' fraction)
1075 lines
42 KiB
Python
1075 lines
42 KiB
Python
"""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
|