Skip to main content

clawft_kernel/
impulse.rs

1//! Ephemeral causal impulse queue for inter-structure communication (ECC Phase K3c).
2//!
3//! Impulses are short-lived events that flow between the four ECC structures
4//! (causal graph, spectral index, HNSW, cloud/edge bridge). The [`ImpulseQueue`]
5//! provides a thread-safe, ordered buffer that producers [`emit`](ImpulseQueue::emit)
6//! into and consumers [`drain_ready`](ImpulseQueue::drain_ready) from.
7//!
8//! Structure tags are represented as raw `u8` values to avoid cross-module
9//! coupling. They correspond to `crossref::StructureTag::as_u8()`.
10
11use std::fmt;
12use std::sync::Mutex;
13use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
14
15use serde::{Deserialize, Serialize};
16
17// ---------------------------------------------------------------------------
18// ImpulseType
19// ---------------------------------------------------------------------------
20
21/// Discriminant for the kind of causal event being signalled.
22///
23/// Variants carry a canonical *byte code* ([`code`](ImpulseType::code)) used as a
24/// stable wire/semantic tag — analogous to [`crossref::StructureTag::as_u8`].
25/// The byte code is **not** the Rust enum discriminant and serde tags this enum
26/// **by variant name** (externally tagged); adding variants is therefore
27/// backward-compatible with existing serialized impulses and `Custom(u8)` stays
28/// the open extension point.
29///
30/// [`crossref::StructureTag::as_u8`]: crate::crossref::StructureTag::as_u8
31#[non_exhaustive]
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub enum ImpulseType {
34    /// causal -> hnsw (new embedding needed)
35    BeliefUpdate,
36    /// spectral -> causal (graph incoherent)
37    CoherenceAlert,
38    /// hnsw -> causal (new cluster found)
39    NoveltyDetected,
40    /// cloud -> edge (DEMOCRITUS validated edge)
41    EdgeConfirmed,
42    /// cloud -> edge (better embedding available)
43    EmbeddingRefined,
44    /// Turn-taking: a speaker claims/asserts the floor — barge-in (0x50, ADR-062 D5).
45    TurnClaim,
46    /// Turn-taking: end-of-utterance — triggers the Frontier→Committed commit (0x51, ADR-062 D5).
47    EndOfUtterance,
48    /// Turn-taking: floor released; the next utterance commits a `Follows` (0x52, ADR-062 D5).
49    TurnShift,
50    /// Turn-taking: a backchannel ("mm-hmm") — becomes a `Continuer` cross-ref,
51    /// never a turn node (0x60, ADR-062 D5).
52    Backchannel,
53    /// Extension point for user-defined impulse kinds.
54    Custom(u8),
55}
56
57impl ImpulseType {
58    /// Canonical byte code for this impulse kind (stable semantic/wire tag).
59    ///
60    /// Turn-taking codes follow ADR-062 D5: `TurnClaim` 0x50, `EndOfUtterance`
61    /// 0x51, `TurnShift` 0x52, `Backchannel` 0x60. `Custom(c)` returns `c`.
62    pub fn code(&self) -> u8 {
63        match self {
64            Self::BeliefUpdate => 0x01,
65            Self::CoherenceAlert => 0x02,
66            Self::NoveltyDetected => 0x03,
67            Self::EdgeConfirmed => 0x04,
68            Self::EmbeddingRefined => 0x05,
69            Self::TurnClaim => 0x50,
70            Self::EndOfUtterance => 0x51,
71            Self::TurnShift => 0x52,
72            Self::Backchannel => 0x60,
73            Self::Custom(c) => *c,
74        }
75    }
76}
77
78impl fmt::Display for ImpulseType {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        match self {
81            Self::BeliefUpdate => write!(f, "BeliefUpdate"),
82            Self::CoherenceAlert => write!(f, "CoherenceAlert"),
83            Self::NoveltyDetected => write!(f, "NoveltyDetected"),
84            Self::EdgeConfirmed => write!(f, "EdgeConfirmed"),
85            Self::EmbeddingRefined => write!(f, "EmbeddingRefined"),
86            Self::TurnClaim => write!(f, "TurnClaim"),
87            Self::EndOfUtterance => write!(f, "EndOfUtterance"),
88            Self::TurnShift => write!(f, "TurnShift"),
89            Self::Backchannel => write!(f, "Backchannel"),
90            Self::Custom(code) => write!(f, "Custom({code})"),
91        }
92    }
93}
94
95// ---------------------------------------------------------------------------
96// Impulse
97// ---------------------------------------------------------------------------
98
99/// A single causal event travelling between ECC structures.
100///
101/// `source_structure` and `target_structure` are `u8` tags that correspond to
102/// `crossref::StructureTag::as_u8()` values:
103///   0 = CausalGraph, 1 = SpectralIndex, 2 = Hnsw, 3 = CloudBridge.
104pub struct Impulse {
105    /// Monotonically increasing identifier assigned by the queue.
106    pub id: u64,
107    /// Originating structure (see `StructureTag::as_u8()`).
108    pub source_structure: u8,
109    /// 32-byte universal node identifier from the source structure.
110    pub source_node: [u8; 32],
111    /// Destination structure (see `StructureTag::as_u8()`).
112    pub target_structure: u8,
113    /// The kind of impulse.
114    pub impulse_type: ImpulseType,
115    /// Arbitrary JSON payload carried by this impulse.
116    pub payload: serde_json::Value,
117    /// Hybrid-logical-clock timestamp for causal ordering.
118    pub hlc_timestamp: u64,
119    /// Set to `true` once the consumer has processed this impulse.
120    pub acknowledged: AtomicBool,
121}
122
123// AtomicBool is not Clone, so we implement Clone manually.
124impl Clone for Impulse {
125    fn clone(&self) -> Self {
126        Self {
127            id: self.id,
128            source_structure: self.source_structure,
129            source_node: self.source_node,
130            target_structure: self.target_structure,
131            impulse_type: self.impulse_type.clone(),
132            payload: self.payload.clone(),
133            hlc_timestamp: self.hlc_timestamp,
134            acknowledged: AtomicBool::new(self.acknowledged.load(Ordering::Acquire)),
135        }
136    }
137}
138
139impl fmt::Debug for Impulse {
140    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141        f.debug_struct("Impulse")
142            .field("id", &self.id)
143            .field("source_structure", &self.source_structure)
144            .field("target_structure", &self.target_structure)
145            .field("impulse_type", &self.impulse_type)
146            .field("hlc_timestamp", &self.hlc_timestamp)
147            .field("acknowledged", &self.acknowledged.load(Ordering::Relaxed))
148            .finish()
149    }
150}
151
152// ---------------------------------------------------------------------------
153// ImpulseQueue
154// ---------------------------------------------------------------------------
155
156/// Thread-safe queue of [`Impulse`] events awaiting consumption.
157pub struct ImpulseQueue {
158    queue: Mutex<Vec<Impulse>>,
159    next_id: AtomicU64,
160}
161
162impl ImpulseQueue {
163    /// Create a new, empty impulse queue.
164    pub fn new() -> Self {
165        Self {
166            queue: Mutex::new(Vec::new()),
167            next_id: AtomicU64::new(1),
168        }
169    }
170
171    /// Enqueue a new impulse and return its assigned id.
172    pub fn emit(
173        &self,
174        source_structure: u8,
175        source_node: [u8; 32],
176        target_structure: u8,
177        impulse_type: ImpulseType,
178        payload: serde_json::Value,
179        hlc_timestamp: u64,
180    ) -> u64 {
181        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
182        let impulse = Impulse {
183            id,
184            source_structure,
185            source_node,
186            target_structure,
187            impulse_type,
188            payload,
189            hlc_timestamp,
190            acknowledged: AtomicBool::new(false),
191        };
192        let mut q = self.queue.lock().expect("impulse queue poisoned");
193        q.push(impulse);
194        id
195    }
196
197    /// Drain all unacknowledged impulses, returning them sorted by
198    /// `hlc_timestamp` (ascending). Acknowledged impulses are discarded.
199    pub fn drain_ready(&self) -> Vec<Impulse> {
200        let mut q = self.queue.lock().expect("impulse queue poisoned");
201        let drained: Vec<Impulse> = q
202            .drain(..)
203            .filter(|imp| !imp.acknowledged.load(Ordering::Acquire))
204            .collect();
205        let mut sorted = drained;
206        sorted.sort_by_key(|imp| imp.hlc_timestamp);
207        sorted
208    }
209
210    /// Drain up to `max` unacknowledged impulses in `hlc_timestamp` order,
211    /// leaving any overflow **queued** for the next call.
212    ///
213    /// This is the data-loss-safe variant of [`drain_ready`](Self::drain_ready):
214    /// a consumer that only processes `max` per tick (the Talk-Mode loop) must
215    /// not silently drop the remainder. Acknowledged impulses are still
216    /// discarded. `max == 0` drains nothing and leaves the queue untouched.
217    pub fn drain_ready_limited(&self, max: usize) -> Vec<Impulse> {
218        let mut q = self.queue.lock().expect("impulse queue poisoned");
219        let mut ready: Vec<Impulse> = std::mem::take(&mut *q)
220            .into_iter()
221            .filter(|imp| !imp.acknowledged.load(Ordering::Acquire))
222            .collect();
223        ready.sort_by_key(|imp| imp.hlc_timestamp);
224        if ready.len() > max {
225            // Keep the earliest `max`; the tail stays queued (HLC-ordered) for
226            // the next drain rather than being dropped on the floor.
227            *q = ready.split_off(max);
228        }
229        ready
230    }
231
232    /// Total number of impulses in the queue (acknowledged or not).
233    pub fn len(&self) -> usize {
234        self.queue.lock().expect("impulse queue poisoned").len()
235    }
236
237    /// Returns `true` if the queue contains no impulses.
238    pub fn is_empty(&self) -> bool {
239        self.len() == 0
240    }
241
242    /// Remove all impulses from the queue (e.g. during calibration).
243    pub fn clear(&self) {
244        self.queue.lock().expect("impulse queue poisoned").clear();
245    }
246
247    /// Count of impulses that have not yet been acknowledged.
248    pub fn pending_count(&self) -> usize {
249        self.queue
250            .lock()
251            .expect("impulse queue poisoned")
252            .iter()
253            .filter(|imp| !imp.acknowledged.load(Ordering::Acquire))
254            .count()
255    }
256}
257
258impl Default for ImpulseQueue {
259    fn default() -> Self {
260        Self::new()
261    }
262}
263
264// ---------------------------------------------------------------------------
265// Tests
266// ---------------------------------------------------------------------------
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn impulse_type_display() {
274        assert_eq!(ImpulseType::BeliefUpdate.to_string(), "BeliefUpdate");
275        assert_eq!(ImpulseType::CoherenceAlert.to_string(), "CoherenceAlert");
276        assert_eq!(ImpulseType::NoveltyDetected.to_string(), "NoveltyDetected");
277        assert_eq!(ImpulseType::EdgeConfirmed.to_string(), "EdgeConfirmed");
278        assert_eq!(
279            ImpulseType::EmbeddingRefined.to_string(),
280            "EmbeddingRefined"
281        );
282        assert_eq!(ImpulseType::Custom(42).to_string(), "Custom(42)");
283    }
284
285    #[test]
286    fn impulse_queue_new_empty() {
287        let q = ImpulseQueue::new();
288        assert!(q.is_empty());
289        assert_eq!(q.len(), 0);
290        assert_eq!(q.pending_count(), 0);
291    }
292
293    #[test]
294    fn impulse_queue_emit_assigns_id() {
295        let q = ImpulseQueue::new();
296        let node = [0u8; 32];
297        let id1 = q.emit(
298            0,
299            node,
300            2,
301            ImpulseType::BeliefUpdate,
302            serde_json::json!({}),
303            100,
304        );
305        let id2 = q.emit(
306            1,
307            node,
308            0,
309            ImpulseType::CoherenceAlert,
310            serde_json::json!({}),
311            200,
312        );
313        assert_eq!(id1, 1);
314        assert_eq!(id2, 2);
315        assert_eq!(q.len(), 2);
316    }
317
318    #[test]
319    fn impulse_queue_drain_sorted_by_hlc() {
320        let q = ImpulseQueue::new();
321        let node = [0u8; 32];
322        // Emit in reverse timestamp order.
323        q.emit(
324            0,
325            node,
326            2,
327            ImpulseType::BeliefUpdate,
328            serde_json::json!({}),
329            300,
330        );
331        q.emit(
332            1,
333            node,
334            0,
335            ImpulseType::CoherenceAlert,
336            serde_json::json!({}),
337            100,
338        );
339        q.emit(
340            2,
341            node,
342            0,
343            ImpulseType::NoveltyDetected,
344            serde_json::json!({}),
345            200,
346        );
347
348        let drained = q.drain_ready();
349        assert_eq!(drained.len(), 3);
350        assert_eq!(drained[0].hlc_timestamp, 100);
351        assert_eq!(drained[1].hlc_timestamp, 200);
352        assert_eq!(drained[2].hlc_timestamp, 300);
353    }
354
355    #[test]
356    fn impulse_queue_drain_removes_items() {
357        let q = ImpulseQueue::new();
358        let node = [0u8; 32];
359        q.emit(
360            0,
361            node,
362            2,
363            ImpulseType::BeliefUpdate,
364            serde_json::json!({}),
365            1,
366        );
367        q.emit(
368            0,
369            node,
370            2,
371            ImpulseType::EdgeConfirmed,
372            serde_json::json!({}),
373            2,
374        );
375        assert_eq!(q.len(), 2);
376
377        let drained = q.drain_ready();
378        assert_eq!(drained.len(), 2);
379        assert!(q.is_empty());
380    }
381
382    #[test]
383    fn impulse_queue_clear() {
384        let q = ImpulseQueue::new();
385        let node = [0u8; 32];
386        q.emit(
387            0,
388            node,
389            1,
390            ImpulseType::EmbeddingRefined,
391            serde_json::json!({}),
392            10,
393        );
394        q.emit(
395            0,
396            node,
397            1,
398            ImpulseType::Custom(7),
399            serde_json::json!({}),
400            20,
401        );
402        assert_eq!(q.len(), 2);
403
404        q.clear();
405        assert!(q.is_empty());
406        assert_eq!(q.len(), 0);
407    }
408
409    #[test]
410    fn impulse_queue_pending_count() {
411        let q = ImpulseQueue::new();
412        let node = [0u8; 32];
413        q.emit(
414            0,
415            node,
416            2,
417            ImpulseType::BeliefUpdate,
418            serde_json::json!({}),
419            1,
420        );
421        q.emit(
422            1,
423            node,
424            0,
425            ImpulseType::CoherenceAlert,
426            serde_json::json!({}),
427            2,
428        );
429        assert_eq!(q.pending_count(), 2);
430
431        // Acknowledge one via the internal queue.
432        {
433            let guard = q.queue.lock().unwrap();
434            guard[0].acknowledged.store(true, Ordering::Release);
435        }
436        assert_eq!(q.pending_count(), 1);
437    }
438
439    #[test]
440    fn turn_impulse_codes_and_display() {
441        // ADR-062 D5 canonical byte codes.
442        assert_eq!(ImpulseType::TurnClaim.code(), 0x50);
443        assert_eq!(ImpulseType::EndOfUtterance.code(), 0x51);
444        assert_eq!(ImpulseType::TurnShift.code(), 0x52);
445        assert_eq!(ImpulseType::Backchannel.code(), 0x60);
446        assert_eq!(ImpulseType::Custom(0x77).code(), 0x77);
447
448        assert_eq!(ImpulseType::TurnClaim.to_string(), "TurnClaim");
449        assert_eq!(ImpulseType::EndOfUtterance.to_string(), "EndOfUtterance");
450        assert_eq!(ImpulseType::TurnShift.to_string(), "TurnShift");
451        assert_eq!(ImpulseType::Backchannel.to_string(), "Backchannel");
452    }
453
454    #[test]
455    fn turn_impulse_serde_roundtrip_by_name() {
456        // serde tags by variant name → stable across added variants.
457        let json = serde_json::to_string(&ImpulseType::EndOfUtterance).unwrap();
458        assert_eq!(json, "\"EndOfUtterance\"");
459        let back: ImpulseType = serde_json::from_str(&json).unwrap();
460        assert_eq!(back, ImpulseType::EndOfUtterance);
461        // Pre-existing variants still round-trip unchanged.
462        let custom = serde_json::to_string(&ImpulseType::Custom(7)).unwrap();
463        assert_eq!(custom, "{\"Custom\":7}");
464    }
465
466    #[test]
467    fn turn_impulses_drain_hlc_ordered() {
468        // A realistic turn: backchannel mid-utterance, then a barge-in claim,
469        // then EOU — emitted out of HLC order, must drain in HLC order.
470        let q = ImpulseQueue::new();
471        let speaker = [9u8; 32];
472        // 0 = CausalGraph tag (see StructureTag::as_u8).
473        q.emit(
474            0,
475            speaker,
476            0,
477            ImpulseType::EndOfUtterance,
478            json_payload(),
479            300,
480        );
481        q.emit(0, speaker, 0, ImpulseType::Backchannel, json_payload(), 100);
482        q.emit(0, speaker, 0, ImpulseType::TurnClaim, json_payload(), 200);
483
484        let drained = q.drain_ready();
485        assert_eq!(drained.len(), 3);
486        // HLC ascending.
487        assert_eq!(drained[0].impulse_type, ImpulseType::Backchannel);
488        assert_eq!(drained[0].hlc_timestamp, 100);
489        assert_eq!(drained[1].impulse_type, ImpulseType::TurnClaim);
490        assert_eq!(drained[1].hlc_timestamp, 200);
491        assert_eq!(drained[2].impulse_type, ImpulseType::EndOfUtterance);
492        assert_eq!(drained[2].hlc_timestamp, 300);
493    }
494
495    fn json_payload() -> serde_json::Value {
496        serde_json::json!({})
497    }
498
499    #[test]
500    fn impulse_emit_and_acknowledge() {
501        let q = ImpulseQueue::new();
502        let node = [1u8; 32];
503        q.emit(
504            0,
505            node,
506            2,
507            ImpulseType::NoveltyDetected,
508            serde_json::json!({"k": "v"}),
509            50,
510        );
511        q.emit(
512            0,
513            node,
514            3,
515            ImpulseType::EdgeConfirmed,
516            serde_json::json!(null),
517            60,
518        );
519
520        let drained = q.drain_ready();
521        assert_eq!(drained.len(), 2);
522        // Queue is now empty after drain.
523        assert!(q.is_empty());
524
525        // Mark drained impulses as acknowledged.
526        for imp in &drained {
527            imp.acknowledged.store(true, Ordering::Release);
528        }
529
530        // Verify acknowledgement persists on the drained copies.
531        for imp in &drained {
532            assert!(imp.acknowledged.load(Ordering::Acquire));
533        }
534    }
535}