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

130 lines
5.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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).