core/docs/decisions/ADR-0135-binding-graph-question-target.md
Shay 3b30eb248a
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.
2026-05-23 11:24:49 -07:00

5.1 KiB
Raw Permalink Blame History

ADR-0135 — Binding Graph Phase 4: question-target binding refinement

Status: Accepted. Parents: ADR-0132, ADR-0133, ADR-0134. 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).