feat(binding-graph): Phase 4 question-target binding (ADR-0135) (#179)

Refines BoundUnknown from "the symbol whose value the solver determines"
to "the symbol at a specific temporal/state index with a specific
question-form". Two new required fields on BoundUnknown — state_index
(initial/terminal/Operation(operation_index)) and question_form
(count/rate/total/difference/ratio/identity) — populated by the new
pure-function resolver in generate/binding_graph/question_target.py.

The adapter (ADR-0133) now delegates Unknown -> BoundUnknown construction
to bound_unknown_from_math_problem_graph. No runtime wiring, no solver
invocation. Phase 5 (bounded-grammar / B3 integration) remains deferred.

Refusal-first via the new QuestionTargetError (sibling of AdapterError /
AdmissibilityError). Closed reason vocab: not_a_math_problem_graph,
unknown_entity_not_in_entities, apply_rate_unit_mismatch,
unmappable_question_form. Closed precedence rule on question_form
documented in ADR-0135 (compare_multiplicative > compare_additive >
apply_rate{numerator|denominator unit-match} > count); ambiguity refuses.

SemanticSymbolicBindingGraph.__post_init__ gains a cross-collection
guard: Operation(operation_index) must satisfy operation_index <
len(equations). canonical_string emission widened to include
state=... form=... tokens (hash differs from Phase 3 main by design —
not a regression; byte-equal across runs preserved).

Parents: ADR-0132 / ADR-0133 / ADR-0134.

Tests: +70 new (45 unit in test_binding_graph_question_target.py +
25 integration in test_binding_graph_adapter_question_target.py); 5
Phase 1+3 BoundUnknown fixtures migrated. Total binding-graph lane
295/1 pass (1 pre-existing test_symbol_binding_uses_slots failure on
Python 3.14, unrelated to Phase 4 — exists on origin/main). Pyright
clean on new and modified files. No edits to algebra/, chat/, core/,
or runtime hot path. Field invariant untouched.
This commit is contained in:
Shay 2026-05-23 11:24:49 -07:00 committed by GitHub
parent 6cbaa74076
commit 3b30eb248a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1550 additions and 28 deletions

View file

@ -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 13 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 13 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).

View file

@ -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",

View file

@ -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(

View file

@ -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:

View file

@ -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",
)

View file

@ -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)

View file

@ -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()),
)

View file

@ -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"