clawft_kernel/talk_loop.rs
1//! Talk-Mode tick service — the orchestration heart of the ECC graph-walk voice
2//! conversation (ADR-062 §Architecture, Phase 2.1).
3//!
4//! A conversation turn is a **walk that mutates a causal graph**; this loop is
5//! the driver. It is modeled on the dormant [`DemocritusLoop`](crate::democritus)
6//! (SENSE → … → COMMIT on a compute budget) but specialised to turn-taking:
7//!
8//! ```text
9//! SENSE = ImpulseQueue.drain_ready() (HLC-ordered turn signals)
10//! FLOOR = evaluate_floor / compute_urgency over (a scored read, ADR-062 D4)
11//! SessionView.live_seqs(), dampened by (coherence soft-dampener D6)
12//! MUTATE = per impulse:
13//! EndOfUtterance → commit (Frontier→Committed via mirror_state)
14//! TurnShift → handoff (floor Open; clear the in-flight turn)
15//! Backchannel → a Continuer cross-ref to the current speaker node,
16//! and **never a turn node** (the load-bearing invariant)
17//! TurnClaim → prune the in-flight node (Frontier→Pruned) +
18//! draw a Contradicts edge from the claiming turn
19//! (WEFT-615: acoustic claims with `erl_confidence`
20//! below the ERL barge floor are denied — self-cancel)
21//! ```
22//!
23//! It registers as a [`SystemService`] **alongside** the coherence
24//! [`CognitiveTick`](crate::cognitive_tick::CognitiveTick) — it does **not**
25//! touch that loop (which stays the slow integrity monitor). It owns its **own**
26//! `CognitiveTick` instance so it shares the ADR-047 self-calibrating
27//! budget/adaptive primitives (`tick_budget_ratio`, `record_tick`,
28//! `current_interval_ms`) without coupling to the coherence cadence.
29//!
30//! The loop creates **no turn nodes** itself — turn nodes are produced by the
31//! dual-write path (`session_forest::dual_write_turn`, Phase 1) and handed to
32//! the loop via [`register_turn`](TalkModeLoop::register_turn). The loop only
33//! advances their lifecycle and links reply/claim relationships. No audio, no
34//! LLM, no embedder live here: this is pure graph orchestration.
35
36use std::collections::HashSet;
37use std::sync::Mutex;
38use std::sync::atomic::{AtomicU64, Ordering};
39use std::time::Instant;
40
41use dashmap::DashMap;
42use serde::{Deserialize, Serialize};
43use tracing::debug;
44
45use crate::causal::{CausalEdgeType, CausalGraph, NodeId};
46use crate::cognitive_tick::CognitiveTick;
47use crate::coherence::{CoherenceDampener, CoherenceSignals};
48use crate::context_graft::{NodeState, mirror_state};
49use crate::crossref::{CrossRef, CrossRefStore, CrossRefType, StructureTag, UniversalNodeId};
50use crate::floor::{
51 ContentReadiness, FloorCandidate, FloorState, UrgencySignals, contending_count, crowd_density,
52 erl_admits_barge, evaluate_floor,
53};
54use crate::impulse::{Impulse, ImpulseQueue, ImpulseType};
55use crate::view_resolver::ViewResolver;
56
57use std::sync::Arc;
58
59/// Configuration for the Talk-Mode loop.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct TalkModeConfig {
62 /// Maximum number of turn impulses to drain per tick.
63 pub max_impulses_per_tick: usize,
64 /// Default semantic-relevance signal fed to the floor read for a live
65 /// frontier candidate (the in-loop path has no embedder; P6 supplies the
66 /// real cosine via `observe_*`). Constant in v0.1 (Weaver learns it later).
67 pub default_semantic_relevance: f32,
68}
69
70impl Default for TalkModeConfig {
71 fn default() -> Self {
72 Self {
73 max_impulses_per_tick: 64,
74 default_semantic_relevance: 0.2,
75 }
76 }
77}
78
79/// Summary of one Talk-Mode tick (SENSE → FLOOR → MUTATE).
80#[derive(Debug, Clone)]
81pub struct TalkTickResult {
82 /// Turn impulses drained in the SENSE phase.
83 pub impulses_sensed: usize,
84 /// Turns committed (Frontier→Committed) this tick (EndOfUtterance).
85 pub commits: usize,
86 /// In-flight turns pruned this tick (TurnClaim barge-in).
87 pub prunes: usize,
88 /// Backchannels handled as Continuer cross-refs (never nodes).
89 pub backchannels: usize,
90 /// Floor handoffs (TurnShift → floor Open).
91 pub handoffs: usize,
92 /// The floor read for this tick.
93 pub floor: FloorState,
94 /// Winning urgency score before the coherence dampener.
95 pub floor_score: f32,
96 /// Winning urgency score after the coherence dampener (≤ `floor_score`).
97 pub dampened_score: f32,
98 /// Wall-clock duration of the tick in microseconds.
99 pub duration_us: u64,
100}
101
102/// A registered turn node: its causal-graph id, its universal id (the cross-ref
103/// key), and the conversation it belongs to. Populated by the dual-write path
104/// (Phase 1). `conv_id` is what lets the multiplexed loop (M2 D4) resolve the
105/// correct per-conversation view at commit/prune time.
106#[derive(Debug, Clone)]
107struct TurnRef {
108 node: NodeId,
109 uid: UniversalNodeId,
110 conv_id: String,
111}
112
113struct TalkInner {
114 /// Coherence soft-dampener (ADR-062 D6); applied to floor scores.
115 dampener: CoherenceDampener,
116}
117
118/// The Talk-Mode tick service (ADR-062 Phase 2.1; multiplexed per M2 D1/D4).
119///
120/// A single daemon-hosted instance multiplexes **all** conversations. Turn
121/// lineage is keyed by the globally-unique `chain_seq`; the per-conversation
122/// view is resolved through the injected [`ViewResolver`] at commit/prune time,
123/// so commit lands on the same `SessionView` the tier grafts from.
124pub struct TalkModeLoop {
125 impulses: Arc<ImpulseQueue>,
126 causal: Arc<CausalGraph>,
127 crossrefs: Arc<CrossRefStore>,
128 /// Per-conversation view resolution (M2 D4). Replaces the single
129 /// `Arc<SessionView>` so one loop serves every conversation.
130 views: Arc<dyn ViewResolver>,
131 /// Own ADR-047 self-calibrating tick (separate instance from the coherence
132 /// `CognitiveTick`; shares the primitives, not the cadence).
133 tick: Arc<CognitiveTick>,
134 config: TalkModeConfig,
135 /// chain_seq → its registered turn node (set by the dual-write path). The
136 /// `chain_seq` is globally unique, so one map serves all conversations.
137 lineage: DashMap<u64, TurnRef>,
138 /// conv_id → its in-flight turn's `chain_seq`. Read by the floor and
139 /// barge-in (both degenerate for text, which commits by explicit
140 /// `chain_seq`); scoped per conversation for the multiplexed loop.
141 current_turn: DashMap<String, u64>,
142 inner: Mutex<TalkInner>,
143 total_ticks: AtomicU64,
144}
145
146impl TalkModeLoop {
147 /// Wire a Talk-Mode loop to the forest substrate, a per-conversation view
148 /// resolver (M2 D4), and an ADR-047 tick.
149 pub fn new(
150 impulses: Arc<ImpulseQueue>,
151 causal: Arc<CausalGraph>,
152 crossrefs: Arc<CrossRefStore>,
153 views: Arc<dyn ViewResolver>,
154 tick: Arc<CognitiveTick>,
155 config: TalkModeConfig,
156 ) -> Self {
157 Self {
158 impulses,
159 causal,
160 crossrefs,
161 views,
162 tick,
163 config,
164 lineage: DashMap::new(),
165 current_turn: DashMap::new(),
166 inner: Mutex::new(TalkInner {
167 dampener: CoherenceDampener::new(),
168 }),
169 total_ticks: AtomicU64::new(0),
170 }
171 }
172
173 /// The impulse queue this loop drains. The text impulse source
174 /// (`SessionTier::index_turn`) emits `EndOfUtterance` onto this exact queue,
175 /// so it derives the handle from the loop rather than holding a second copy.
176 pub fn impulses(&self) -> &Arc<ImpulseQueue> {
177 &self.impulses
178 }
179
180 /// Register a turn node produced by the dual-write path (Phase 1) and make
181 /// it `conv_id`'s in-flight turn. `node`/`uid` are the causal node id and
182 /// universal id from `session_forest::dual_write_turn`.
183 pub fn register_turn(
184 &self,
185 chain_seq: u64,
186 node: NodeId,
187 uid: UniversalNodeId,
188 conv_id: impl Into<String>,
189 ) {
190 let conv_id = conv_id.into();
191 self.lineage.insert(
192 chain_seq,
193 TurnRef {
194 node,
195 uid,
196 conv_id: conv_id.clone(),
197 },
198 );
199 self.current_turn.insert(conv_id, chain_seq);
200 }
201
202 /// Evict all per-conversation loop state for `conv_id` (M2 D6). Called when
203 /// the idle reaper or an explicit `agent.chat.end` ends a conversation, so
204 /// `lineage` and `current_turn` do not grow unbounded across the daemon's
205 /// lifetime. Committed causal nodes stay on the graph (they are witnessed).
206 pub fn end_conversation(&self, conv_id: &str) {
207 self.lineage.retain(|_, turn| turn.conv_id != conv_id);
208 self.current_turn.remove(conv_id);
209 }
210
211 /// Feed the latest coherence read so the dampener tracks drift (ADR-062 D6).
212 /// P6 supplies the real RSTE signals; below threshold floor scores are
213 /// softened ×0.8 until recovery (never a hard gate).
214 pub fn observe_coherence(&self, signals: &CoherenceSignals) -> bool {
215 self.inner
216 .lock()
217 .expect("talk loop inner poisoned")
218 .dampener
219 .observe(signals.score())
220 }
221
222 /// Total ticks executed.
223 pub fn total_ticks(&self) -> u64 {
224 self.total_ticks.load(Ordering::Relaxed)
225 }
226
227 /// The in-flight turn's `chain_seq` for `conv_id`, if any.
228 pub fn current_turn(&self, conv_id: &str) -> Option<u64> {
229 self.current_turn.get(conv_id).map(|e| *e.value())
230 }
231
232 /// Execute one tick: SENSE → FLOOR → MUTATE. Records its own compute time in
233 /// the ADR-047 tick system. Pure graph work — no async, no I/O.
234 pub fn tick(&self) -> TalkTickResult {
235 let start = Instant::now();
236
237 // ── SENSE ────────────────────────────────────────────────────────
238 // Drain at most `max_impulses_per_tick`; any overflow stays queued for
239 // the next tick (M2 D8 fix — the old `drain_ready().truncate(max)`
240 // dropped the tail on the floor, so flooded EOUs never committed).
241 let impulses = self
242 .impulses
243 .drain_ready_limited(self.config.max_impulses_per_tick);
244
245 // ── FLOOR ────────────────────────────────────────────────────────
246 // A scored read over the live frontier crossed with the drained turn
247 // signals; softened by the coherence dampener (state, not a new read).
248 let (floor, floor_score, dampened_score) = self.read_floor(&impulses);
249
250 // ── MUTATE ─────────────────────────────────────────────────────────
251 let mut result = TalkTickResult {
252 impulses_sensed: impulses.len(),
253 commits: 0,
254 prunes: 0,
255 backchannels: 0,
256 handoffs: 0,
257 floor,
258 floor_score,
259 dampened_score,
260 duration_us: 0,
261 };
262 for imp in &impulses {
263 self.mutate(imp, &mut result);
264 }
265
266 // ── COMMIT (tick bookkeeping) ──────────────────────────────────────
267 let compute_us = start.elapsed().as_micros() as u64;
268 result.duration_us = compute_us;
269 self.tick.record_tick(compute_us);
270 self.total_ticks.fetch_add(1, Ordering::Relaxed);
271 debug!(
272 sensed = result.impulses_sensed,
273 commits = result.commits,
274 prunes = result.prunes,
275 backchannels = result.backchannels,
276 handoffs = result.handoffs,
277 "talk-mode tick"
278 );
279 result
280 }
281
282 /// FLOOR: build candidates from the live frontier of every conversation
283 /// touched this tick, score them, dampen the winner. Returns
284 /// `(state, raw_score, dampened_score)`. Multiplexed (M2 D4): the resolver
285 /// can't enumerate all views, so the conversations read are those referenced
286 /// by the drained impulses plus any with an in-flight turn — degenerate for
287 /// text, identical to the pre-M2 read for a single-conversation loop (voice).
288 fn read_floor(&self, drained: &[Impulse]) -> (FloorState, f32, f32) {
289 let crowd = crowd_density(contending_count(drained));
290 // WEFT-615 / ADR-068 D1: latest acoustic TurnClaim ERL folds into
291 // compute_urgency so self-echo cannot outscore a held floor.
292 let claim_erl = drained
293 .iter()
294 .filter(|i| i.impulse_type == ImpulseType::TurnClaim)
295 .find_map(|i| payload_f32(i, "erl_confidence"));
296 let mut convs: HashSet<String> = HashSet::new();
297 for imp in drained {
298 if let Some(conv) = self.impulse_conv(imp) {
299 convs.insert(conv);
300 }
301 }
302 for entry in self.current_turn.iter() {
303 convs.insert(entry.key().clone());
304 }
305 let mut candidates: Vec<FloorCandidate> = Vec::new();
306 for conv in &convs {
307 let current = self.current_turn(conv);
308 let Some(view) = self.views.view_for(conv) else {
309 continue;
310 };
311 for seq in view.live_seqs() {
312 let readiness = if Some(seq) == current {
313 ContentReadiness::Generated
314 } else {
315 ContentReadiness::NotStarted
316 };
317 candidates.push(FloorCandidate::new(
318 format!("seq-{seq}"),
319 seq,
320 UrgencySignals {
321 semantic_relevance: self.config.default_semantic_relevance,
322 emotional_arousal: 0.0,
323 wait_time: 0.0,
324 crowd_density: crowd,
325 content_readiness: readiness,
326 hard_interrupt: false,
327 // Agent content is not an acoustic barge claim — ERL
328 // scales only the barge contender below (WEFT-615).
329 erl_confidence: None,
330 },
331 ));
332 }
333 }
334 // Barge claim itself is a floor contender when present (ADR-068 D1 /
335 // WEFT-615): high-ERL onset can win; low-ERL is weighted toward zero
336 // via compute_urgency's erl_scale so self-echo cannot take the floor.
337 if let Some(erl) = claim_erl {
338 candidates.push(FloorCandidate::new(
339 "barge-claim",
340 0,
341 UrgencySignals {
342 semantic_relevance: 1.0,
343 emotional_arousal: 0.5,
344 wait_time: 0.0,
345 crowd_density: crowd,
346 content_readiness: ContentReadiness::NotStarted,
347 hard_interrupt: false,
348 erl_confidence: Some(erl),
349 },
350 ));
351 }
352 let decision = evaluate_floor(&candidates);
353 let dampened = {
354 let inner = self.inner.lock().expect("talk loop inner poisoned");
355 inner.dampener.apply(decision.score)
356 };
357 (decision.state, decision.score, dampened)
358 }
359
360 /// Resolve the conversation an impulse acts on: explicit `conv_id` in the
361 /// payload, else its `chain_seq`'s registered turn, else — for empty-payload
362 /// floor-pressure impulses (voice) — the sole in-flight conversation if one
363 /// exists. Text always carries `conv_id`/`chain_seq`, so the fallback is voice-only.
364 fn impulse_conv(&self, imp: &Impulse) -> Option<String> {
365 if let Some(conv) = imp.payload.get("conv_id").and_then(|v| v.as_str()) {
366 return Some(conv.to_string());
367 }
368 if let Some(seq) = payload_u64(imp, "chain_seq")
369 && let Some(turn) = self.lineage.get(&seq)
370 {
371 return Some(turn.conv_id.clone());
372 }
373 if self.current_turn.len() == 1 {
374 return self.current_turn.iter().next().map(|e| e.key().clone());
375 }
376 None
377 }
378
379 /// MUTATE one turn impulse onto the graph (ADR-062 D5). Every branch scopes
380 /// its `current_turn` read/write to the impulse's conversation (M2 D4).
381 fn mutate(&self, imp: &Impulse, result: &mut TalkTickResult) {
382 match imp.impulse_type {
383 ImpulseType::EndOfUtterance => {
384 // Commit the named turn (or the conversation's in-flight one):
385 // Frontier→Committed on both substrates (mirror_state gates the
386 // legal step). Text always carries an explicit `chain_seq`.
387 let seq = payload_u64(imp, "chain_seq")
388 .or_else(|| self.impulse_conv(imp).and_then(|c| self.current_turn(&c)));
389 if let Some(seq) = seq
390 && self.commit_turn(seq)
391 {
392 result.commits += 1;
393 // Clear the in-flight slot for the turn's own conversation.
394 if let Some(turn) = self.lineage.get(&seq) {
395 let conv = turn.conv_id.clone();
396 drop(turn);
397 if self.current_turn(&conv) == Some(seq) {
398 self.current_turn.remove(&conv);
399 }
400 }
401 }
402 }
403 ImpulseType::TurnShift => {
404 // Floor handoff: the floor opens; the next utterance commits a
405 // Follows. We drop the conversation's in-flight turn so the read
406 // goes Open.
407 if let Some(conv) = self.impulse_conv(imp) {
408 self.current_turn.remove(&conv);
409 }
410 result.handoffs += 1;
411 }
412 ImpulseType::Backchannel => {
413 // THE load-bearing invariant: a backchannel is a Continuer
414 // cross-ref to the current speaker node — NEVER a turn node.
415 //
416 // An explicit payload `turn_seq` names the floor-holding turn
417 // (Wave 2 §W2.6: the voice loop targets the in-flight reply
418 // ATTEMPT captured at routing time — `current_turn` may have
419 // been overwritten by the backchannel utterance's own
420 // registration by the time this tick drains). Absent that,
421 // fall back to the conversation's current turn.
422 if let Some(conv) = self.impulse_conv(imp)
423 && let Some(current) =
424 payload_u64(imp, "turn_seq").or_else(|| self.current_turn(&conv))
425 && let Some(turn) = self.lineage.get(¤t)
426 {
427 let listener = UniversalNodeId::from_bytes(imp.source_node);
428 self.crossrefs.insert(CrossRef {
429 source: listener,
430 source_structure: StructureTag::CausalGraph,
431 target: turn.uid.clone(),
432 target_structure: StructureTag::CausalGraph,
433 ref_type: CrossRefType::Continuer,
434 created_at: imp.hlc_timestamp,
435 chain_seq: current,
436 });
437 result.backchannels += 1;
438 }
439 }
440 ImpulseType::TurnClaim => {
441 // Barge-in: prune the conversation's in-flight node
442 // (Frontier→Pruned) and draw a Contradicts edge from the claiming
443 // turn (if registered).
444 //
445 // WEFT-615 / ADR-068 D1: acoustic claims carry `erl_confidence`
446 // from the thin-edge AEC. Below the ERL barge floor → DenyBarge
447 // (self-echo of the bot's own TTS) — do not prune. Non-acoustic
448 // TurnClaims (Wave 2 cancel/refine with `prune_seq`, text) omit
449 // the field and keep the legacy always-prune path.
450 if let Some(erl) = payload_f32(imp, "erl_confidence")
451 && !erl_admits_barge(erl)
452 {
453 debug!(erl, "talk loop: TurnClaim denied — ERL below barge floor");
454 return;
455 }
456 let Some(conv) = self.impulse_conv(imp) else {
457 return;
458 };
459 // Wave 2 §W2.3: an explicit `prune_seq` targets a specific node
460 // (cancel/refine — the reply captured at decision time, robust to
461 // `current_turn` moving after a resubmit registers the amendment).
462 // Absent (the barge-in path emits no `prune_seq`) ⇒ the
463 // conversation's in-flight turn, unchanged.
464 let pruned = payload_u64(imp, "prune_seq").or_else(|| self.current_turn(&conv));
465 if let Some(prune_seq) = pruned
466 && self.prune_turn(prune_seq)
467 {
468 result.prunes += 1;
469 let claim_seq = payload_u64(imp, "claim_seq");
470 if let (Some(claim_seq), Some(prune_node)) =
471 (claim_seq, self.lineage.get(&prune_seq).map(|r| r.node))
472 && let Some(claim_node) = self.lineage.get(&claim_seq).map(|r| r.node)
473 {
474 self.causal.link(
475 claim_node,
476 prune_node,
477 CausalEdgeType::Contradicts,
478 1.0,
479 imp.hlc_timestamp,
480 claim_seq,
481 );
482 }
483 // The claiming turn becomes the conversation's in-flight turn
484 // (if known); otherwise the floor is left open.
485 match claim_seq.filter(|s| self.lineage.contains_key(s)) {
486 Some(s) => {
487 self.current_turn.insert(conv, s);
488 }
489 None => {
490 self.current_turn.remove(&conv);
491 }
492 }
493 }
494 }
495 // Non-turn impulses are the DEMOCRITUS loop's concern, not ours.
496 _ => {}
497 }
498 }
499
500 /// Commit a turn Frontier→Committed across both substrates, resolving the
501 /// turn's per-conversation view (M2 D4). A no-op (logged) when the turn is
502 /// unregistered or its conversation has been reaped.
503 fn commit_turn(&self, seq: u64) -> bool {
504 self.mirror_turn(seq, NodeState::Committed)
505 }
506
507 /// Prune a turn Frontier→Pruned (hard rebase) across both substrates.
508 fn prune_turn(&self, seq: u64) -> bool {
509 self.mirror_turn(seq, NodeState::Pruned)
510 }
511
512 /// Resolve `seq`'s registered node + conversation view and mirror `state`
513 /// across the view and the causal graph. No-op (logged) when the lineage
514 /// entry is gone (unregistered / evicted) or the view has been reaped.
515 fn mirror_turn(&self, seq: u64, state: NodeState) -> bool {
516 let Some(turn) = self.lineage.get(&seq) else {
517 debug!(seq, ?state, "talk loop: no lineage entry for turn, no-op");
518 return false;
519 };
520 let (node, conv_id) = (turn.node, turn.conv_id.clone());
521 drop(turn);
522 match self.views.view_for(&conv_id) {
523 Some(view) => mirror_state(&view, seq, &self.causal, node, state),
524 None => {
525 debug!(seq, conv_id, ?state, "talk loop: view reaped, commit no-op");
526 false
527 }
528 }
529 }
530}
531
532/// Read an optional `u64` field from an impulse payload object.
533fn payload_u64(imp: &Impulse, key: &str) -> Option<u64> {
534 imp.payload.get(key).and_then(|v| v.as_u64())
535}
536
537/// Read an optional finite `f32` field from an impulse payload object
538/// (JSON numbers arrive as `f64`).
539fn payload_f32(imp: &Impulse, key: &str) -> Option<f32> {
540 imp.payload
541 .get(key)
542 .and_then(|v| v.as_f64())
543 .map(|f| f as f32)
544 .filter(|f| f.is_finite())
545}
546
547// The `SystemService` impl + the native `run_talk_loop` live in a sibling file
548// (child module, so it reaches these private fields) to keep this file under the
549// 500-line ceiling. `run_talk_loop` is re-exported so the daemon path is stable.
550#[path = "talk_loop_service.rs"]
551mod service;
552#[cfg(feature = "native")]
553pub use service::run_talk_loop;
554
555// ── Tests (split to a sibling file for the <500-line rule) ────────────────────
556#[cfg(test)]
557#[path = "talk_loop_tests.rs"]
558mod tests;