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 field.state import FieldState
|
||||||
from generate.articulation import ArticulationPlan, realize
|
from generate.articulation import ArticulationPlan, realize
|
||||||
from generate.dialogue import DialogueRole, classify_dialogue_blade, propose_dialogue
|
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.proposition import FrameRegistry, Proposition, propose
|
||||||
from generate.result import GenerationResult
|
from generate.result import GenerationResult
|
||||||
from generate.stream import generate
|
from generate.stream import generate
|
||||||
|
|
@ -387,6 +388,22 @@ class ChatRuntime:
|
||||||
salience_top_k=self.config.salience_top_k,
|
salience_top_k=self.config.salience_top_k,
|
||||||
inhibition_threshold=self.config.inhibition_threshold,
|
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)
|
reasoning_trajectory = _make_trajectory_from_result(result, self._context.turn)
|
||||||
identity_score = self._identity_check.check(reasoning_trajectory, self.identity_manifold)
|
identity_score = self._identity_check.check(reasoning_trajectory, self.identity_manifold)
|
||||||
flagged = identity_score.flagged
|
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]:
|
def _recall_state(state: FieldState, vault, top_k: int) -> tuple[FieldState, int]:
|
||||||
if vault is None or top_k <= 0:
|
if vault is None or top_k <= 0:
|
||||||
return state, 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
|
current = state
|
||||||
hits_applied = 0
|
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)
|
recalled_F = np.asarray(hit["versor"], dtype=np.float64)
|
||||||
try:
|
try:
|
||||||
V = word_transition_rotor(current.F, recalled_F)
|
V = word_transition_rotor(current.F, recalled_F)
|
||||||
except ValueError:
|
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
|
continue
|
||||||
current = propagate_step(current, V)
|
current = propagate_step(current, V)
|
||||||
current = FieldState(
|
current = FieldState(
|
||||||
|
|
@ -153,6 +181,31 @@ def _recall_state(state: FieldState, vault, top_k: int) -> tuple[FieldState, int
|
||||||
valence=state.valence,
|
valence=state.valence,
|
||||||
)
|
)
|
||||||
hits_applied += 1
|
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
|
return current, hits_applied
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ from __future__ import annotations
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from algebra.backend import cga_inner, versor_apply
|
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 field.state import FieldState
|
||||||
from generate.dialogue import DialogueTurn
|
from generate.dialogue import DialogueTurn
|
||||||
from generate.proposition import Proposition
|
from generate.proposition import Proposition
|
||||||
|
|
@ -23,6 +23,45 @@ from session.graph import SessionGraph
|
||||||
from session.referents import ReferentRegistry
|
from session.referents import ReferentRegistry
|
||||||
from vault.store import VaultStore
|
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:
|
class SessionContext:
|
||||||
def __init__(self, vocab, persona=None, vault=None, vault_reproject_interval: int = 100):
|
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_sources = self.referents.consumed_turns()
|
||||||
snapshot_slots = self.referents.consumed_slots()
|
snapshot_slots = self.referents.consumed_slots()
|
||||||
candidate, _ = self._field_from_tokens(tokens, resolve_referents=True)
|
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
|
||||||
self.referents._last_resolved_sources = snapshot_sources # internal rollback by design
|
|
||||||
self.referents._last_resolved_slots = snapshot_slots
|
self.referents._last_resolved_slots = snapshot_slots
|
||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
|
|
@ -120,12 +158,29 @@ class SessionContext:
|
||||||
blade = proposition.relation
|
blade = proposition.relation
|
||||||
turn = _DT(proposition=proposition, outer_product_blade=blade)
|
turn = _DT(proposition=proposition, outer_product_blade=blade)
|
||||||
self._dialogue_history_compat.append(turn)
|
self._dialogue_history_compat.append(turn)
|
||||||
|
|
||||||
if self.running_dialogue_blade is None:
|
if self.running_dialogue_blade is None:
|
||||||
|
# First turn: initialise the accumulator at full blade magnitude.
|
||||||
self.running_dialogue_blade = blade.copy()
|
self.running_dialogue_blade = blade.copy()
|
||||||
else:
|
else:
|
||||||
alpha = cga_inner(self.running_dialogue_blade, blade)
|
# Drift fix 1: magnitude-preserving EMA accumulation.
|
||||||
sign = 1.0 if alpha >= 0.0 else -1.0
|
#
|
||||||
self.running_dialogue_blade = sign * blade
|
# 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
|
return turn
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -160,6 +215,29 @@ class SessionContext:
|
||||||
valence=field_state.valence,
|
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(
|
def finalize_turn(
|
||||||
self,
|
self,
|
||||||
result: GenerationResult,
|
result: GenerationResult,
|
||||||
|
|
@ -185,7 +263,9 @@ class SessionContext:
|
||||||
self._register_result_referent(result)
|
self._register_result_referent(result)
|
||||||
active_slots = self.referents.active_slots() | active_slots
|
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._hemisphere_consistent_field(result.final_state)
|
||||||
|
oriented_state = self._anchor_pull(oriented_state)
|
||||||
|
|
||||||
self.graph.add_turn(
|
self.graph.add_turn(
|
||||||
turn_idx=self.turn,
|
turn_idx=self.turn,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue