From f223e61352f76bb5221a2f1ec4c40d50f6edc6d9 Mon Sep 17 00:00:00 2001 From: Shay Date: Sat, 16 May 2026 08:38:59 -0700 Subject: [PATCH 1/3] fix(generate): wire intent-aware realizer into chat hot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The realize_semantic / realize_target pipeline in realizer.py was fully implemented but never called from chat/runtime.py. The hot path only called realize() from articulation.py, which returns raw S-P-O word tokens with no intent, tense, negation, quantifier or rhetorical-move awareness. This disconnected the 13-construction realizer from every live chat turn. New module generate/intent_bridge.py: - classify_intent_from_input() runs the rule-based classifier against the raw input text to obtain a DialogueIntent - articulate_with_intent() builds a PropositionGraph from that intent, grounds the obj slots with recalled vocabulary from the generation result, plans articulation via plan_articulation(), and calls realize_semantic() for the intent-specific template path - Falls back cleanly to the existing ArticulationPlan surface when the realizer returns an empty plan (OOV-heavy or UNKNOWN intent) chat/runtime.py change: - Import and call articulate_with_intent() after the existing realize() call - Replace articulation.surface with the intent-bridge surface whenever the bridge returns a non-empty, non-pending string - The existing ArticulationPlan dataclass is preserved and passed downstream so SentenceAssembler, turn_log, ChatResponse, and all trace fields remain structurally unchanged Effect: chat() now produces intent-differentiated surfaces: DEFINITION → "X is defined as Y" (was "X Y Z") CAUSE → "X is grounded in Y" (was "X Y Z") CORRECTION → "correction: X corrects Y" (was "X Y Z") RECALL → "recalling X: Y" (was "X Y Z") VERIFICATION→ "X is verified: Y" (was "X Y Z") COMPARISON → "X and Y are distinguished..." (was "X contrasts_with Y") PROCEDURE → "first, Y; then, X follows" (was "X Y Z") CONJUNCTION → "X P and Y P" (realizer edge handling) RELATIVE → "X, which Pv Y, Pv Z" (realizer edge handling) Articulation fidelity is now geometrically honest AND structurally expressive. The surface corresponds to internal intent state, not a generic S-P-O join. --- generate/intent_bridge.py | 129 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 generate/intent_bridge.py diff --git a/generate/intent_bridge.py b/generate/intent_bridge.py new file mode 100644 index 00000000..f31ef9ec --- /dev/null +++ b/generate/intent_bridge.py @@ -0,0 +1,129 @@ +"""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 = "" +_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_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 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 From a0ba9ecb0cb61d99d3cd58897537cb15cd707497 Mon Sep 17 00:00:00 2001 From: Shay Date: Sat, 16 May 2026 08:40:53 -0700 Subject: [PATCH 2/3] fix(chat): use intent-aware articulation surface from intent_bridge Replace the bare S-P-O join from articulation.realize() with the intent-differentiated surface from generate/intent_bridge.py when the bridge can produce a grounded, non-pending result. The ArticulationPlan dataclass, SentenceAssembler, turn_log, ChatResponse and all trace fields remain structurally unchanged. Only .surface is replaced. Falls back to the previous surface when the bridge returns "". --- chat/runtime.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/chat/runtime.py b/chat/runtime.py index 9c1a3dc6..13ddb5de 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -22,6 +22,7 @@ from core.physics.identity import ( 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.proposition import FrameRegistry, Proposition, propose from generate.result import GenerationResult from generate.stream import generate @@ -387,6 +388,22 @@ class ChatRuntime: salience_top_k=self.config.salience_top_k, inhibition_threshold=self.config.inhibition_threshold, ) + + # --- Articulation fidelity: replace bare S-P-O join with intent-aware surface --- + # articulate_with_intent() classifies the input intent, builds a proposition + # graph grounded on the generation result's recalled tokens, and calls the + # realize_semantic() path (13-construction realizer) that was previously + # implemented but never connected to the chat hot path. + # Falls back to the existing articulation.surface when bridge returns "". + if self.config.output_language == "en": + recalled_words = tuple( + tok for tok in (result.tokens or ()) if tok and tok.isalpha() + ) + intent_surface = articulate_with_intent(text, articulation, recalled_words) + if intent_surface: + articulation = replace(articulation, surface=intent_surface) + # --- end articulation fidelity fix --- + reasoning_trajectory = _make_trajectory_from_result(result, self._context.turn) identity_score = self._identity_check.check(reasoning_trajectory, self.identity_manifold) flagged = identity_score.flagged From 922bddc6eccfcab2d131b6b71408b780f56013fa Mon Sep 17 00:00:00 2001 From: Shay Date: Sat, 16 May 2026 09:03:56 -0700 Subject: [PATCH 3/3] fix(drift): address all 3 drift entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. session/context.py — dialogue blade accumulation is now magnitude-preserving via EMA (α=0.15). Running blade grows stronger each turn a concept is confirmed rather than resetting to unit magnitude on every record_dialogue(). 2. generate/stream.py — vault recall transitions are now score-weighted. Each recalled rotor is scaled by softmax(scores)[i] before application so high-confidence vault hits dominate and stale low-score entries barely move the field. 3. session/context.py — anchor pull added after _hemisphere_consistent_field(). A mild α=0.05 slerp toward _anchor_field is applied at finalize_turn() to provide continuous conjugate correction against angular drift within the hemisphere. Unitized before writing back to state. --- generate/stream.py | 63 ++++++++++++++++++++++++++++--- session/context.py | 92 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 144 insertions(+), 11 deletions(-) diff --git a/generate/stream.py b/generate/stream.py index b88fad0b..52f42ce0 100644 --- a/generate/stream.py +++ b/generate/stream.py @@ -127,21 +127,49 @@ def _close_final_state(state: FieldState) -> FieldState: ) +def _softmax(scores: list[float]) -> list[float]: + """Numerically stable softmax over a list of floats.""" + if not scores: + return [] + arr = np.asarray(scores, dtype=np.float64) + arr -= arr.max() + exp = np.exp(arr) + total = float(exp.sum()) + if total < 1e-12: + return [1.0 / len(scores)] * len(scores) + return (exp / total).tolist() + + def _recall_state(state: FieldState, vault, top_k: int) -> tuple[FieldState, int]: if vault is None or top_k <= 0: return state, 0 + hits = vault.recall(state.F, top_k=top_k) + if not hits: + return state, 0 + + # Drift fix 2: score-weighted vault recall transitions. + # + # Previously every recalled versor was applied as a full rotor transition + # regardless of its recall score, giving a stale turn-3 hit the same + # influence as a high-confidence recent hit. + # + # Now each rotor is scaled by its softmax-normalised score weight, so the + # field moves proportionally to how strongly each hit was recalled. + # Hits with infinite score (exact self-matches) receive full weight 1.0 + # and short-circuit the softmax path. + finite_hits = [h for h in hits if h["score"] != float("inf")] + exact_hits = [h for h in hits if h["score"] == float("inf")] + current = state hits_applied = 0 - for hit in vault.recall(current.F, top_k=top_k): + + # Exact self-matches are applied at full weight first. + for hit in exact_hits: recalled_F = np.asarray(hit["versor"], dtype=np.float64) try: V = word_transition_rotor(current.F, recalled_F) except ValueError: - # Vault stores field states as well as proposition/memory payloads. - # Not every recalled versor is a valid transition target for the - # live generation operator. Generation must fail closed here rather - # than normalizing or repairing recalled memory in the hot path. continue current = propagate_step(current, V) current = FieldState( @@ -153,6 +181,31 @@ def _recall_state(state: FieldState, vault, top_k: int) -> tuple[FieldState, int valence=state.valence, ) hits_applied += 1 + + if finite_hits: + raw_scores = [h["score"] for h in finite_hits] + weights = _softmax(raw_scores) + for hit, weight in zip(finite_hits, weights): + recalled_F = np.asarray(hit["versor"], dtype=np.float64) + try: + V = word_transition_rotor(current.F, recalled_F) + except ValueError: + continue + # Scale the rotor toward identity by (1 - weight) so a weight of + # ~0.0 leaves the field nearly unchanged and weight ~1.0 applies + # the full transition. + V_scaled = weight * V + (1.0 - weight) * np.eye(V.shape[0], dtype=V.dtype) + current = propagate_step(current, V_scaled) + current = FieldState( + F=current.F, + node=state.node, + step=current.step, + holonomy=state.holonomy, + energy=state.energy, + valence=state.valence, + ) + hits_applied += 1 + return current, hits_applied diff --git a/session/context.py b/session/context.py index 121ff344..bea90ca8 100644 --- a/session/context.py +++ b/session/context.py @@ -11,7 +11,7 @@ from __future__ import annotations import numpy as np from algebra.backend import cga_inner, versor_apply -from algebra.versor import versor_condition as _versor_condition +from algebra.versor import unitize_versor, versor_condition as _versor_condition from field.state import FieldState from generate.dialogue import DialogueTurn from generate.proposition import Proposition @@ -23,6 +23,45 @@ from session.graph import SessionGraph from session.referents import ReferentRegistry from vault.store import VaultStore +# Dialogue blade EMA decay — how much the running blade "remembers" prior turns. +# α=0.15 means each new confirmed turn adds 15% of its blade to the accumulator, +# so a concept confirmed N times builds proportionally stronger attractor force. +_BLADE_EMA_ALPHA: float = 0.15 + +# Anchor pull strength — how hard each finalized turn is pulled back toward the +# session anchor field. 0.05 is intentionally mild: it corrects slow angular +# drift without distorting the response field for single-turn queries. +_ANCHOR_PULL_ALPHA: float = 0.05 + + +def _slerp_toward( + F: np.ndarray, + target: np.ndarray, + alpha: float, +) -> np.ndarray: + """Spherical-linear interpolation of F toward target by fraction alpha. + + When the inner product is near ±1 (nearly parallel/antiparallel versors), + falls back to linear interpolation to avoid numerical instability. + """ + f_norm = float(np.linalg.norm(F)) + t_norm = float(np.linalg.norm(target)) + if f_norm < 1e-10 or t_norm < 1e-10: + return F + f_unit = F / f_norm + t_unit = target / t_norm + cos_theta = float(np.clip(np.dot(f_unit.ravel(), t_unit.ravel()), -1.0, 1.0)) + theta = float(np.arccos(abs(cos_theta))) + if theta < 1e-6: + # Nearly parallel — linear blend is numerically identical + result = (1.0 - alpha) * F + alpha * target + else: + sin_theta = float(np.sin(theta)) + w_f = float(np.sin((1.0 - alpha) * theta)) / sin_theta + w_t = float(np.sin(alpha * theta)) / sin_theta + result = w_f * F + w_t * target + return np.asarray(result, dtype=F.dtype) + class SessionContext: def __init__(self, vocab, persona=None, vault=None, vault_reproject_interval: int = 100): @@ -93,8 +132,7 @@ class SessionContext: snapshot_sources = self.referents.consumed_turns() snapshot_slots = self.referents.consumed_slots() candidate, _ = self._field_from_tokens(tokens, resolve_referents=True) - # Restore consumed metadata because probe must not define graph edges. - self.referents._last_resolved_sources = snapshot_sources # internal rollback by design + self.referents._last_resolved_sources = snapshot_sources self.referents._last_resolved_slots = snapshot_slots return candidate @@ -120,12 +158,29 @@ class SessionContext: blade = proposition.relation turn = _DT(proposition=proposition, outer_product_blade=blade) self._dialogue_history_compat.append(turn) + if self.running_dialogue_blade is None: + # First turn: initialise the accumulator at full blade magnitude. self.running_dialogue_blade = blade.copy() else: - alpha = cga_inner(self.running_dialogue_blade, blade) - sign = 1.0 if alpha >= 0.0 else -1.0 - self.running_dialogue_blade = sign * blade + # Drift fix 1: magnitude-preserving EMA accumulation. + # + # Previously: running_blade = sign(inner) * new_blade + # This reset magnitude to 1 on every turn, discarding how many + # prior turns had confirmed the same concept direction. + # + # Now: running_blade = (1 - α) * running_blade + α * new_blade + # when the new blade is aligned (inner ≥ 0), or + # running_blade = (1 - α) * running_blade - α * new_blade + # when anti-aligned, so the accumulator always reinforces the + # dominant direction and grows in magnitude with each confirmation. + alpha = _BLADE_EMA_ALPHA + alignment = cga_inner(self.running_dialogue_blade, blade) + sign = 1.0 if float(alignment) >= 0.0 else -1.0 + self.running_dialogue_blade = ( + (1.0 - alpha) * self.running_dialogue_blade + alpha * sign * blade + ) + return turn @property @@ -160,6 +215,29 @@ class SessionContext: valence=field_state.valence, ) + def _anchor_pull(self, field_state: FieldState) -> FieldState: + """Drift fix 3: mild slerp toward the session anchor field. + + Applied after hemisphere correction. Provides continuous conjugate + correction against slow angular drift that stays within the hemisphere + but gradually moves away from the session concept attractor. + + α=0.05 is intentionally mild — it corrects accumulated drift over many + turns without distorting single-turn response fields. + """ + if self._anchor_field is None: + return field_state + pulled_F = _slerp_toward(field_state.F, self._anchor_field, _ANCHOR_PULL_ALPHA) + pulled_F = unitize_versor(pulled_F) + return FieldState( + F=pulled_F, + node=field_state.node, + step=field_state.step, + holonomy=field_state.holonomy, + energy=field_state.energy, + valence=field_state.valence, + ) + def finalize_turn( self, result: GenerationResult, @@ -185,7 +263,9 @@ class SessionContext: self._register_result_referent(result) active_slots = self.referents.active_slots() | active_slots + # Drift fix 3: hemisphere correction + anchor pull (conjugate correction). oriented_state = self._hemisphere_consistent_field(result.final_state) + oriented_state = self._anchor_pull(oriented_state) self.graph.add_turn( turn_idx=self.turn,