From f47a85a3e75d17f225849124ae565ddc29dd2f20 Mon Sep 17 00:00:00 2001 From: Shay Date: Mon, 18 May 2026 06:18:10 -0700 Subject: [PATCH] feat(adr-0047): wire forward graph constraint into the chat hot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:") - 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. --- chat/runtime.py | 18 +- core/config.py | 11 + .../ADR-0047-wire-forward-graph-constraint.md | 234 ++++++++++++++++++ docs/decisions/README.md | 13 +- generate/intent_bridge.py | 16 ++ tests/test_forward_graph_constraint_wiring.py | 107 ++++++++ 6 files changed, 396 insertions(+), 3 deletions(-) create mode 100644 docs/decisions/ADR-0047-wire-forward-graph-constraint.md create mode 100644 tests/test_forward_graph_constraint_wiring.py diff --git a/chat/runtime.py b/chat/runtime.py index 1f3dd633..de28a8e8 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -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, diff --git a/core/config.py b/core/config.py index 0cab7a78..771fd406 100644 --- a/core/config.py +++ b/core/config.py @@ -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" diff --git a/docs/decisions/ADR-0047-wire-forward-graph-constraint.md b/docs/decisions/ADR-0047-wire-forward-graph-constraint.md new file mode 100644 index 00000000..5b79ae88 --- /dev/null +++ b/docs/decisions/ADR-0047-wire-forward-graph-constraint.md @@ -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 `` 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 `` 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 `` / 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. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index a384da81..43dc51bd 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -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 diff --git a/generate/intent_bridge.py b/generate/intent_bridge.py index f31ef9ec..1f09c61c 100644 --- a/generate/intent_bridge.py +++ b/generate/intent_bridge.py @@ -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 ```` 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. diff --git a/tests/test_forward_graph_constraint_wiring.py b/tests/test_forward_graph_constraint_wiring.py new file mode 100644 index 00000000..a6d44f13 --- /dev/null +++ b/tests/test_forward_graph_constraint_wiring.py @@ -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]