Merge remote-tracking branch 'origin/main'

This commit is contained in:
Shay 2026-05-16 11:00:36 -07:00
commit 5ea47af91a
4 changed files with 290 additions and 11 deletions

View file

@ -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

129
generate/intent_bridge.py Normal file
View file

@ -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 = "<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_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

View file

@ -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

View file

@ -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,