Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
5ea47af91a
4 changed files with 290 additions and 11 deletions
|
|
@ -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
129
generate/intent_bridge.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue