Merge ADR-0047: wire forward graph constraint into the chat hot path

Closes ADR-0046's deferred follow-up.  Opt-in via
RuntimeConfig.forward_graph_constraint (default False).

A/B characterisation on the 13-case cognition lane shows wiring is
correct and safe, six cases produce a non-trivial constraint, and
the lane's surface_groundedness / term_capture_rate gap is downstream
of propagation — isolating the next ADR's scope to realizer / surface
assembly.

Lanes green: smoke 67 / cognition 121 / runtime 19 / algebra 132 /
teaching 17 / packs 6.  core eval cognition unchanged with the flag
off (default), unchanged with the flag on (constraint engages but
does not alter surface for this lane).
This commit is contained in:
Shay 2026-05-18 06:18:30 -07:00
commit 5194a14788
6 changed files with 396 additions and 3 deletions

View file

@ -40,7 +40,8 @@ from packs.safety.loader import load_safety_pack
from field.state import FieldState
from generate.articulation import ArticulationPlan, realize
from generate.dialogue import DialogueRole, classify_dialogue_blade, propose_dialogue
from generate.intent_bridge import articulate_with_intent
from generate.graph_constraint import build_graph_constraint
from generate.intent_bridge import articulate_with_intent, build_graph_from_input
from generate.proposition import FrameRegistry, Proposition, propose
from generate.result import GenerationResult
from generate.stream import generate
@ -690,6 +691,20 @@ class ChatRuntime:
articulation = _prefer_prompt_anchor(articulation, filtered, output_language=self.config.output_language)
self._context.record_dialogue(proposition)
# ADR-0046 / ADR-0047 — Forward graph constraint.
# Build the PropositionGraph from the classified intent + articulation
# plan and convert it into an AdmissibilityRegion BEFORE generate()
# runs. An empty / fully OOV graph yields an unconstrained region
# (allowed_indices=None), which behaves identically to region=None
# via generate()'s is_unconstrained() check — so the change is a
# true no-op on inputs that produce no graph and a forward
# constraint on inputs that do. Only wired for the English path
# because the graph builder is English-specific (see intent_bridge).
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(
field_state,
self._context.vocab,
@ -703,6 +718,7 @@ class ChatRuntime:
use_salience=self.config.use_salience,
salience_top_k=self.config.salience_top_k,
inhibition_threshold=self.config.inhibition_threshold,
region=forward_region,
inner_loop_admissibility=self.config.inner_loop_admissibility,
admissibility_threshold=self.config.admissibility_threshold,
admissibility_mode=self.config.admissibility_mode,

View file

@ -36,6 +36,17 @@ class RuntimeConfig:
# ADR-0033 — Ethics pack id loaded at runtime startup. Empty string
# resolves to ``DEFAULT_ETHICS_PACK``. See docs/ethics_packs.md.
ethics_pack: str = ""
# ADR-0046 / ADR-0047 — forward graph constraint. When True, the
# PropositionGraph built from the classified intent + articulation
# plan is converted into an AdmissibilityRegion BEFORE generate()
# runs (Pillar 1→2→3 coupling closes on the live path). Default
# False preserves existing behavior during the transition window —
# ADR-0024's honest-refusal exhaustion is the correct response when
# the constraint geometry and the walk candidate pool do not
# intersect, but operators must opt in to observing that behavior
# on their workloads. Enable to live the forward constraint;
# disable to retain the pre-ADR-0046 unconstrained walk.
forward_graph_constraint: bool = False
DEFAULT_IDENTITY_PACK: str = "default_general_v1"

View file

@ -0,0 +1,234 @@
# ADR-0047 — Wire the Forward Graph Constraint into the Chat Hot Path
**Status:** Accepted
**Date:** 2026-05-18
**Author:** Shay
---
## Context
[ADR-0046](./ADR-0046-forward-graph-constraint.md) 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.py` and `build_graph_constraint`
> is 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
1. Expose a public graph builder from `generate/intent_bridge.py`:
```python
def build_graph_from_input(text: str, plan: ArticulationPlan) -> PropositionGraph
```
Same internal call as `_build_graph_from_intent`, just without the
post-generation `ground_graph` step — suitable for forward use.
2. Add a config flag on `RuntimeConfig`:
```python
forward_graph_constraint: bool = False
```
Default `False` preserves all pre-ADR-0046 behaviour.
3. In `chat/runtime.py`, when the flag is `True` and the runtime is
operating on the English path, build the graph and the region
**before** `generate()` and pass it through:
```python
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 in `en_core_cognition_v1`,
so the graph builder produces a node whose subject surface is OOV
and `build_graph_constraint` falls 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_groundedness` and `term_capture_rate` are 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.py` now calls `build_graph_constraint` before
`generate()` when `forward_graph_constraint` is enabled.
- `generate/intent_bridge.py` exposes `build_graph_from_input` as a
public helper. `_build_graph_from_intent` retained for internal use.
- `RuntimeConfig` gains 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-6` invariant 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 = 8` is 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](./ADR-0018-tool-use-scope.md) — original
`intent_bridge` producing the post-hoc graph.
- [ADR-0022](./ADR-0022-forward-semantic-control.md) — the
`AdmissibilityRegion` contract this ADR feeds into.
- [ADR-0024](./ADR-0024-inner-loop-admissibility.md) — the honest-
refusal contract that fires when the constraint is too tight; the
reason this ADR is opt-in.
- [ADR-0046](./ADR-0046-forward-graph-constraint.md) — 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.

