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:
parent
6cbaa74076
commit
3b30eb248a
8 changed files with 1550 additions and 28 deletions
130
docs/decisions/ADR-0135-binding-graph-question-target.md
Normal file
130
docs/decisions/ADR-0135-binding-graph-question-target.md
Normal 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 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).
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
274
generate/binding_graph/question_target.py
Normal file
274
generate/binding_graph/question_target.py
Normal 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",
|
||||
)
|
||||
446
tests/test_binding_graph_adapter_question_target.py
Normal file
446
tests/test_binding_graph_adapter_question_target.py
Normal 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)
|
||||
|
|
@ -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()),
|
||||
)
|
||||
|
|
|
|||
550
tests/test_binding_graph_question_target.py
Normal file
550
tests/test_binding_graph_question_target.py
Normal 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"
|
||||
Loading…
Reference in a new issue