core/generate/intent_bridge.py
Shay f47a85a3e7 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.
2026-05-18 06:18:10 -07:00

145 lines
5.1 KiB
Python

"""generate/intent_bridge.py — connects intent classification to the realizer.
Bridges the gap between chat/runtime.py's articulation path (which resolves
Proposition slot-versors into raw word tokens) and the intent-aware realizer
pipeline (realize_semantic / realize_target in realizer.py, which are fully
implemented but were never called from the chat hot path).
Design constraints:
- Deterministic: same input text + same field state → same surface
- No LLM fallback
- Falls back cleanly to the existing ArticulationPlan when the realizer
cannot produce a non-empty surface (OOV-heavy input, UNKNOWN intent
with no grounded obj slots)
- Does not alter the ArticulationPlan dataclass or ChatResponse structure;
only the .surface field is replaced when the bridge succeeds
"""
from __future__ import annotations
from generate.articulation import ArticulationPlan
from generate.graph_planner import (
GraphEdge,
GraphNode,
PropositionGraph,
Relation,
ground_graph,
plan_articulation,
)
from generate.intent import DialogueIntent, IntentTag, classify_intent
from generate.realizer import RealizedPlan, realize_semantic
_PENDING = "<pending>"
_PRIOR = "<prior>"
_EMPTY_INDICATORS = frozenset({_PENDING, _PRIOR, "...", ""})
def classify_intent_from_input(text: str) -> DialogueIntent:
"""Run the rule-based intent classifier against raw input text."""
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.
Uses the resolved slot words from ArticulationPlan (subject, predicate, object)
as the concrete node content, with the intent tag selecting the predicate.
"""
from generate.graph_planner import _INTENT_PREDICATES # noqa: PLC0415
predicate = _INTENT_PREDICATES.get(intent.tag, "addresses")
subject = intent.subject or plan.subject or ""
obj = plan.object or plan.predicate or _PENDING
graph = PropositionGraph()
if intent.tag is IntentTag.COMPARISON:
secondary = intent.secondary_subject or plan.object or plan.predicate or obj
left = GraphNode(
node_id="p0",
subject=subject,
predicate=predicate,
obj=secondary,
source_intent=intent.tag,
)
right = GraphNode(
node_id="p1",
subject=secondary,
predicate=predicate,
obj=subject,
source_intent=intent.tag,
)
edge = GraphEdge(source="p0", target="p1", relation=Relation.CONTRAST)
return graph.add_node(left).add_node(right).add_edge(edge)
root = GraphNode(
node_id="p0",
subject=subject,
predicate=predicate,
obj=obj,
source_intent=intent.tag,
)
return graph.add_node(root)
def _is_useful_surface(surface: str) -> bool:
"""Return True when the realized surface is non-empty and fully grounded."""
if not surface or not surface.strip():
return False
for indicator in _EMPTY_INDICATORS:
if indicator and indicator in surface:
return False
return True
def articulate_with_intent(
text: str,
plan: ArticulationPlan,
recalled_words: tuple[str, ...] = (),
) -> str:
"""Return an intent-aware surface string for *plan*, or "" if none can be produced.
Steps:
1. Classify intent from raw input *text*
2. Build a PropositionGraph from the intent + ArticulationPlan slot words
3. Ground <pending> obj slots with *recalled_words* from generation result
4. Plan articulation (topological walk)
5. Realize via realize_semantic() for intent-specific templates
6. Return the surface, or "" if the result is empty / ungrounded
The caller (chat/runtime.py) should fall back to the existing
ArticulationPlan.surface when this returns "".
"""
intent = classify_intent_from_input(text)
graph = _build_graph_from_intent(intent, plan)
if recalled_words:
graph = ground_graph(graph, recalled_words)
articulation_target = plan_articulation(graph)
realized: RealizedPlan = realize_semantic(articulation_target, graph)
if not realized.surface or not realized.fragments:
return ""
surface = realized.surface
if not _is_useful_surface(surface):
return ""
return surface