feat(adr-0047): wire forward graph constraint into the chat hot path

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.
This commit is contained in:
Shay 2026-05-18 06:18:10 -07:00
parent 3067e7ddb2
commit f47a85a3e7
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]