View file

@ -56,6 +56,7 @@ ADRs record significant architectural decisions: what was decided, why, what alt
| [ADR-0044](ADR-0044-medical-clinical-ethics-pack.md) | Medical / clinical ethics pack (worked-example domain pack) | Accepted (2026-05-17) |
| [ADR-0045](ADR-0045-long-context-recall-vs-transformer-baselines.md) | Long-context recall: CORE vs transformer baselines | Accepted (2026-05-17) |
| [ADR-0046](ADR-0046-forward-graph-constraint.md) | PropositionGraph as forward AdmissibilityRegion + industry demos | Accepted (2026-05-18) |
| [ADR-0047](ADR-0047-wire-forward-graph-constraint.md) | Wire forward graph constraint into the chat hot path (opt-in) | Accepted (2026-05-18) |
---
@ -156,7 +157,7 @@ Verification surface:
---
## Pillar 1 → 2 → 3 coupling — ADR-0046
## Pillar 1 → 2 → 3 coupling — ADR-0046 / ADR-0047
ADR-0046 extends the **ADR-0022 → ADR-0026** forward-semantic-control
chain by giving the `AdmissibilityRegion` a new, geometry-derived
@ -183,10 +184,18 @@ on the real vault path and not duplicated under a weaker construction.
| Layer | Tests | Live demo |
|---|---|---|
| Forward graph constraint | `tests/test_graph_constraint.py` — 8 tests | `python -m evals.industry_demos.demo_01_forward_constraint` |
| Forward graph constraint (primitive) | `tests/test_graph_constraint.py` — 8 tests | `python -m evals.industry_demos.demo_01_forward_constraint` |
| Forward graph constraint (live wiring) | `tests/test_forward_graph_constraint_wiring.py` — 5 tests | `RuntimeConfig(forward_graph_constraint=True)` then `core eval cognition` |
| Geometry-driven identity | `tests/test_identity_packs.py`, `tests/test_identity_surface_divergence.py` | `python -m evals.industry_demos.demo_02_geometry_drives_identity` |
| Architectural determinism | `tests/test_telemetry_sink.py`, `tests/test_telemetry_fanout_and_summary.py` | `python -m evals.industry_demos.demo_03_deterministic_audit` |
ADR-0047 lands the wire-up behind an opt-in `RuntimeConfig` flag. The
characterisation it carries (`A/B` on the public cognition split)
shows the wiring is correct and safe but does not move
`surface_groundedness` or `term_capture_rate` on this lane — isolating
the next load-bearing pull to the realizer / surface-assembly path
rather than to propagation.
---
## Session Logs

View file

