Closes ADR-0046's deferred follow-up: convert the PropositionGraph
into an AdmissibilityRegion BEFORE generate() runs on the live
chat path.
== generate/intent_bridge.py ==
New public helper:
build_graph_from_input(text, plan) -> PropositionGraph
Same internal call as _build_graph_from_intent, without the
post-generation ground_graph step — suitable for forward use.
== chat/runtime.py ==
When the new flag is on and output language is English, build the
graph and the region before generate() and pass it via region=.
Empty / fully OOV graphs return AdmissibilityRegion(allowed_indices=None),
which generate() treats as unconstrained — the change is a true
no-op when the graph carries no in-vocab anchors.
== core/config.py ==
RuntimeConfig.forward_graph_constraint: bool = False
Default False preserves all pre-ADR-0046 behaviour and the ADR-0024
honest-refusal contract. A first attempt wired the constraint
unconditionally; 15 tests failed with InnerLoopExhaustion because the
intent-derived graph's CGA neighbourhood doesn't intersect the walk's
candidate pool with top_k=8 on the current packs. The honest answer
is not to widen top_k until the failure goes away nor to silently
relax — both erase the architectural information that the geometry
of the graph and the geometry of the walk are not yet co-located.
Opt-in preserves ADR-0024 and follows the ADR-0022→0026 transition-
window pattern.
== Characterisation (core eval cognition, 13-case public split) ==
A/B with the flag toggled:
Metric OFF ON Δ
intent_accuracy 100.0% 100.0% 0
surface_groundedness 15.4% 15.4% 0
term_capture_rate 0.0% 0.0% 0
versor_closure_rate 100.0% 100.0% 0
InnerLoopExhaustion 0 0 0
non-trivial constraint n/a 6 / 13 —
Findings:
- Wiring is correct and safe (no exhaustions, closure unchanged).
- Single-token in-vocab subjects engage the constraint
(light/knowledge/meaning/memory/correction).
- Multi-word OOV subject phrases produced by the intent classifier
fall through to unconstrained — this is the existing intent-
classifier contract surfacing into geometry, not a constraint bug.
- Restricting which tokens the walk may visit did not change
surface_groundedness or term_capture_rate on this lane. The
surface-grounding gap therefore lives downstream of propagation
— in the realizer / surface-assembly / dialogue-role path — and is
the next load-bearing pull. This isolates the next ADR's scope.
== tests/test_forward_graph_constraint_wiring.py (5 tests) ==
- DEFAULT_CONFIG.forward_graph_constraint is False
- Default runtime answers without InnerLoopExhaustion
- Opt-in runtime answers on a short benign input
- Graph builder + build_graph_constraint produce a labelled
AdmissibilityRegion ("graph:unconstrained" or "graph:<root_id>")
- Flag is observable on the frozen RuntimeConfig
== docs/decisions/ ==
- ADR-0047 ratifies the wire-up, opt-in rationale, and A/B numbers.
- README index updated; the Pillar 1→2→3 section now reflects both
the primitive (ADR-0046) and the live wiring (ADR-0047), and
names the next pull (realizer / surface assembly) explicitly.
Verification (this branch):
tests/test_forward_graph_constraint_wiring.py 5 passed
tests/test_graph_constraint.py 8 passed
core test --suite smoke 67 passed
core test --suite cognition 121 passed
core test --suite runtime 19 passed
core test --suite algebra 132 passed
core test --suite teaching 17 passed
core test --suite packs 6 passed
core eval cognition metrics unchanged from main
versor_condition(F) < 1e-6 invariant unaffected.
9.7 KiB
ADR-0047 — Wire the Forward Graph Constraint into the Chat Hot Path
Status: Accepted Date: 2026-05-18 Author: Shay
Context
ADR-0046 introduced
generate/graph_constraint.py:build_graph_constraint — a function that
converts a PropositionGraph into an AdmissibilityRegion before
generate() runs. ADR-0046 explicitly deferred the hot-path wire-up:
"The coupling between
chat/runtime.pyandbuild_graph_constraintis available but the hot-path wire-up is a follow-up ADR (wire when the intent bridge returns a non-empty graph on the main path)."
This ADR closes that follow-up. As of merge, the only call site that
built a PropositionGraph (generate/intent_bridge.py:articulate_with_intent)
did so after generate() returned, using the walk's recalled tokens
to fill <pending> object slots. The graph still described the field
rather than constraining it.
Decision
-
Expose a public graph builder from
generate/intent_bridge.py:def build_graph_from_input(text: str, plan: ArticulationPlan) -> PropositionGraphSame internal call as
_build_graph_from_intent, just without the post-generationground_graphstep — suitable for forward use. -
Add a config flag on
RuntimeConfig:forward_graph_constraint: bool = FalseDefault
Falsepreserves all pre-ADR-0046 behaviour. -
In
chat/runtime.py, when the flag isTrueand the runtime is operating on the English path, build the graph and the region beforegenerate()and pass it through:forward_region = None if self.config.forward_graph_constraint and self.config.output_language == "en": pre_gen_graph = build_graph_from_input(text, articulation) forward_region = build_graph_constraint(pre_gen_graph, self._context.vocab) result = generate( ..., region=forward_region, ..., )
The post-generation articulate_with_intent path is left alone — it
still grounds <pending> slots from the recalled tokens for surface
realization.
Why opt-in, not always-on
A first attempt wired the constraint unconditionally on the English path and produced 15 test failures across the smoke and cognition suites:
generate.exhaustion.InnerLoopExhaustion:
AdmissibilityRegion[graph:p0] left no walk candidates.
InnerLoopExhaustion (ADR-0024) is doing exactly what it is supposed
to do — refusing honestly when the admissibility region's intersection
with the walk's candidate pool is empty. The finding is that for many
benign inputs the intent-derived graph's CGA neighbourhood does not
intersect the walk's pool with top_k = 8 on the current pack
sizes. Either:
- The graph's anchor surfaces are in vocabulary but their top-k geometric neighbourhood does not overlap with the language/salience- filtered candidate set produced by the walk on that field state, or
- The graph nodes use
<pending>/ function-word slots whose versors are not where the walk is operating.
The honest response to that finding is not to widen top_k until
the failure goes away, and not to silently fall back to
unconstrained — both would erase the architectural information that
the geometry of the graph and the geometry of the walk are not yet
co-located. Opt-in preserves the ADR-0024 refusal contract intact
and lets operators observe the behaviour on their workloads before
deciding whether to make it default-on.
This mirrors the ADR-0022 → ADR-0026 transition-window pattern: ship the capability behind a flag, characterise empirically, then decide on default behaviour in a follow-up.
Characterisation — core eval cognition
A/B run on the 13-case public cognition split, identical
RuntimeConfig except for the flag:
| Metric | Flag OFF | Flag ON | Δ |
|---|---|---|---|
intent_accuracy |
100.0 % | 100.0 % | 0 |
surface_groundedness |
15.4 % | 15.4 % | 0 |
term_capture_rate |
0.0 % | 0.0 % | 0 |
versor_closure_rate |
100.0 % | 100.0 % | 0 |
InnerLoopExhaustion |
0 | 0 | 0 |
| Cases producing non-trivial constraint | n/a | 6 / 13 | — |
Per-case constraint engagement (flag ON):
| Case | Subject | Region |
|---|---|---|
| Why does knowledge require evidence? | "does knowledge require evidence" | graph:unconstrained |
| Why does light exist? | "does light exist" | graph:unconstrained |
| Compare memory and recall | "memory" | graph:p0 |
| No, correction means reviewed repair | ", correction means reviewed repair" | graph:p0 |
| What is knowledge? | "knowledge" | graph:p0 |
| What is light? | "light" | graph:p0 |
| What is meaning? | "meaning" | graph:p0 |
| What is a procedure? | "a procedure" | graph:unconstrained |
| What is a relation? | "a relation" | graph:unconstrained |
| How can I correct an error? | "correct an error" | graph:unconstrained |
| Remember light | "light" | graph:p0 |
| light logos | "light logos" | graph:unconstrained |
| Does memory require recall? | "Does memory require recall?" | graph:unconstrained |
What this tells us
- Wiring is correct and safe. No exhaustions; closure unchanged;
intent classification unchanged. When the graph names a single
in-vocabulary concept (
light,knowledge,meaning,memory,correction), the constraint engages. - Multi-word subject phrases bypass the constraint. The intent
classifier returns the full subject phrase (
"does light exist","a procedure"); the graph builder uses this directly; none of those multi-word surfaces are present inen_core_cognition_v1, so the graph builder produces a node whose subject surface is OOV andbuild_graph_constraintfalls back to unconstrained. This is not a bug in the constraint — it is the existing intent-classifier contract surfacing into the geometry layer. - The cognition lane's grounding gap is not in the candidate set.
Six cases narrow the walk's admissible vocabulary, yet
surface_groundednessandterm_capture_rateare byte-identical. Restricting which tokens the walk may visit did not change what surface gets emitted on this lane. The surface-grounding gap therefore lives downstream of propagation — in the realizer / surface-assembly / dialogue role-resolution path — and is the next load-bearing pull. This isolates the next ADR's scope.
Consequences
What changes
chat/runtime.pynow callsbuild_graph_constraintbeforegenerate()whenforward_graph_constraintis enabled.generate/intent_bridge.pyexposesbuild_graph_from_inputas a public helper._build_graph_from_intentretained for internal use.RuntimeConfiggains one opt-in field; frozen dataclass contract preserved.
What does not change
generate()signature is unchanged (region already accepted via ADR-0022).- Default runtime behaviour is byte-identical to pre-ADR-0047 main.
- ADR-0024 honest-refusal contract is intact — when an operator enables the flag on inputs that produce a too-tight constraint, the runtime refuses rather than silently relaxing.
versor_condition < 1e-6invariant is unaffected (constraint filters indices, not rotor construction).
Scope limits
- The graph builder is English-specific; the runtime guard restricts
forward-constraint computation to
output_language == "en". - The behaviour of the constraint on multi-word OOV subject phrases is the current intent-classifier contract — narrowing it (subject- span normalisation, single-token reduction) is out of scope and a candidate follow-up.
top_k = 8is inherited from ADR-0046's default; this ADR does not re-tune it. An eval that does differentiate flag ON vs OFF will need to land before any tuning is justified.
Cross-References
- ADR-0018 — original
intent_bridgeproducing the post-hoc graph. - ADR-0022 — the
AdmissibilityRegioncontract this ADR feeds into. - ADR-0024 — the honest- refusal contract that fires when the constraint is too tight; the reason this ADR is opt-in.
- ADR-0046 — the constraint primitive that this ADR wires into the live path.
Verification
tests/test_forward_graph_constraint_wiring.py — 5 tests, all green
Lanes (all green on this branch):
core test --suite smoke 67 passed
core test --suite cognition 121 passed
core test --suite runtime 19 passed
core test --suite algebra 132 passed
core test --suite teaching 17 passed
core test --suite packs 6 passed
core eval cognition metrics unchanged from main
(flag OFF default = pre-ADR-0047)
The non-negotiable field invariant (versor_condition(F) < 1e-6)
remains satisfied: this ADR only changes which indices the walk
considers — it does not touch rotor construction, sandwich
application, or field update.