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.
145 lines
5.1 KiB
Python
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
|