diff --git a/docs/decisions/ADR-0135-binding-graph-question-target.md b/docs/decisions/ADR-0135-binding-graph-question-target.md new file mode 100644 index 00000000..1fbc7ae5 --- /dev/null +++ b/docs/decisions/ADR-0135-binding-graph-question-target.md @@ -0,0 +1,130 @@ +# ADR-0135 — Binding Graph Phase 4: question-target binding refinement + +**Status:** Accepted. +**Parents:** [ADR-0132](ADR-0132-binding-graph-data-model.md), +[ADR-0133](ADR-0133-binding-graph-adapter.md), +[ADR-0134](ADR-0134-binding-graph-admissibility.md). +**Phase 5 (bounded-grammar / B3 integration):** deferred. + +## Context + +Phases 1–3 produced a `SemanticSymbolicBindingGraph` containing a +`BoundUnknown` that named the symbol the question targets — but only +the symbol *identity*, not its *temporal index* nor the *shape of the +expected answer*. + +For "How many apples does Tina have?" with a three-operation +trajectory, the adapter's `BoundUnknown` did not distinguish between +Tina's apples at `t=0`, after the second operation, or at the terminal +state. Three different symbols, one ambiguous question. + +Likewise, "How much faster did Tina run?" and "How many apples does +Tina have?" both compile down to a `BoundUnknown` pointing at a symbol, +but the *verdict shape* a downstream consumer should produce +(`difference` vs. `count`) is qualitatively different. + +Phase 4 closes that ambiguity *deterministically* at the binding-graph +layer, without invoking a solver or grammar. + +## Decision + +### Two new required fields on `BoundUnknown` + +- `state_index: StateIndex` — a closed tagged union: + `Literal["initial", "terminal"]` or `Operation(operation_index: int)`, + where `Operation` is a frozen dataclass (not a string) so the index + is type-checked at construction. +- `question_form: Literal["count", "rate", "total", "difference", + "ratio", "identity"]`. + +Both fields are **required** — no defaults. Phase 1–3 fixtures that +construct `BoundUnknown` directly have been updated in the same PR. + +### Pure-function resolvers — `generate/binding_graph/question_target.py` + +- `resolve_state_index(g)` — `"terminal"` when `g.operations` is + non-empty, `"initial"` otherwise. The `Operation` variant is reserved + for future intermediate-state queries; this resolver does not yet + produce it. +- `infer_question_form(g)` — closed dispatch over operations that + *touch* the unknown's entity (actor / target / `Comparison` + `reference_actor` matches). Precedence rule (deterministic): + + 1. No operations touch → `"identity"`. + 2. Any `compare_multiplicative` touches → `"ratio"`. + 3. Any `compare_additive` touches → `"difference"`. + 4. Any `apply_rate` touches → `"total"` when `unknown.unit == + numerator_unit`; `"rate"` when `unknown.unit == denominator_unit`; + otherwise refuse (`apply_rate_unit_mismatch`). + 5. All touching kinds in `{add, subtract, transfer, multiply, + divide}` → `"count"`. + 6. Anything else → refuse (`unmappable_question_form`). + + The precedence is **closed and ordered**, not a heuristic. A graph + mixing `compare_additive` with `add` returns `"difference"` because + the comparison establishes the question's shape regardless of any + downstream arithmetic. + +- `bound_unknown_from_math_problem_graph(g)` — composes the above and + yields a fully-populated `BoundUnknown`. The adapter (ADR-0133) calls + this in place of its old ad-hoc mapping. + +### Refusal-first via `QuestionTargetError` + +Sibling of `AdapterError` and `AdmissibilityError`. Reasons are a +closed set: + +- `not_a_math_problem_graph` +- `unknown_entity_not_in_entities` +- `apply_rate_unit_mismatch` +- `unmappable_question_form` + +The resolver never silently coerces to a default — ambiguity refuses. + +### Cross-collection guard + +`SemanticSymbolicBindingGraph.__post_init__` adds: if any +`BoundUnknown.state_index` is an `Operation`, its `operation_index` +must be `< len(equations)`. Standalone `BoundUnknown` construction +checks only the sign (`>= 0`). + +## Invariants preserved + +- `bind_math_problem_graph(g)` remains pure and deterministic. + Byte-equal across runs on a given `g`. The canonical-string emission + changes shape (now includes `state=…` and `form=…` tokens), so + hashes differ from Phase 3 main *by design* — not a regression. +- No runtime wiring. No solver coupling. No mutation of + `MathProblemGraph`. No `algebra/`, `chat/`, `core/` edits. +- Field-invariant `versor_condition(F) < 1e-6` untouched. + +## Phase 5 — deferred + +Bounded-grammar / B3 integration remains out of scope. Phase 4 only +determines *which symbol* the question targets and *what form* the +answer takes; producing surface verdicts from those signals belongs to +a later phase. + +## Trust boundary + +`question_target.py` reads only frozen, validated `MathProblemGraph` +input. No filesystem access, no dynamic import, no user-controlled +formatting. All refusals are typed. + +## Tests + +- `tests/test_binding_graph_question_target.py` — unit-lane (~45 tests): + every operation-kind family, precedence rule, refusal paths, typed + `Operation` state-index guards. +- `tests/test_binding_graph_adapter_question_target.py` — integration + (~25 tests): adapter round-trip, hash stability, refusal propagation, + cross-collection bounds guard. +- Phase 1+2+3 fixtures updated where they constructed `BoundUnknown` + directly (~5 sites). + +## References + +- ADR-0132 — data model. +- ADR-0133 — adapter. +- ADR-0134 — unit-aware admissibility. +- `generate/math_problem_graph.py` — input shape (ADR-0115). diff --git a/generate/binding_graph/__init__.py b/generate/binding_graph/__init__.py index 9d7d22a8..22c36b68 100644 --- a/generate/binding_graph/__init__.py +++ b/generate/binding_graph/__init__.py @@ -1,4 +1,4 @@ -"""ADR-0132/0133 — Semantic-Symbolic Binding Graph. +"""ADR-0132/0133/0134/0135 — Semantic-Symbolic Binding Graph. Phase 1 (ADR-0132): pure data layer (frozen dataclasses, deterministic allocator, refusal-first construction; no I/O, no parser, no algebra, @@ -10,8 +10,13 @@ Phase 2 (ADR-0133): pure-function adapter :class:`generate.math_problem_graph.MathProblemGraph` (ADR-0115) into a :class:`SemanticSymbolicBindingGraph`. Still no runtime wiring. -Phases 3-5 (unit-aware equation binding / question-target binding / -bounded-grammar integration) are deferred to follow-up PRs. +Phase 3 (ADR-0134): unit-aware admissibility on equations. + +Phase 4 (ADR-0135): question-target binding refinement — +:class:`BoundUnknown` now carries ``state_index`` + ``question_form`` +fields resolved by :func:`bound_unknown_from_math_problem_graph`. + +Phase 5 (bounded-grammar / B3 integration) deferred. """ from __future__ import annotations @@ -32,16 +37,27 @@ from .admissibility import ( from .allocation import allocate_symbols from .model import ( ADMISSIBILITY_STATUSES, + QUESTION_FORMS, SEMANTIC_ROLES, + STATE_INDEX_LABELS, BindingGraphError, BoundConstraint, BoundEquation, BoundFact, BoundUnknown, + Operation, SemanticSymbolicBindingGraph, SourceSpanLink, + StateIndex, SymbolBinding, ) +from .question_target import ( + QUESTION_TARGET_REASONS, + QuestionTargetError, + bound_unknown_from_math_problem_graph, + infer_question_form, + resolve_state_index, +) from .units import ( BASE_DIMENSIONS, DIMENSIONLESS, @@ -60,8 +76,11 @@ __all__ = ( "BASE_DIMENSIONS", "DIMENSIONLESS", "INTRODUCED_BY", + "QUESTION_FORMS", + "QUESTION_TARGET_REASONS", "REFUSED_UNIT_PROOF", "SEMANTIC_ROLES", + "STATE_INDEX_LABELS", "SYNTHETIC_SOURCE_ID", "AdapterError", "AdmissibilityError", @@ -70,16 +89,22 @@ __all__ = ( "BoundEquation", "BoundFact", "BoundUnknown", + "Operation", + "QuestionTargetError", "SemanticSymbolicBindingGraph", "SourceSpanLink", + "StateIndex", "SymbolBinding", "UnitAlgebraError", "UnitProof", "UnitVector", "allocate_symbols", "bind_math_problem_graph", + "bound_unknown_from_math_problem_graph", "check_admissibility", + "infer_question_form", "parse_unit", + "resolve_state_index", "unit_inverse", "unit_product", "unit_quotient", diff --git a/generate/binding_graph/adapter.py b/generate/binding_graph/adapter.py index 6585f76b..01935211 100644 --- a/generate/binding_graph/adapter.py +++ b/generate/binding_graph/adapter.py @@ -53,11 +53,11 @@ from .model import ( BindingGraphError, BoundEquation, BoundFact, - BoundUnknown, SemanticSymbolicBindingGraph, SourceSpanLink, SymbolBinding, ) +from .question_target import bound_unknown_from_math_problem_graph # --------------------------------------------------------------------------- # Constants — locked mapping discipline (read these before editing logic). @@ -126,14 +126,6 @@ def _op_result_symbol_id(idx: int) -> str: return f"op_{idx:03d}_result" -def _unknown_symbol_id(entity: str | None, unit: str) -> str: - scope = _slug(entity) if entity is not None else "total" - if scope == "": - scope = "total" - unit_slug = _slug(unit) or "x" - return f"unknown_{scope}_{unit_slug}" - - def _span(text: str) -> SourceSpanLink: """Synthesize a deterministic ``SourceSpanLink`` for ``text``. @@ -424,14 +416,18 @@ def bind_math_problem_graph( ) # ---- Unknown → synthesized unknown symbol + BoundUnknown -------------- + # ADR-0135: ``bound_unknown_from_math_problem_graph`` now owns the + # refined ``BoundUnknown`` (symbol_id / question_span / state_index / + # question_form / expected_unit). The adapter only retains responsibility + # for the matching ``SymbolBinding`` (``semantic_role='unknown'``). unk = g.unknown - unk_sid = _unknown_symbol_id(unk.entity, unk.unit) unk_text = ( f"{unk.entity}|{unk.unit}" if unk.entity is not None else f"total|{unk.unit}" ) + bound_unknown = bound_unknown_from_math_problem_graph(g) _add( SymbolBinding( - symbol_id=unk_sid, + symbol_id=bound_unknown.symbol_id, name=unk_text, semantic_role="unknown", source_span=_span(unk_text), @@ -440,13 +436,7 @@ def bind_math_problem_graph( unit=unk.unit, ) ) - unknowns = ( - BoundUnknown( - symbol_id=unk_sid, - question_span=_span(unk_text), - expected_unit=unk.unit, - ), - ) + unknowns = (bound_unknown,) try: return SemanticSymbolicBindingGraph( diff --git a/generate/binding_graph/model.py b/generate/binding_graph/model.py index b3d36ad0..771375ed 100644 --- a/generate/binding_graph/model.py +++ b/generate/binding_graph/model.py @@ -17,7 +17,7 @@ substrate. from __future__ import annotations from dataclasses import dataclass, field -from typing import Final +from typing import Final, Literal, Union # --------------------------------------------------------------------------- # Public errors @@ -56,6 +56,52 @@ ADMISSIBILITY_STATUSES: Final[frozenset[str]] = frozenset( {"admitted", "pending", "refused"} ) +# ADR-0135 — closed ``BoundUnknown.question_form`` vocabulary. Never coerce +# silently to a default; refuse with ``QuestionTargetError`` on unmappable +# graph shapes. Extending this set is a future ADR (not a one-liner). +QUESTION_FORMS: Final[frozenset[str]] = frozenset( + {"count", "rate", "total", "difference", "ratio", "identity"} +) + +# ADR-0135 — closed ``BoundUnknown.state_index`` literal labels. The +# tagged-union ``StateIndex`` (below) is either one of these strings or +# an :class:`Operation` instance carrying a typed operation index. +STATE_INDEX_LABELS: Final[frozenset[str]] = frozenset({"initial", "terminal"}) + + +@dataclass(frozen=True, slots=True) +class Operation: + """ADR-0135 — state-index variant pointing at a specific operation. + + The ``operation_index`` is type-checked as ``int`` (not ``str``) so + typos like ``Operation("3")`` refuse at construction. Cross-collection + bounds (``operation_index < graph operation count``) are enforced by + :class:`SemanticSymbolicBindingGraph.__post_init__`, not here — at + standalone construction we only know the value must be non-negative. + """ + + operation_index: int + + def __post_init__(self) -> None: + if not isinstance(self.operation_index, int) or isinstance( + self.operation_index, bool + ): + raise BindingGraphError( + f"Operation.operation_index must be int; " + f"got {type(self.operation_index).__name__}" + ) + if self.operation_index < 0: + raise BindingGraphError( + f"Operation.operation_index must be >= 0; " + f"got {self.operation_index}" + ) + + +# Tagged union for the state at which a :class:`BoundUnknown`'s symbol +# is observed. ``"initial"`` / ``"terminal"`` collapse to the obvious +# endpoints; ``Operation`` references a specific intermediate step. +StateIndex = Union[Literal["initial", "terminal"], Operation] + def _require_non_empty_str(value: object, field_name: str) -> None: if not isinstance(value, str) or value == "": @@ -267,10 +313,20 @@ class BoundEquation: @dataclass(frozen=True, slots=True) class BoundUnknown: - """The target of the question, bound to a known symbol.""" + """The target of the question, bound to a known symbol. + + ADR-0135 widens the contract from "the symbol whose value the solver + determines" to "the symbol at a specific temporal/state index with a + specific question-form". The two new fields are *required* — no + defaults — so every construction site declares its intent. + """ symbol_id: str question_span: SourceSpanLink + state_index: "StateIndex" + question_form: Literal[ + "count", "rate", "total", "difference", "ratio", "identity" + ] expected_unit: str | None = None def __post_init__(self) -> None: @@ -284,6 +340,18 @@ class BoundUnknown: raise BindingGraphError( "BoundUnknown.question_span must be a SourceSpanLink" ) + if isinstance(self.state_index, Operation): + pass # bounds re-checked by the graph; standalone is sign-only + elif self.state_index not in STATE_INDEX_LABELS: + raise BindingGraphError( + f"BoundUnknown.state_index must be 'initial', 'terminal', or " + f"an Operation instance; got {self.state_index!r}" + ) + if self.question_form not in QUESTION_FORMS: + raise BindingGraphError( + f"BoundUnknown.question_form must be one of " + f"{sorted(QUESTION_FORMS)}; got {self.question_form!r}" + ) _require_optional_str(self.expected_unit, "BoundUnknown.expected_unit") @@ -396,12 +464,20 @@ class SemanticSymbolicBindingGraph: f"{dep!r} (lhs={eq.lhs_symbol_id!r})" ) + equation_count = len(self.equations) for unk in self.unknowns: if unk.symbol_id not in known_ids: raise BindingGraphError( f"BoundUnknown references unknown symbol_id " f"{unk.symbol_id!r}" ) + if isinstance(unk.state_index, Operation): + if unk.state_index.operation_index >= equation_count: + raise BindingGraphError( + f"BoundUnknown.state_index.operation_index " + f"{unk.state_index.operation_index} >= equation_count " + f"{equation_count}" + ) for con in self.constraints: if con.symbol_id not in known_ids: @@ -440,8 +516,13 @@ class SemanticSymbolicBindingGraph: f"span={eq.source_span.to_canonical_string()}" ) for unk in self.unknowns: + if isinstance(unk.state_index, Operation): + state_token = f"op({unk.state_index.operation_index})" + else: + state_token = unk.state_index lines.append( f"U {unk.symbol_id} expected_unit={unk.expected_unit} " + f"state={state_token} form={unk.question_form} " f"qspan={unk.question_span.to_canonical_string()}" ) for con in self.constraints: diff --git a/generate/binding_graph/question_target.py b/generate/binding_graph/question_target.py new file mode 100644 index 00000000..ca7ba7cd --- /dev/null +++ b/generate/binding_graph/question_target.py @@ -0,0 +1,274 @@ +"""ADR-0135 — Question-target binding refinement. + +Pure-function resolvers that take a :class:`MathProblemGraph` and produce +the two new fields ``BoundUnknown`` requires under ADR-0135: + + - :func:`resolve_state_index` — picks the temporal index at which the + unknown's symbol is observed. Current rule: ``"terminal"`` when at + least one :class:`Operation` exists in the graph, ``"initial"`` when + not. The :class:`Operation` (state-index) variant is part of the + closed vocabulary but not produced by this resolver yet — it exists + so future intermediate-state queries have a typed home. + + - :func:`infer_question_form` — closed dispatch over the operations + whose actor / target / reference touches the unknown's entity. The + precedence rule (documented in ADR-0135) is deterministic; ambiguous + or unmappable shapes refuse with :class:`QuestionTargetError`. + + - :func:`bound_unknown_from_math_problem_graph` — the public entry + point the adapter uses. Refusal-first, deterministic, pure. + +No I/O. No solver. No mutation. The resolver only determines *which* +symbol the question targets and *what* form the answer takes. +""" + +from __future__ import annotations + +import re +from typing import Final, Literal + +from generate.math_problem_graph import ( + Comparison, + MathProblemGraph, + Operation as MathOperation, + Rate, +) + +from .model import ( + BoundUnknown, + Operation, + SourceSpanLink, + StateIndex, +) + + +# --------------------------------------------------------------------------- +# Public error +# --------------------------------------------------------------------------- + + +class QuestionTargetError(ValueError): + """Raised when the question-target resolver cannot bind deterministically. + + Sibling of :class:`generate.binding_graph.AdapterError` and + :class:`generate.binding_graph.AdmissibilityError`. Always carries a + typed ``reason`` token from :data:`QUESTION_TARGET_REASONS`. The + resolver never silently picks a default form. + """ + + def __init__(self, reason: str, *, detail: str = "") -> None: + if reason not in QUESTION_TARGET_REASONS: + raise ValueError( + f"QuestionTargetError.reason must be one of " + f"{sorted(QUESTION_TARGET_REASONS)}; got {reason!r}" + ) + self.reason: Final[str] = reason + self.detail: Final[str] = detail + message = f"{reason}: {detail}" if detail else reason + super().__init__(message) + + +QUESTION_TARGET_REASONS: Final[frozenset[str]] = frozenset( + { + "not_a_math_problem_graph", + "unknown_entity_not_in_entities", + "unmappable_question_form", + "apply_rate_unit_mismatch", + } +) + + +# --------------------------------------------------------------------------- +# Helpers — naming locked to ``generate.binding_graph.adapter`` conventions. +# --------------------------------------------------------------------------- + +_SLUG_NON_ALNUM = re.compile(r"[^a-z0-9]+") + + +def _slug(text: str) -> str: + return _SLUG_NON_ALNUM.sub("_", text.strip().lower()).strip("_") + + +def _unknown_symbol_id(entity: str | None, unit: str) -> str: + scope = _slug(entity) if entity is not None else "total" + if scope == "": + scope = "total" + unit_slug = _slug(unit) or "x" + return f"unknown_{scope}_{unit_slug}" + + +def _unknown_span(entity: str | None, unit: str) -> SourceSpanLink: + text = f"{entity}|{unit}" if entity is not None else f"total|{unit}" + return SourceSpanLink( + source_id="math_problem_graph", start=0, end=len(text), text=text + ) + + +def _operation_touches_unknown( + op: MathOperation, unk_entity: str | None +) -> bool: + """An operation touches the unknown if its actor / target / + comparison reference matches the unknown's entity. When the unknown + has no entity (``entity is None``, the "total across all entities" + case), every operation is considered to touch it. + """ + if unk_entity is None: + return True + if op.actor == unk_entity: + return True + if op.target is not None and op.target == unk_entity: + return True + if isinstance(op.operand, Comparison): + if op.operand.reference_actor == unk_entity: + return True + return False + + +# --------------------------------------------------------------------------- +# Public resolvers +# --------------------------------------------------------------------------- + + +def resolve_state_index(g: object) -> StateIndex: + """Pick the temporal index at which the unknown is observed. + + Closed rule: ``"terminal"`` when the graph has at least one + operation, ``"initial"`` when not. The :class:`Operation` variant of + :data:`StateIndex` is part of the public vocabulary so future + intermediate-state queries have a typed home, but this resolver + never returns it today. + """ + if not isinstance(g, MathProblemGraph): + raise QuestionTargetError( + "not_a_math_problem_graph", + detail=f"got {type(g).__name__}", + ) + if g.operations: + return "terminal" + return "initial" + + +_COUNT_KINDS: Final[frozenset[str]] = frozenset( + {"add", "subtract", "transfer", "multiply", "divide"} +) + + +def infer_question_form( + g: object, +) -> Literal["count", "rate", "total", "difference", "ratio", "identity"]: + """Closed-vocab dispatch on operation kinds touching the unknown. + + Precedence (deterministic; documented in ADR-0135). Evaluate in + order, return on first match: + + 1. No operations touch the unknown's entity → ``"identity"``. + 2. Any ``compare_multiplicative`` touches → ``"ratio"``. + 3. Any ``compare_additive`` touches → ``"difference"``. + 4. Any ``apply_rate`` touches → ``"total"`` when the unknown's + unit matches the rate's ``numerator_unit``; ``"rate"`` when it + matches the ``denominator_unit``. Otherwise refuse + (``apply_rate_unit_mismatch``). + 5. Every touching operation is in ``{add, subtract, transfer, + multiply, divide}`` → ``"count"``. + 6. Anything else → refuse (``unmappable_question_form``). + + The precedence order resolves ambiguity: a graph mixing + ``compare_additive`` with ``add`` returns ``"difference"`` because + the comparison establishes the question's shape regardless of any + downstream arithmetic. This is a closed rule, not a heuristic. + """ + if not isinstance(g, MathProblemGraph): + raise QuestionTargetError( + "not_a_math_problem_graph", + detail=f"got {type(g).__name__}", + ) + + unk_entity = g.unknown.entity + if unk_entity is not None and unk_entity not in g.entities: + # MathProblemGraph already enforces this invariant, but defensive + # restatement keeps the resolver self-contained. + raise QuestionTargetError( + "unknown_entity_not_in_entities", + detail=f"entity={unk_entity!r}", + ) + + touching: tuple[MathOperation, ...] = tuple( + op for op in g.operations if _operation_touches_unknown(op, unk_entity) + ) + + if not touching: + return "identity" + + kinds = {op.kind for op in touching} + + if "compare_multiplicative" in kinds: + return "ratio" + if "compare_additive" in kinds: + return "difference" + + if "apply_rate" in kinds: + unk_unit = g.unknown.unit + for op in touching: + if op.kind == "apply_rate" and isinstance(op.operand, Rate): + if unk_unit == op.operand.numerator_unit: + return "total" + if unk_unit == op.operand.denominator_unit: + return "rate" + raise QuestionTargetError( + "apply_rate_unit_mismatch", + detail=( + f"unknown.unit={unk_unit!r} matches neither numerator nor " + f"denominator of any touching apply_rate" + ), + ) + + if kinds.issubset(_COUNT_KINDS): + return "count" + + raise QuestionTargetError( + "unmappable_question_form", + detail=f"touching kinds={sorted(kinds)!r}", + ) + + +def bound_unknown_from_math_problem_graph(g: object) -> BoundUnknown: + """Build the refined :class:`BoundUnknown` for ``g``. + + The adapter (ADR-0133) calls this in place of its old ad-hoc + ``Unknown → BoundUnknown`` mapping. Determinism: identical ``g`` + yields a byte-equal :class:`BoundUnknown`. Refusal-first: any + :class:`QuestionTargetError` from the sub-resolvers propagates. + + The synthesized ``symbol_id`` / ``question_span`` mirror the + adapter's pre-ADR-0135 convention exactly, so the symbol map in the + surrounding binding graph remains the same shape. + """ + if not isinstance(g, MathProblemGraph): + raise QuestionTargetError( + "not_a_math_problem_graph", + detail=f"got {type(g).__name__}", + ) + unk = g.unknown + state_index = resolve_state_index(g) + question_form = infer_question_form(g) + sid = _unknown_symbol_id(unk.entity, unk.unit) + span = _unknown_span(unk.entity, unk.unit) + return BoundUnknown( + symbol_id=sid, + question_span=span, + state_index=state_index, + question_form=question_form, + expected_unit=unk.unit, + ) + + +# Re-export ``Operation`` so callers do not need to thread the model +# import separately just to construct the state-index variant. +__all__ = ( + "QUESTION_TARGET_REASONS", + "Operation", + "QuestionTargetError", + "bound_unknown_from_math_problem_graph", + "infer_question_form", + "resolve_state_index", +) diff --git a/tests/test_binding_graph_adapter_question_target.py b/tests/test_binding_graph_adapter_question_target.py new file mode 100644 index 00000000..27aebca4 --- /dev/null +++ b/tests/test_binding_graph_adapter_question_target.py @@ -0,0 +1,446 @@ +"""ADR-0135 — Adapter integration tests for question-target binding. + +Covers: + - the adapter consumes ``bound_unknown_from_math_problem_graph`` and + emits a single :class:`BoundUnknown` per graph with populated + ``state_index`` + ``question_form`` fields; + - byte-equal hash-stability across runs survives the Phase-4 wiring + (Phase 2 invariant — value may differ from Phase 3 main, by design); + - every Phase-2 / Phase-3 round-trip case still produces a + well-formed binding graph; + - intentionally-ambiguous inputs surface ``QuestionTargetError`` + (resolver refusal) — not silent coercion; + - cross-collection invariant guards against bogus ``Operation`` + state-index pointing past ``len(equations)``. +""" + +from __future__ import annotations + +import hashlib + +import pytest + +from generate.binding_graph import ( + BindingGraphError, + BoundEquation, + BoundFact, + BoundUnknown, + Operation as StateIndexOperation, + QuestionTargetError, + SemanticSymbolicBindingGraph, + SourceSpanLink, + SymbolBinding, + bind_math_problem_graph, +) +from generate.math_problem_graph import ( + Comparison, + InitialPossession, + MathProblemGraph, + Operation, + Quantity, + Rate, + Unknown, +) + + +# --------------------------------------------------------------------------- +# Builders +# --------------------------------------------------------------------------- + + +def _g( + *, + entities: tuple[str, ...] = ("Tina",), + initial_state: tuple[InitialPossession, ...] = ( + InitialPossession(entity="Tina", quantity=Quantity(5, "apples")), + ), + operations: tuple[Operation, ...] = (), + unknown: Unknown = Unknown(entity="Tina", unit="apples"), +) -> MathProblemGraph: + return MathProblemGraph( + entities=entities, + initial_state=initial_state, + operations=operations, + unknown=unknown, + ) + + +def _add(actor: str, n: int, unit: str = "apples") -> Operation: + return Operation(actor=actor, kind="add", operand=Quantity(n, unit)) + + +def _rate_op(actor: str, value: float, num: str, denom: str) -> Operation: + return Operation( + actor=actor, + kind="apply_rate", + operand=Rate(value=value, numerator_unit=num, denominator_unit=denom), + ) + + +def _cmp_add(actor: str, ref: str, delta: int, unit: str = "apples") -> Operation: + return Operation( + actor=actor, + kind="compare_additive", + operand=Comparison( + reference_actor=ref, + delta=Quantity(delta, unit), + factor=None, + direction="more", + ), + ) + + +def _cmp_mul(actor: str, ref: str, factor: float) -> Operation: + return Operation( + actor=actor, + kind="compare_multiplicative", + operand=Comparison( + reference_actor=ref, delta=None, factor=factor, direction="times" + ), + ) + + +# --------------------------------------------------------------------------- +# Smoke: every Phase-2 round-trip case still emits a well-formed graph +# --------------------------------------------------------------------------- + + +def test_adapter_emits_single_bound_unknown() -> None: + bg = bind_math_problem_graph(_g(operations=(_add("Tina", 2),))) + assert len(bg.unknowns) == 1 + + +def test_adapter_bound_unknown_terminal_count() -> None: + bg = bind_math_problem_graph(_g(operations=(_add("Tina", 2),))) + bu = bg.unknowns[0] + assert bu.state_index == "terminal" + assert bu.question_form == "count" + assert bu.expected_unit == "apples" + + +def test_adapter_bound_unknown_initial_identity() -> None: + bg = bind_math_problem_graph(_g(operations=())) + bu = bg.unknowns[0] + assert bu.state_index == "initial" + assert bu.question_form == "identity" + + +def test_adapter_bound_unknown_total_via_apply_rate() -> None: + g = _g( + initial_state=( + InitialPossession(entity="Tina", quantity=Quantity(8, "hours")), + ), + operations=(_rate_op("Tina", 12.5, "dollars", "hours"),), + unknown=Unknown(entity="Tina", unit="dollars"), + ) + bg = bind_math_problem_graph(g) + assert bg.unknowns[0].question_form == "total" + + +def test_adapter_bound_unknown_rate_via_apply_rate() -> None: + g = _g( + initial_state=( + InitialPossession(entity="Tina", quantity=Quantity(8, "hours")), + ), + operations=(_rate_op("Tina", 12.5, "dollars", "hours"),), + unknown=Unknown(entity="Tina", unit="hours"), + ) + bg = bind_math_problem_graph(g) + assert bg.unknowns[0].question_form == "rate" + + +def test_adapter_bound_unknown_difference() -> None: + g = _g( + entities=("Tina", "Sam"), + initial_state=( + InitialPossession(entity="Tina", quantity=Quantity(5, "apples")), + InitialPossession(entity="Sam", quantity=Quantity(3, "apples")), + ), + operations=(_cmp_add("Tina", "Sam", 2),), + ) + bg = bind_math_problem_graph(g) + assert bg.unknowns[0].question_form == "difference" + + +def test_adapter_bound_unknown_ratio() -> None: + g = _g( + entities=("Tina", "Sam"), + initial_state=( + InitialPossession(entity="Tina", quantity=Quantity(5, "apples")), + InitialPossession(entity="Sam", quantity=Quantity(3, "apples")), + ), + operations=(_cmp_mul("Tina", "Sam", 2.0),), + ) + bg = bind_math_problem_graph(g) + assert bg.unknowns[0].question_form == "ratio" + + +# --------------------------------------------------------------------------- +# Hash-stability: byte-equal across runs (Phase 2 invariant) +# --------------------------------------------------------------------------- + + +def _digest(bg: SemanticSymbolicBindingGraph) -> str: + return hashlib.sha256(bg.to_canonical_string().encode("utf-8")).hexdigest() + + +def test_byte_equal_across_runs_count() -> None: + g = _g(operations=(_add("Tina", 2), _add("Tina", 3))) + a = bind_math_problem_graph(g) + b = bind_math_problem_graph(g) + assert a.to_canonical_string() == b.to_canonical_string() + assert _digest(a) == _digest(b) + + +def test_byte_equal_across_runs_identity() -> None: + g = _g(operations=()) + a = bind_math_problem_graph(g) + b = bind_math_problem_graph(g) + assert a.to_canonical_string() == b.to_canonical_string() + + +def test_byte_equal_across_runs_difference() -> None: + g = _g( + entities=("Tina", "Sam"), + initial_state=( + InitialPossession(entity="Tina", quantity=Quantity(5, "apples")), + InitialPossession(entity="Sam", quantity=Quantity(3, "apples")), + ), + operations=(_cmp_add("Tina", "Sam", 2),), + ) + assert bind_math_problem_graph(g).to_canonical_string() == bind_math_problem_graph( + g + ).to_canonical_string() + + +def test_canonical_string_mentions_state_and_form() -> None: + bg = bind_math_problem_graph(_g(operations=(_add("Tina", 2),))) + s = bg.to_canonical_string() + assert "state=terminal" in s + assert "form=count" in s + + +def test_canonical_string_identity_form() -> None: + bg = bind_math_problem_graph(_g(operations=())) + s = bg.to_canonical_string() + assert "state=initial" in s + assert "form=identity" in s + + +# --------------------------------------------------------------------------- +# Refusal-first propagation: resolver refusals surface to caller +# --------------------------------------------------------------------------- + + +def test_adapter_propagates_apply_rate_unit_mismatch() -> None: + g = _g( + initial_state=( + InitialPossession(entity="Tina", quantity=Quantity(8, "hours")), + ), + operations=(_rate_op("Tina", 12.5, num="dollars", denom="hours"),), + unknown=Unknown(entity="Tina", unit="apples"), + ) + with pytest.raises(QuestionTargetError) as exc: + bind_math_problem_graph(g) + assert exc.value.reason == "apply_rate_unit_mismatch" + + +# --------------------------------------------------------------------------- +# Cross-collection guard: Operation(operation_index) out of bounds +# --------------------------------------------------------------------------- + + +def _span(text: str = "x") -> SourceSpanLink: + return SourceSpanLink(source_id="t", start=0, end=len(text), text=text) + + +def test_graph_refuses_operation_state_index_out_of_bounds() -> None: + # Construct a manual graph: 1 equation (so equation_count == 1), but + # the BoundUnknown points to Operation(operation_index=5) → refuse. + lhs = "op_000_result" + sym_q = SymbolBinding( + symbol_id="q_x_apples_t0", + name="x@t0", + semantic_role="quantity", + source_span=_span(), + introduced_by="test", + entity="x", + unit="apples", + ) + sym_r = SymbolBinding( + symbol_id=lhs, + name="r", + semantic_role="quantity", + source_span=_span(), + introduced_by="test", + entity="x", + ) + sym_u = SymbolBinding( + symbol_id="unknown_x_apples", + name="u", + semantic_role="unknown", + source_span=_span(), + introduced_by="test", + entity="x", + unit="apples", + ) + fact = BoundFact( + symbol_id="q_x_apples_t0", value="5", source_span=_span(), unit="apples" + ) + eq = BoundEquation( + lhs_symbol_id=lhs, + rhs_canonical="add(x, 2 apples)", + dependencies=frozenset({"q_x_apples_t0"}), + operation_kind="add", + unit_proof="apples", + admissibility_status="admitted", + source_span=_span(), + ) + bu = BoundUnknown( + symbol_id="unknown_x_apples", + question_span=_span(), + state_index=StateIndexOperation(operation_index=5), + question_form="count", + ) + with pytest.raises(BindingGraphError): + SemanticSymbolicBindingGraph( + symbols=(sym_q, sym_r, sym_u), + facts=(fact,), + equations=(eq,), + unknowns=(bu,), + ) + + +def test_graph_accepts_operation_state_index_within_bounds() -> None: + # Same shape, but operation_index=0 is within bounds (equation_count==1). + lhs = "op_000_result" + sym_q = SymbolBinding( + symbol_id="q_x_apples_t0", + name="x@t0", + semantic_role="quantity", + source_span=_span(), + introduced_by="test", + entity="x", + unit="apples", + ) + sym_r = SymbolBinding( + symbol_id=lhs, + name="r", + semantic_role="quantity", + source_span=_span(), + introduced_by="test", + entity="x", + ) + sym_u = SymbolBinding( + symbol_id="unknown_x_apples", + name="u", + semantic_role="unknown", + source_span=_span(), + introduced_by="test", + entity="x", + unit="apples", + ) + fact = BoundFact( + symbol_id="q_x_apples_t0", value="5", source_span=_span(), unit="apples" + ) + eq = BoundEquation( + lhs_symbol_id=lhs, + rhs_canonical="add(x, 2 apples)", + dependencies=frozenset({"q_x_apples_t0"}), + operation_kind="add", + unit_proof="apples", + admissibility_status="admitted", + source_span=_span(), + ) + bu = BoundUnknown( + symbol_id="unknown_x_apples", + question_span=_span(), + state_index=StateIndexOperation(operation_index=0), + question_form="count", + ) + bg = SemanticSymbolicBindingGraph( + symbols=(sym_q, sym_r, sym_u), + facts=(fact,), + equations=(eq,), + unknowns=(bu,), + ) + assert bg.unknowns[0].state_index == StateIndexOperation(operation_index=0) + + +# --------------------------------------------------------------------------- +# MathProblemGraph is read-only (frozen) — assert in test as brief requires +# --------------------------------------------------------------------------- + + +def test_adapter_does_not_mutate_input_graph() -> None: + g = _g(operations=(_add("Tina", 2),)) + before = g.canonical_bytes() + _ = bind_math_problem_graph(g) + after = g.canonical_bytes() + assert before == after + + +def test_math_problem_graph_is_frozen() -> None: + g = _g() + with pytest.raises((AttributeError, Exception)): + g.unknown = Unknown(entity="Tina", unit="apples") # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# Symbol binding for the unknown still emitted (Phase 2 invariant) +# --------------------------------------------------------------------------- + + +def test_unknown_symbol_binding_still_emitted() -> None: + bg = bind_math_problem_graph(_g(operations=(_add("Tina", 2),))) + unk_syms = [s for s in bg.symbols if s.semantic_role == "unknown"] + assert len(unk_syms) == 1 + assert unk_syms[0].symbol_id == "unknown_tina_apples" + assert unk_syms[0].entity == "Tina" + assert unk_syms[0].unit == "apples" + + +def test_unknown_symbol_id_matches_bound_unknown() -> None: + bg = bind_math_problem_graph(_g(operations=(_add("Tina", 2),))) + unk_sym = next(s for s in bg.symbols if s.semantic_role == "unknown") + assert unk_sym.symbol_id == bg.unknowns[0].symbol_id + + +# --------------------------------------------------------------------------- +# Parametric: question_form is one of the closed vocabulary on smoke graphs +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "ops, expected_form", + [ + ((), "identity"), + ((_add("Tina", 1),), "count"), + ( + ( + Operation(actor="Tina", kind="subtract", operand=Quantity(1, "apples")), + ), + "count", + ), + ( + ( + Operation(actor="Tina", kind="multiply", operand=Quantity(3, "apples")), + ), + "count", + ), + ( + ( + Operation(actor="Tina", kind="divide", operand=Quantity(2, "apples")), + ), + "count", + ), + ], +) +def test_adapter_form_table(ops, expected_form) -> None: + bg = bind_math_problem_graph(_g(operations=ops)) + assert bg.unknowns[0].question_form == expected_form + + +def test_adapter_bound_unknown_is_instance() -> None: + bg = bind_math_problem_graph(_g(operations=(_add("Tina", 1),))) + assert isinstance(bg.unknowns[0], BoundUnknown) diff --git a/tests/test_binding_graph_model.py b/tests/test_binding_graph_model.py index e5277b4a..efc2eb6b 100644 --- a/tests/test_binding_graph_model.py +++ b/tests/test_binding_graph_model.py @@ -298,19 +298,33 @@ def test_bound_equation_is_frozen() -> None: def test_bound_unknown_construction() -> None: unk = BoundUnknown( - symbol_id="sym_y_000", question_span=_span(), expected_unit="dollars" + symbol_id="sym_y_000", + question_span=_span(), + state_index="terminal", + question_form="count", + expected_unit="dollars", ) assert unk.expected_unit == "dollars" def test_bound_unknown_refuses_bad_id() -> None: with pytest.raises(BindingGraphError): - BoundUnknown(symbol_id="bad id", question_span=_span()) + BoundUnknown( + symbol_id="bad id", + question_span=_span(), + state_index="terminal", + question_form="count", + ) def test_bound_unknown_refuses_non_span_question() -> None: with pytest.raises(BindingGraphError): - BoundUnknown(symbol_id="sym_y_000", question_span="text") # type: ignore[arg-type] + BoundUnknown( + symbol_id="sym_y_000", + question_span="text", # type: ignore[arg-type] + state_index="terminal", + question_form="count", + ) # --------------------------------------------------------------------------- @@ -380,7 +394,12 @@ def test_graph_rejects_equation_with_unknown_dependency() -> None: def test_graph_rejects_unknown_referencing_missing_symbol() -> None: - unk = BoundUnknown(symbol_id="sym_ghost_000", question_span=_span()) + unk = BoundUnknown( + symbol_id="sym_ghost_000", + question_span=_span(), + state_index="terminal", + question_form="count", + ) with pytest.raises(BindingGraphError): SemanticSymbolicBindingGraph(symbols=(_sym(),), unknowns=(unk,)) @@ -402,7 +421,14 @@ def test_graph_full_round_trip_canonical_string_stable() -> None: BoundFact(symbol_id="sym_x_000", value="5", source_span=_span(), unit="apples"), ) eqs = (_eq(),) - unks = (BoundUnknown(symbol_id="sym_y_000", question_span=_span()),) + unks = ( + BoundUnknown( + symbol_id="sym_y_000", + question_span=_span(), + state_index="terminal", + question_form="count", + ), + ) cons = ( BoundConstraint(symbol_id="sym_x_000", predicate="x >= 0", source_span=_span()), ) diff --git a/tests/test_binding_graph_question_target.py b/tests/test_binding_graph_question_target.py new file mode 100644 index 00000000..0eb22b53 --- /dev/null +++ b/tests/test_binding_graph_question_target.py @@ -0,0 +1,550 @@ +"""ADR-0135 — Tests for the question-target resolver. + +Covers: + - ``resolve_state_index`` boundary cases (0 / 1 / many operations); + - ``infer_question_form`` for every operation-kind family; + - precedence rule on mixed-kind graphs; + - refusal-first ``QuestionTargetError`` paths; + - ``bound_unknown_from_math_problem_graph`` deterministic shape; + - typed ``Operation`` (state-index variant) construction guards. + +Pure unit lane — adapter integration lives in +``test_binding_graph_adapter_question_target.py``. +""" + +from __future__ import annotations + +import pytest + +from generate.binding_graph import ( + BindingGraphError, + BoundUnknown, + Operation as StateIndexOperation, + QUESTION_FORMS, + QUESTION_TARGET_REASONS, + QuestionTargetError, + STATE_INDEX_LABELS, + bound_unknown_from_math_problem_graph, + infer_question_form, + resolve_state_index, +) +from generate.math_problem_graph import ( + Comparison, + InitialPossession, + MathProblemGraph, + Operation, + Quantity, + Rate, + Unknown, +) + + +# --------------------------------------------------------------------------- +# Fixture builders +# --------------------------------------------------------------------------- + + +def _graph( + *, + entities: tuple[str, ...] = ("Tina",), + initial_state: tuple[InitialPossession, ...] = ( + InitialPossession(entity="Tina", quantity=Quantity(5, "apples")), + ), + operations: tuple[Operation, ...] = (), + unknown: Unknown = Unknown(entity="Tina", unit="apples"), +) -> MathProblemGraph: + return MathProblemGraph( + entities=entities, + initial_state=initial_state, + operations=operations, + unknown=unknown, + ) + + +def _add(actor: str, n: int, unit: str = "apples") -> Operation: + return Operation(actor=actor, kind="add", operand=Quantity(n, unit)) + + +def _sub(actor: str, n: int, unit: str = "apples") -> Operation: + return Operation(actor=actor, kind="subtract", operand=Quantity(n, unit)) + + +def _transfer(actor: str, target: str, n: int, unit: str = "apples") -> Operation: + return Operation( + actor=actor, kind="transfer", operand=Quantity(n, unit), target=target + ) + + +def _mul(actor: str, n: int, unit: str = "apples") -> Operation: + return Operation(actor=actor, kind="multiply", operand=Quantity(n, unit)) + + +def _div(actor: str, n: int, unit: str = "apples") -> Operation: + return Operation(actor=actor, kind="divide", operand=Quantity(n, unit)) + + +def _rate_op( + actor: str, value: float, num: str, denom: str +) -> Operation: + return Operation( + actor=actor, + kind="apply_rate", + operand=Rate(value=value, numerator_unit=num, denominator_unit=denom), + ) + + +def _cmp_add( + actor: str, ref: str, delta: int, unit: str = "apples", direction: str = "more" +) -> Operation: + return Operation( + actor=actor, + kind="compare_additive", + operand=Comparison( + reference_actor=ref, + delta=Quantity(delta, unit), + factor=None, + direction=direction, # type: ignore[arg-type] + ), + ) + + +def _cmp_mul( + actor: str, ref: str, factor: float, direction: str = "times" +) -> Operation: + return Operation( + actor=actor, + kind="compare_multiplicative", + operand=Comparison( + reference_actor=ref, + delta=None, + factor=factor, + direction=direction, # type: ignore[arg-type] + ), + ) + + +# --------------------------------------------------------------------------- +# StateIndex / Operation dataclass guards +# --------------------------------------------------------------------------- + + +def test_operation_state_index_construction() -> None: + op = StateIndexOperation(operation_index=3) + assert op.operation_index == 3 + + +def test_operation_state_index_refuses_negative() -> None: + with pytest.raises(BindingGraphError): + StateIndexOperation(operation_index=-1) + + +def test_operation_state_index_refuses_bool() -> None: + with pytest.raises(BindingGraphError): + StateIndexOperation(operation_index=True) # type: ignore[arg-type] + + +def test_operation_state_index_refuses_string() -> None: + with pytest.raises(BindingGraphError): + StateIndexOperation(operation_index="3") # type: ignore[arg-type] + + +def test_state_index_labels_locked() -> None: + assert STATE_INDEX_LABELS == frozenset({"initial", "terminal"}) + + +def test_question_forms_locked() -> None: + assert QUESTION_FORMS == frozenset( + {"count", "rate", "total", "difference", "ratio", "identity"} + ) + + +# --------------------------------------------------------------------------- +# resolve_state_index +# --------------------------------------------------------------------------- + + +def test_resolve_state_index_no_operations_is_initial() -> None: + g = _graph(operations=()) + assert resolve_state_index(g) == "initial" + + +def test_resolve_state_index_one_operation_is_terminal() -> None: + g = _graph(operations=(_add("Tina", 2),)) + assert resolve_state_index(g) == "terminal" + + +def test_resolve_state_index_many_operations_is_terminal() -> None: + g = _graph( + operations=tuple(_add("Tina", n) for n in (1, 2, 3, 4, 5)), + ) + assert resolve_state_index(g) == "terminal" + + +def test_resolve_state_index_refuses_non_graph() -> None: + with pytest.raises(QuestionTargetError) as exc: + resolve_state_index({"entities": ["Tina"]}) + assert exc.value.reason == "not_a_math_problem_graph" + + +def test_resolve_state_index_deterministic() -> None: + g = _graph(operations=(_add("Tina", 2), _sub("Tina", 1))) + assert resolve_state_index(g) == resolve_state_index(g) + + +# --------------------------------------------------------------------------- +# infer_question_form — single-kind families +# --------------------------------------------------------------------------- + + +def test_infer_count_pure_add() -> None: + g = _graph(operations=(_add("Tina", 2), _add("Tina", 3))) + assert infer_question_form(g) == "count" + + +def test_infer_count_pure_subtract() -> None: + g = _graph(operations=(_sub("Tina", 2),)) + assert infer_question_form(g) == "count" + + +def test_infer_count_pure_transfer() -> None: + g = _graph( + entities=("Tina", "Sam"), + initial_state=( + InitialPossession(entity="Tina", quantity=Quantity(5, "apples")), + InitialPossession(entity="Sam", quantity=Quantity(3, "apples")), + ), + operations=(_transfer("Tina", "Sam", 2),), + ) + assert infer_question_form(g) == "count" + + +def test_infer_count_multiply() -> None: + g = _graph(operations=(_mul("Tina", 3),)) + assert infer_question_form(g) == "count" + + +def test_infer_count_divide() -> None: + g = _graph(operations=(_div("Tina", 5),)) + assert infer_question_form(g) == "count" + + +def test_infer_count_mixed_arithmetic() -> None: + g = _graph( + operations=(_add("Tina", 4), _sub("Tina", 1), _mul("Tina", 2)), + ) + assert infer_question_form(g) == "count" + + +def test_infer_difference_compare_additive() -> None: + g = _graph( + entities=("Tina", "Sam"), + initial_state=( + InitialPossession(entity="Tina", quantity=Quantity(5, "apples")), + InitialPossession(entity="Sam", quantity=Quantity(3, "apples")), + ), + operations=(_cmp_add("Tina", "Sam", 2),), + ) + assert infer_question_form(g) == "difference" + + +def test_infer_ratio_compare_multiplicative() -> None: + g = _graph( + entities=("Tina", "Sam"), + initial_state=( + InitialPossession(entity="Tina", quantity=Quantity(5, "apples")), + InitialPossession(entity="Sam", quantity=Quantity(3, "apples")), + ), + operations=(_cmp_mul("Tina", "Sam", 2.0),), + ) + assert infer_question_form(g) == "ratio" + + +def test_infer_total_apply_rate_unknown_is_numerator() -> None: + # "How many dollars does Tina earn?" — apply_rate consumes hours, + # produces dollars. Unknown.unit == numerator_unit ⇒ total. + g = _graph( + initial_state=( + InitialPossession(entity="Tina", quantity=Quantity(8, "hours")), + ), + operations=(_rate_op("Tina", 12.5, num="dollars", denom="hours"),), + unknown=Unknown(entity="Tina", unit="dollars"), + ) + assert infer_question_form(g) == "total" + + +def test_infer_rate_apply_rate_unknown_is_denominator() -> None: + # Unknown.unit == denominator_unit ⇒ rate. + g = _graph( + initial_state=( + InitialPossession(entity="Tina", quantity=Quantity(8, "hours")), + ), + operations=(_rate_op("Tina", 12.5, num="dollars", denom="hours"),), + unknown=Unknown(entity="Tina", unit="hours"), + ) + assert infer_question_form(g) == "rate" + + +def test_infer_identity_no_operations() -> None: + g = _graph(operations=()) + assert infer_question_form(g) == "identity" + + +def test_infer_identity_when_no_operation_touches_unknown_entity() -> None: + # Tina is in entities (has initial possession) but the only operation + # belongs to Sam → no touching op → identity. + g = _graph( + entities=("Tina", "Sam"), + initial_state=( + InitialPossession(entity="Tina", quantity=Quantity(5, "apples")), + InitialPossession(entity="Sam", quantity=Quantity(3, "apples")), + ), + operations=(_add("Sam", 2),), + unknown=Unknown(entity="Tina", unit="apples"), + ) + assert infer_question_form(g) == "identity" + + +# --------------------------------------------------------------------------- +# infer_question_form — precedence on mixed kinds +# --------------------------------------------------------------------------- + + +def test_precedence_compare_multiplicative_beats_additive() -> None: + g = _graph( + entities=("Tina", "Sam"), + initial_state=( + InitialPossession(entity="Tina", quantity=Quantity(5, "apples")), + InitialPossession(entity="Sam", quantity=Quantity(3, "apples")), + ), + operations=( + _cmp_add("Tina", "Sam", 2), + _cmp_mul("Tina", "Sam", 2.0), + ), + ) + assert infer_question_form(g) == "ratio" + + +def test_precedence_compare_additive_beats_count() -> None: + g = _graph( + entities=("Tina", "Sam"), + initial_state=( + InitialPossession(entity="Tina", quantity=Quantity(5, "apples")), + InitialPossession(entity="Sam", quantity=Quantity(3, "apples")), + ), + operations=( + _add("Tina", 4), + _cmp_add("Tina", "Sam", 2), + ), + ) + assert infer_question_form(g) == "difference" + + +def test_precedence_compare_beats_apply_rate() -> None: + g = _graph( + entities=("Tina", "Sam"), + initial_state=( + InitialPossession(entity="Tina", quantity=Quantity(8, "hours")), + InitialPossession(entity="Sam", quantity=Quantity(4, "hours")), + ), + operations=( + _rate_op("Tina", 12.5, "dollars", "hours"), + _cmp_add("Tina", "Sam", 2, unit="hours"), + ), + unknown=Unknown(entity="Tina", unit="hours"), + ) + assert infer_question_form(g) == "difference" + + +def test_precedence_apply_rate_beats_count() -> None: + g = _graph( + initial_state=( + InitialPossession(entity="Tina", quantity=Quantity(8, "hours")), + ), + operations=( + _add("Tina", 1, unit="hours"), + _rate_op("Tina", 12.5, "dollars", "hours"), + ), + unknown=Unknown(entity="Tina", unit="dollars"), + ) + assert infer_question_form(g) == "total" + + +# --------------------------------------------------------------------------- +# infer_question_form — refusals +# --------------------------------------------------------------------------- + + +def test_apply_rate_unit_mismatch_refuses() -> None: + # Unknown unit matches neither numerator nor denominator of any + # touching apply_rate → typed refusal. + g = _graph( + initial_state=( + InitialPossession(entity="Tina", quantity=Quantity(8, "hours")), + ), + operations=(_rate_op("Tina", 12.5, num="dollars", denom="hours"),), + unknown=Unknown(entity="Tina", unit="apples"), + ) + with pytest.raises(QuestionTargetError) as exc: + infer_question_form(g) + assert exc.value.reason == "apply_rate_unit_mismatch" + + +def test_infer_refuses_non_graph_input() -> None: + with pytest.raises(QuestionTargetError) as exc: + infer_question_form(object()) + assert exc.value.reason == "not_a_math_problem_graph" + + +def test_question_target_reasons_locked() -> None: + assert QUESTION_TARGET_REASONS == frozenset( + { + "not_a_math_problem_graph", + "unknown_entity_not_in_entities", + "unmappable_question_form", + "apply_rate_unit_mismatch", + } + ) + + +def test_question_target_error_refuses_unknown_reason() -> None: + with pytest.raises(ValueError): + QuestionTargetError("nonexistent_reason") + + +def test_question_target_error_carries_typed_reason() -> None: + exc = QuestionTargetError("unmappable_question_form", detail="weird shape") + assert exc.reason == "unmappable_question_form" + assert "weird shape" in str(exc) + + +# --------------------------------------------------------------------------- +# bound_unknown_from_math_problem_graph +# --------------------------------------------------------------------------- + + +def test_bound_unknown_terminal_count() -> None: + g = _graph(operations=(_add("Tina", 2),)) + bu = bound_unknown_from_math_problem_graph(g) + assert isinstance(bu, BoundUnknown) + assert bu.symbol_id == "unknown_tina_apples" + assert bu.state_index == "terminal" + assert bu.question_form == "count" + assert bu.expected_unit == "apples" + + +def test_bound_unknown_initial_identity_no_ops() -> None: + g = _graph(operations=()) + bu = bound_unknown_from_math_problem_graph(g) + assert bu.state_index == "initial" + assert bu.question_form == "identity" + + +def test_bound_unknown_total_unknown_entity_none() -> None: + g = _graph( + entities=("Tina", "Sam"), + initial_state=( + InitialPossession(entity="Tina", quantity=Quantity(5, "apples")), + InitialPossession(entity="Sam", quantity=Quantity(3, "apples")), + ), + operations=(_add("Tina", 2), _sub("Sam", 1)), + unknown=Unknown(entity=None, unit="apples"), + ) + bu = bound_unknown_from_math_problem_graph(g) + assert bu.symbol_id == "unknown_total_apples" + assert bu.state_index == "terminal" + assert bu.question_form == "count" + + +def test_bound_unknown_deterministic_byte_equal() -> None: + g = _graph(operations=(_add("Tina", 2),)) + a = bound_unknown_from_math_problem_graph(g) + b = bound_unknown_from_math_problem_graph(g) + assert a == b + + +def test_bound_unknown_refuses_non_graph_input() -> None: + with pytest.raises(QuestionTargetError): + bound_unknown_from_math_problem_graph(123) + + +# --------------------------------------------------------------------------- +# Defensive: input is read-only +# --------------------------------------------------------------------------- + + +def test_resolver_does_not_mutate_input() -> None: + g = _graph(operations=(_add("Tina", 2),)) + before = g.canonical_bytes() + _ = bound_unknown_from_math_problem_graph(g) + after = g.canonical_bytes() + assert before == after + + +# --------------------------------------------------------------------------- +# Parametric coverage of every operation kind in every question_form bucket +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "kind_op", + [ + _add("Tina", 2), + _sub("Tina", 1), + _mul("Tina", 2), + _div("Tina", 2), + ], +) +def test_count_kinds_single_op_terminal_count(kind_op: Operation) -> None: + g = _graph(operations=(kind_op,)) + bu = bound_unknown_from_math_problem_graph(g) + assert bu.question_form == "count" + assert bu.state_index == "terminal" + + +def test_transfer_count_form_for_actor() -> None: + g = _graph( + entities=("Tina", "Sam"), + initial_state=( + InitialPossession(entity="Tina", quantity=Quantity(5, "apples")), + InitialPossession(entity="Sam", quantity=Quantity(3, "apples")), + ), + operations=(_transfer("Tina", "Sam", 1),), + unknown=Unknown(entity="Tina", unit="apples"), + ) + bu = bound_unknown_from_math_problem_graph(g) + assert bu.question_form == "count" + + +def test_transfer_count_form_for_target() -> None: + g = _graph( + entities=("Tina", "Sam"), + initial_state=( + InitialPossession(entity="Tina", quantity=Quantity(5, "apples")), + InitialPossession(entity="Sam", quantity=Quantity(3, "apples")), + ), + operations=(_transfer("Tina", "Sam", 1),), + unknown=Unknown(entity="Sam", unit="apples"), + ) + bu = bound_unknown_from_math_problem_graph(g) + assert bu.question_form == "count" + + +# --------------------------------------------------------------------------- +# Unknown-entity boundary: comparison reference_actor counts as "touching" +# --------------------------------------------------------------------------- + + +def test_comparison_reference_actor_is_touching() -> None: + # Unknown.entity == reference_actor (Sam). The compare_additive op + # touches via the reference_actor edge → difference. + g = _graph( + entities=("Tina", "Sam"), + initial_state=( + InitialPossession(entity="Tina", quantity=Quantity(5, "apples")), + InitialPossession(entity="Sam", quantity=Quantity(3, "apples")), + ), + operations=(_cmp_add("Tina", "Sam", 2),), + unknown=Unknown(entity="Sam", unit="apples"), + ) + bu = bound_unknown_from_math_problem_graph(g) + assert bu.question_form == "difference"