Skip to main content

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(&current)
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;