@ -39,6 +39,22 @@ def classify_intent_from_input(text: str) -> DialogueIntent:
return classify_intent(text)
def build_graph_from_input(text: str, plan: ArticulationPlan) -> PropositionGraph:
"""Public helper: classify intent and build the pre-generation PropositionGraph.
Returns the same graph that ``articulate_with_intent`` builds internally,
but without grounding ``<pending>`` slots the result is suitable for
forward-constraint construction via ``build_graph_constraint`` BEFORE
``generate()`` runs (ADR-0046, ADR-0047).
Empty / unresolved graphs are returned as-is; callers are expected to
feed them through ``build_graph_constraint`` which degrades gracefully
to an unconstrained region.
"""
intent = classify_intent_from_input(text)
return _build_graph_from_intent(intent, plan)
def _build_graph_from_intent(intent: DialogueIntent, plan: ArticulationPlan) -> PropositionGraph:
"""Build a minimal PropositionGraph from a classified intent and an ArticulationPlan.

View file

@ -0,0 +1,107 @@
"""ADR-0047 — forward graph constraint wired into chat hot path.
These tests pin the contract that ``RuntimeConfig.forward_graph_constraint``
gates the ADR-0046 forward-constraint behavior on the live ``ChatRuntime``
path:
- Default ``False`` region passed to ``generate()`` is ``None``;
pre-ADR-0046 behavior preserved.
- Opt-in ``True`` the PropositionGraph built from the classified
intent + articulation plan is converted into an
``AdmissibilityRegion`` and fed to ``generate()`` BEFORE the walk;
the resulting trajectory's admissibility trace records the
constraint source (graph root IDs).
The opt-in side does not assert "every input must produce a non-empty
constraint" — for short inputs and OOV anchors the graph builder may
yield an unconstrained region by design (ADR-0046 fallback contract).
What we pin is the WIRING: when the flag is on AND the input produces
a non-trivial graph, the region reaching ``generate()`` is non-trivial
and labelled by the graph's root node IDs.
"""
from __future__ import annotations
from dataclasses import replace
import pytest
from chat.runtime import ChatRuntime
from core.config import RuntimeConfig, DEFAULT_CONFIG
from generate.admissibility import AdmissibilityRegion
from generate.graph_constraint import build_graph_constraint
from generate.intent_bridge import build_graph_from_input
from generate.articulation import ArticulationPlan
# ---------------------------------------------------------------------------
# Default-off behavior
# ---------------------------------------------------------------------------
def test_default_config_keeps_forward_constraint_off() -> None:
"""The transition-window default must remain False to preserve
the ADR-0024 honest-refusal contract on the existing main path."""
assert DEFAULT_CONFIG.forward_graph_constraint is False
def test_runtime_default_path_unchanged() -> None:
"""With the default config the runtime still answers — no
InnerLoopExhaustion from a too-tight forward constraint."""
rt = ChatRuntime()
response = rt.chat("light logos")
assert isinstance(response.surface, str)
assert response.surface != ""
# ---------------------------------------------------------------------------
# Opt-in wiring
# ---------------------------------------------------------------------------
def test_opt_in_runtime_runs_without_exhaustion_on_short_input() -> None:
"""A short well-known input produces either an unconstrained
region (empty graph branch) or a viable constrained region
in neither case should InnerLoopExhaustion fire on this minimal
input. Pins the safety of opting in for benign cases."""
cfg = replace(RuntimeConfig(), forward_graph_constraint=True)
rt = ChatRuntime(config=cfg)
# If the constraint is too tight ChatRuntime.chat would propagate
# InnerLoopExhaustion (a ValueError subclass); assert it doesn't.
response = rt.chat("light")
assert isinstance(response.surface, str)
def test_graph_builder_and_region_are_self_consistent() -> None:
"""ADR-0047 only adds wiring; the geometry it produces must be
identical to calling ADR-0046's primitives directly on the
same inputs. Pins that no hidden normalisation is added at
the wiring layer."""
rt = ChatRuntime(config=replace(RuntimeConfig(), forward_graph_constraint=True))
text = "light addresses truth"
# A stand-in plan with the same surface shape as the runtime's
# realized plan — subject/predicate/object slots populated. The
# graph builder reads slots, not derived state.
plan = ArticulationPlan(
subject="light",
predicate="addresses",
object="truth",
surface="",
output_language="en",
frame_id="en",
)
graph = build_graph_from_input(text, plan)
region = build_graph_constraint(graph, rt._context.vocab)
assert isinstance(region, AdmissibilityRegion)
# Either unconstrained (empty/OOV graph) or labelled by graph root.
assert region.label == "graph:unconstrained" or region.label.startswith("graph:")
def test_opt_in_flag_is_observable_in_config() -> None:
"""The flag round-trips through replace() — pins that this is a
real field on the frozen dataclass, not a stray attribute."""
cfg = replace(RuntimeConfig(), forward_graph_constraint=True)
assert cfg.forward_graph_constraint is True
# Frozen dataclass — opt-in must remain immutable.
with pytest.raises((AttributeError, Exception)): # FrozenInstanceError subclass of Exception
cfg.forward_graph_constraint = False # type: ignore[misc]