Skip to main content

clawft_kernel/
democritus.rs

1//! DEMOCRITUS continuous cognitive loop (ECC decision D5).
2//!
3//! The [`DemocritusLoop`] is the nervous system of WeftOS — an integration
4//! layer that orchestrates the ECC subsystems on every cognitive tick:
5//!
6//! ```text
7//! SENSE → EMBED → SEARCH → UPDATE → COMMIT
8//! ```
9//!
10//! It drains the [`ImpulseQueue`] for new events, embeds them via the
11//! configured [`EmbeddingProvider`], queries the pluggable [`VectorBackend`]
12//! for nearest neighbors (default: HNSW), updates the [`CausalGraph`] with
13//! inferred edges, registers cross-refs in the [`CrossRefStore`], and logs
14//! the result.
15//!
16//! This module is compiled only when the `ecc` feature is enabled.
17
18use std::sync::Arc;
19use std::sync::atomic::{AtomicU64, Ordering};
20use std::time::Instant;
21
22use serde::{Deserialize, Serialize};
23use tracing::{debug, warn};
24
25use crate::causal::{CausalEdgeType, CausalGraph};
26use crate::crossref::{CrossRef, CrossRefStore, CrossRefType, StructureTag, UniversalNodeId};
27use crate::embedding::EmbeddingProvider;
28use crate::impulse::{Impulse, ImpulseQueue, ImpulseType};
29use crate::vector_backend::VectorBackend;
30
31// ---------------------------------------------------------------------------
32// Configuration
33// ---------------------------------------------------------------------------
34
35/// Configuration for the DEMOCRITUS loop.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct DemocritusConfig {
38    /// Maximum number of impulses to process per tick.
39    pub max_impulses_per_tick: usize,
40    /// Number of nearest neighbors to retrieve during SEARCH phase.
41    pub search_k: usize,
42    /// Cosine similarity threshold above which two events are considered correlated.
43    pub correlation_threshold: f32,
44    /// Budget for a single tick in microseconds. If exceeded, the tick stops early.
45    pub tick_budget_us: u64,
46}
47
48impl Default for DemocritusConfig {
49    fn default() -> Self {
50        Self {
51            max_impulses_per_tick: 64,
52            search_k: 5,
53            correlation_threshold: 0.7,
54            tick_budget_us: 15_000, // 15ms
55        }
56    }
57}
58
59// ---------------------------------------------------------------------------
60// Tick result
61// ---------------------------------------------------------------------------
62
63/// Summary of a single DEMOCRITUS tick cycle.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct DemocritusTickResult {
66    /// Number of impulses drained in the SENSE phase.
67    pub impulses_sensed: usize,
68    /// Number of embeddings produced in the EMBED phase.
69    pub embeddings_produced: usize,
70    /// Number of vector searches performed in the SEARCH phase.
71    pub searches_performed: usize,
72    /// Number of causal edges added in the UPDATE phase.
73    pub edges_added: usize,
74    /// Number of cross-refs registered in the UPDATE phase.
75    pub crossrefs_added: usize,
76    /// Whether the tick was cut short due to budget exhaustion.
77    pub budget_exceeded: bool,
78    /// Wall-clock duration of the tick in microseconds.
79    pub duration_us: u64,
80}
81
82// ---------------------------------------------------------------------------
83// DemocritusLoop
84// ---------------------------------------------------------------------------
85
86/// The DEMOCRITUS continuous cognitive loop.
87///
88/// Runs every CognitiveTick cycle: Sense -> Embed -> Search -> Update -> Commit.
89///
90/// Vector recall is pluggable via [`VectorBackend`]. Production default is
91/// [`HnswBackend`](crate::vector_hnsw::HnswBackend); DiskANN / Hybrid can be
92/// swapped in without changing this loop (WEFT-124).
93pub struct DemocritusLoop {
94    // ECC subsystem references
95    causal_graph: Arc<CausalGraph>,
96    /// Pluggable vector index (default: HNSW backend).
97    vector: Arc<dyn VectorBackend>,
98    impulse_queue: Arc<ImpulseQueue>,
99    crossref_store: Arc<CrossRefStore>,
100    embedding_provider: Arc<dyn EmbeddingProvider>,
101    // Configuration
102    config: DemocritusConfig,
103    // Tick statistics
104    total_ticks: AtomicU64,
105    total_nodes_added: AtomicU64,
106    total_edges_added: AtomicU64,
107}
108
109impl DemocritusLoop {
110    /// Create a new DEMOCRITUS loop wired to the given ECC subsystems.
111    ///
112    /// `vector` is typically `Arc::new(HnswBackend::new(...))` for the
113    /// production default; any [`VectorBackend`] implementor works.
114    pub fn new(
115        causal_graph: Arc<CausalGraph>,
116        vector: Arc<dyn VectorBackend>,
117        impulse_queue: Arc<ImpulseQueue>,
118        crossref_store: Arc<CrossRefStore>,
119        embedding_provider: Arc<dyn EmbeddingProvider>,
120        config: DemocritusConfig,
121    ) -> Self {
122        Self {
123            causal_graph,
124            vector,
125            impulse_queue,
126            crossref_store,
127            embedding_provider,
128            config,
129            total_ticks: AtomicU64::new(0),
130            total_nodes_added: AtomicU64::new(0),
131            total_edges_added: AtomicU64::new(0),
132        }
133    }
134
135    /// Borrow the active vector backend (for diagnostics / tests).
136    pub fn vector_backend(&self) -> &dyn VectorBackend {
137        self.vector.as_ref()
138    }
139
140    /// Execute one full tick cycle: Sense -> Embed -> Search -> Update -> Commit.
141    ///
142    /// Returns a summary of what was processed. This is the method the
143    /// [`CognitiveTick`] loop should call on each cycle.
144    pub async fn tick(&self) -> DemocritusTickResult {
145        let start = Instant::now();
146        let mut result = DemocritusTickResult {
147            impulses_sensed: 0,
148            embeddings_produced: 0,
149            searches_performed: 0,
150            edges_added: 0,
151            crossrefs_added: 0,
152            budget_exceeded: false,
153            duration_us: 0,
154        };
155
156        // ── SENSE ────────────────────────────────────────────────────
157        let impulses = self.sense();
158        result.impulses_sensed = impulses.len();
159
160        if impulses.is_empty() {
161            result.duration_us = start.elapsed().as_micros() as u64;
162            self.commit(&result);
163            return result;
164        }
165
166        // ── EMBED ────────────────────────────────────────────────────
167        let embedded = self.embed(&impulses).await;
168        result.embeddings_produced = embedded.len();
169
170        if self.budget_exceeded(start) {
171            result.budget_exceeded = true;
172            result.duration_us = start.elapsed().as_micros() as u64;
173            self.commit(&result);
174            return result;
175        }
176
177        // ── SEARCH ───────────────────────────────────────────────────
178        // Query the pluggable VectorBackend per non-empty embedding.
179        // Neighbor keys are causal node id strings (see update()); scores
180        // are cosine similarity recovered from backend distance so that
181        // correlation_threshold stays calibrated for the HNSW default.
182        let non_empty_queries: Vec<(usize, &[f32])> = embedded
183            .iter()
184            .enumerate()
185            .filter(|(_, emb)| !emb.is_empty())
186            .map(|(i, emb)| (i, emb.as_slice()))
187            .collect();
188
189        let mut search_results_by_index: Vec<Vec<(String, f32)>> = vec![Vec::new(); embedded.len()];
190        for &(orig_idx, query) in &non_empty_queries {
191            search_results_by_index[orig_idx] = self.search(query);
192        }
193        result.searches_performed = non_empty_queries.len();
194
195        type NeighborTriple<'a> = (&'a Impulse, &'a Vec<f32>, Vec<(String, f32)>);
196        let mut neighbors_per_event: Vec<NeighborTriple<'_>> = Vec::with_capacity(embedded.len());
197        for (i, (impulse, embedding)) in impulses.iter().zip(embedded.iter()).enumerate() {
198            if self.budget_exceeded(start) {
199                result.budget_exceeded = true;
200                break;
201            }
202            let neighbors = std::mem::take(&mut search_results_by_index[i]);
203            neighbors_per_event.push((impulse, embedding, neighbors));
204        }
205
206        // ── UPDATE ───────────────────────────────────────────────────
207        for (impulse, embedding, neighbors) in &neighbors_per_event {
208            if self.budget_exceeded(start) {
209                result.budget_exceeded = true;
210                break;
211            }
212            let (edges, crossrefs) = self.update(impulse, embedding, neighbors);
213            result.edges_added += edges;
214            result.crossrefs_added += crossrefs;
215        }
216
217        // ── COMMIT ───────────────────────────────────────────────────
218        result.duration_us = start.elapsed().as_micros() as u64;
219        self.commit(&result);
220        result
221    }
222
223    // ── Phase implementations ────────────────────────────────────────
224
225    /// SENSE: drain the impulse queue up to the per-tick limit.
226    fn sense(&self) -> Vec<Impulse> {
227        let mut impulses = self.impulse_queue.drain_ready();
228        impulses.truncate(self.config.max_impulses_per_tick);
229        impulses
230    }
231
232    /// EMBED: convert each impulse's payload to a vector embedding.
233    ///
234    /// On embedding failure, falls back to an empty vector (the impulse
235    /// will still be recorded in the causal graph but won't participate
236    /// in similarity search).
237    async fn embed(&self, impulses: &[Impulse]) -> Vec<Vec<f32>> {
238        let texts: Vec<String> = impulses
239            .iter()
240            .map(|imp| {
241                // Build a text representation from the impulse payload.
242                let type_str = imp.impulse_type.to_string();
243                let payload_str = imp.payload.to_string();
244                format!("{type_str}:{payload_str}")
245            })
246            .collect();
247
248        let text_refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
249
250        match self.embedding_provider.embed_batch(&text_refs).await {
251            Ok(vecs) => vecs,
252            Err(e) => {
253                warn!("DEMOCRITUS embed phase failed, falling back to empty vectors: {e}");
254                vec![Vec::new(); impulses.len()]
255            }
256        }
257    }
258
259    /// SEARCH: query the vector backend for k nearest neighbors.
260    ///
261    /// Returns `(neighbor_key, cosine_similarity)` pairs. Backends that
262    /// report distance (HNSW: `distance = 1 - cosine_sim`) are converted
263    /// so `correlation_threshold` keeps its original similarity semantics.
264    fn search(&self, embedding: &[f32]) -> Vec<(String, f32)> {
265        if embedding.is_empty() {
266            return Vec::new();
267        }
268        self.vector
269            .search(embedding, self.config.search_k)
270            .into_iter()
271            .map(|r| {
272                // Prefer the string key (causal node id) so neighbor → link
273                // parsing stays identical to the pre-VectorBackend path.
274                let key = if r.key.is_empty() {
275                    r.id.to_string()
276                } else {
277                    r.key
278                };
279                let score = 1.0 - r.distance;
280                (key, score)
281            })
282            .collect()
283    }
284
285    /// UPDATE: add a causal node for the impulse, insert into the vector
286    /// backend, create causal edges based on neighbor similarity, and
287    /// register cross-references.
288    ///
289    /// Returns (edges_added, crossrefs_added).
290    fn update(
291        &self,
292        impulse: &Impulse,
293        embedding: &[f32],
294        neighbors: &[(String, f32)],
295    ) -> (usize, usize) {
296        let mut edges_added = 0usize;
297        let mut crossrefs_added = 0usize;
298
299        // Atom-spine sequence for this event (WEFT-642). Prefer the explicit
300        // `payload.chain_seq` minted by session_tier / talk-mode after the
301        // ExoChain append; without it the projection cannot reverse-resolve
302        // to the witness spine (the panopticon "looks alive but does not
303        // join back" class). Zero is retained only when the emitter never
304        // supplied a sequence — ADR-069 audit will flag those rows.
305        let chain_seq = resolve_chain_seq(impulse);
306
307        // Add a causal node for this impulse. Payload already carries
308        // `chain_seq` when present (session_tier); re-assert it on the
309        // metadata object so non-object payloads still become joinable.
310        let label = format!("impulse:{}:{}", impulse.impulse_type, impulse.id);
311        let node_metadata = node_metadata_with_chain_seq(impulse, chain_seq);
312        let node_id = self.causal_graph.add_node(label.clone(), node_metadata);
313        self.total_nodes_added.fetch_add(1, Ordering::Relaxed);
314
315        // Insert embedding into the vector backend. Numeric id + string key
316        // are both the causal node ID so neighbor hits can re-link by
317        // NodeId. `chain_seq` in metadata is the join key back to the atom
318        // spine — never hardcode 0 here.
319        if !embedding.is_empty() {
320            let meta = serde_json::json!({
321                "impulse_id": impulse.id,
322                "impulse_type": impulse.impulse_type.to_string(),
323                "hlc": impulse.hlc_timestamp,
324                "chain_seq": chain_seq,
325                "causal_node_id": node_id,
326            });
327            if let Err(e) = self.vector.insert(
328                node_id,
329                &node_id.to_string(),
330                embedding,
331                meta,
332            ) {
333                warn!(
334                    "DEMOCRITUS vector insert failed for node {node_id}: {e}"
335                );
336            }
337        }
338
339        // Create causal edges based on neighbor similarity.
340        for (neighbor_id_str, score) in neighbors {
341            let edge_type = self.classify_edge(impulse, *score);
342
343            if let Ok(neighbor_node_id) = neighbor_id_str.parse::<u64>() {
344                let linked = self.causal_graph.link(
345                    node_id,
346                    neighbor_node_id,
347                    edge_type,
348                    *score,
349                    impulse.hlc_timestamp,
350                    chain_seq,
351                );
352                if linked {
353                    edges_added += 1;
354                    self.total_edges_added.fetch_add(1, Ordering::Relaxed);
355                }
356            }
357        }
358
359        // Register a cross-reference linking the causal node to its source structure.
360        let source_tag = structure_tag_from_u8(impulse.source_structure);
361        let uni_id = UniversalNodeId::new(
362            &StructureTag::CausalGraph,
363            label.as_bytes(),
364            // Prefer chain_seq for UID material when available so the
365            // identity aligns with turn-style spine UIDs (session_forest).
366            if chain_seq > 0 {
367                chain_seq
368            } else {
369                impulse.hlc_timestamp
370            },
371            &impulse.source_node,
372            &[0u8; 32],
373        );
374        let source_uni_id = UniversalNodeId::from_bytes(impulse.source_node);
375        self.crossref_store.insert(CrossRef {
376            source: uni_id,
377            source_structure: StructureTag::CausalGraph,
378            target: source_uni_id,
379            target_structure: source_tag,
380            ref_type: CrossRefType::TriggeredBy,
381            created_at: impulse.hlc_timestamp,
382            chain_seq,
383        });
384        crossrefs_added += 1;
385
386        (edges_added, crossrefs_added)
387    }
388
389    /// COMMIT: update tick statistics and log the result.
390    fn commit(&self, result: &DemocritusTickResult) {
391        self.total_ticks.fetch_add(1, Ordering::Relaxed);
392        debug!(
393            "DEMOCRITUS tick #{}: sensed={}, embedded={}, searched={}, edges={}, crossrefs={}, budget_exceeded={}, duration={}us",
394            self.total_ticks.load(Ordering::Relaxed),
395            result.impulses_sensed,
396            result.embeddings_produced,
397            result.searches_performed,
398            result.edges_added,
399            result.crossrefs_added,
400            result.budget_exceeded,
401            result.duration_us,
402        );
403    }
404
405    // ── Helpers ──────────────────────────────────────────────────────
406
407    /// Classify the edge type based on impulse context and similarity score.
408    fn classify_edge(&self, impulse: &Impulse, score: f32) -> CausalEdgeType {
409        // High similarity → Correlates (statistically similar events).
410        if score >= self.config.correlation_threshold {
411            return CausalEdgeType::Correlates;
412        }
413
414        // Impulse type hints at causal direction.
415        match &impulse.impulse_type {
416            ImpulseType::BeliefUpdate | ImpulseType::NoveltyDetected => CausalEdgeType::Follows,
417            ImpulseType::EdgeConfirmed => CausalEdgeType::Causes,
418            ImpulseType::CoherenceAlert => CausalEdgeType::EvidenceFor,
419            ImpulseType::EmbeddingRefined => CausalEdgeType::Enables,
420            // Turn-taking signals (ADR-062 D5) are conversational-flow events;
421            // the Talk-Mode tick (Phase 2) interprets them — here they read as
422            // temporal succession on the general substrate.
423            ImpulseType::EndOfUtterance
424            | ImpulseType::TurnClaim
425            | ImpulseType::TurnShift
426            | ImpulseType::Backchannel => CausalEdgeType::Follows,
427            ImpulseType::Custom(_) => CausalEdgeType::Follows,
428        }
429    }
430
431    /// Check if the tick budget has been exceeded.
432    fn budget_exceeded(&self, start: Instant) -> bool {
433        start.elapsed().as_micros() as u64 > self.config.tick_budget_us
434    }
435
436    // ── Statistics accessors ─────────────────────────────────────────
437
438    /// Total number of ticks executed.
439    pub fn total_ticks(&self) -> u64 {
440        self.total_ticks.load(Ordering::Relaxed)
441    }
442
443    /// Total number of causal nodes added across all ticks.
444    pub fn total_nodes_added(&self) -> u64 {
445        self.total_nodes_added.load(Ordering::Relaxed)
446    }
447
448    /// Total number of causal edges added across all ticks.
449    pub fn total_edges_added(&self) -> u64 {
450        self.total_edges_added.load(Ordering::Relaxed)
451    }
452}
453
454/// Map a raw `u8` structure tag back to a [`StructureTag`] variant.
455fn structure_tag_from_u8(tag: u8) -> StructureTag {
456    match tag {
457        0x01 => StructureTag::ExoChain,
458        0x02 => StructureTag::ResourceTree,
459        0x03 => StructureTag::CausalGraph,
460        0x04 => StructureTag::HnswIndex,
461        other => StructureTag::Custom(other),
462    }
463}
464
465/// Resolve the atom-spine `chain_seq` for an impulse (WEFT-642).
466///
467/// Session-tier / talk-mode emitters put the ExoChain sequence into
468/// `payload.chain_seq` at emit time (see `session_tier::index_turn`).
469/// That is the only durable join key back to the witness spine; hardcoding
470/// `0` made the ECC brain HNSW look populated while remaining unjoinable.
471///
472/// Returns `0` when the emitter did not supply a sequence (legacy / synthetic
473/// impulses). Callers must still record that zero rather than inventing one.
474fn resolve_chain_seq(impulse: &Impulse) -> u64 {
475    impulse
476        .payload
477        .get("chain_seq")
478        .and_then(|v| v.as_u64())
479        .unwrap_or(0)
480}
481
482/// Build causal-node metadata that always surfaces `chain_seq` when known.
483///
484/// Clones the impulse payload and ensures a top-level `chain_seq` field so
485/// reverse scans (`nodes_for_conv`, panopticon audit) can join without
486/// reading HNSW metadata.
487fn node_metadata_with_chain_seq(impulse: &Impulse, chain_seq: u64) -> serde_json::Value {
488    let mut meta = impulse.payload.clone();
489    if chain_seq == 0 {
490        return meta;
491    }
492    match meta.as_object_mut() {
493        Some(obj) => {
494            obj.insert("chain_seq".into(), serde_json::json!(chain_seq));
495            meta
496        }
497        None => serde_json::json!({
498            "chain_seq": chain_seq,
499            "payload": impulse.payload,
500        }),
501    }
502}
503
504// ---------------------------------------------------------------------------
505// Tests
506// ---------------------------------------------------------------------------
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    use crate::embedding::MockEmbeddingProvider;
512    use crate::hnsw_service::HnswServiceConfig;
513    use crate::vector_backend::{SearchResult, VectorResult};
514    use crate::vector_hnsw::HnswBackend;
515    use std::sync::Mutex;
516    use std::sync::atomic::AtomicUsize;
517
518    /// A wired loop plus the stores it was built over.
519    type LoopParts = (
520        Arc<CausalGraph>,
521        Arc<dyn VectorBackend>,
522        Arc<ImpulseQueue>,
523        Arc<CrossRefStore>,
524        DemocritusLoop,
525    );
526
527    /// One stored `(id, key, vector, metadata)` entry of [`CountingBackend`].
528    type CountedEntry = (u64, String, Vec<f32>, serde_json::Value);
529
530    /// Helper: build a fully wired DemocritusLoop with default HNSW backend.
531    fn make_loop() -> LoopParts {
532        make_loop_with_config(DemocritusConfig::default())
533    }
534
535    fn make_loop_with_config(config: DemocritusConfig) -> LoopParts {
536        let cg = Arc::new(CausalGraph::new());
537        let vector: Arc<dyn VectorBackend> = Arc::new(HnswBackend::new(HnswServiceConfig {
538            default_dimensions: 8,
539            ..HnswServiceConfig::default()
540        }));
541        let iq = Arc::new(ImpulseQueue::new());
542        let crs = Arc::new(CrossRefStore::new());
543        let emb: Arc<dyn EmbeddingProvider> = Arc::new(MockEmbeddingProvider::new(8));
544
545        let democritus = DemocritusLoop::new(
546            Arc::clone(&cg),
547            Arc::clone(&vector),
548            Arc::clone(&iq),
549            Arc::clone(&crs),
550            emb,
551            config,
552        );
553        (cg, vector, iq, crs, democritus)
554    }
555
556    /// In-memory counting backend used to prove DemocritusLoop is
557    /// wiring-only over `VectorBackend` (no HNSW-specific path).
558    struct CountingBackend {
559        name: &'static str,
560        entries: Mutex<Vec<CountedEntry>>,
561        inserts: AtomicUsize,
562        searches: AtomicUsize,
563    }
564
565    impl CountingBackend {
566        fn new(name: &'static str) -> Self {
567            Self {
568                name,
569                entries: Mutex::new(Vec::new()),
570                inserts: AtomicUsize::new(0),
571                searches: AtomicUsize::new(0),
572            }
573        }
574
575        fn insert_count(&self) -> usize {
576            self.inserts.load(Ordering::Relaxed)
577        }
578
579        fn search_count(&self) -> usize {
580            self.searches.load(Ordering::Relaxed)
581        }
582    }
583
584    impl VectorBackend for CountingBackend {
585        fn insert(
586            &self,
587            id: u64,
588            key: &str,
589            vector: &[f32],
590            metadata: serde_json::Value,
591        ) -> VectorResult<()> {
592            self.inserts.fetch_add(1, Ordering::Relaxed);
593            let mut entries = self.entries.lock().expect("entries lock");
594            entries.retain(|(eid, _, _, _)| *eid != id);
595            entries.push((id, key.to_owned(), vector.to_vec(), metadata));
596            Ok(())
597        }
598
599        fn search(&self, query: &[f32], k: usize) -> Vec<SearchResult> {
600            self.searches.fetch_add(1, Ordering::Relaxed);
601            let entries = self.entries.lock().expect("entries lock");
602            let mut scored: Vec<(f32, &CountedEntry)> = entries
603                .iter()
604                .map(|e| {
605                    // Cosine similarity → distance = 1 - sim (match HnswBackend).
606                    let sim = cosine_sim(query, &e.2);
607                    (1.0 - sim, e)
608                })
609                .collect();
610            scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
611            scored
612                .into_iter()
613                .take(k)
614                .map(|(dist, e)| SearchResult::new(e.0, e.1.clone(), dist, e.3.clone()))
615                .collect()
616        }
617
618        fn len(&self) -> usize {
619            self.entries.lock().expect("entries lock").len()
620        }
621
622        fn contains(&self, id: u64) -> bool {
623            self.entries
624                .lock()
625                .expect("entries lock")
626                .iter()
627                .any(|(eid, _, _, _)| *eid == id)
628        }
629
630        fn remove(&self, id: u64) -> bool {
631            let mut entries = self.entries.lock().expect("entries lock");
632            let before = entries.len();
633            entries.retain(|(eid, _, _, _)| *eid != id);
634            before != entries.len()
635        }
636
637        fn flush(&self) -> VectorResult<()> {
638            Ok(())
639        }
640
641        fn backend_name(&self) -> &str {
642            self.name
643        }
644    }
645
646    fn cosine_sim(a: &[f32], b: &[f32]) -> f32 {
647        if a.is_empty() || b.is_empty() || a.len() != b.len() {
648            return 0.0;
649        }
650        let mut dot = 0.0f32;
651        let mut na = 0.0f32;
652        let mut nb = 0.0f32;
653        for i in 0..a.len() {
654            dot += a[i] * b[i];
655            na += a[i] * a[i];
656            nb += b[i] * b[i];
657        }
658        let denom = na.sqrt() * nb.sqrt();
659        if denom == 0.0 {
660            0.0
661        } else {
662            dot / denom
663        }
664    }
665
666    fn emit_test_impulse(iq: &ImpulseQueue, impulse_type: ImpulseType, ts: u64) -> u64 {
667        iq.emit(
668            StructureTag::CausalGraph.as_u8(),
669            [0u8; 32],
670            StructureTag::HnswIndex.as_u8(),
671            impulse_type,
672            serde_json::json!({"test": true}),
673            ts,
674        )
675    }
676
677    // ── Test 1: Empty impulse queue — tick completes with no new nodes ──
678
679    #[tokio::test]
680    async fn empty_queue_produces_no_work() {
681        let (_cg, _vector, _iq, _crs, demo) = make_loop();
682        let result = demo.tick().await;
683
684        assert_eq!(result.impulses_sensed, 0);
685        assert_eq!(result.embeddings_produced, 0);
686        assert_eq!(result.searches_performed, 0);
687        assert_eq!(result.edges_added, 0);
688        assert_eq!(result.crossrefs_added, 0);
689        assert!(!result.budget_exceeded);
690        assert_eq!(demo.total_ticks(), 1);
691        assert_eq!(demo.total_nodes_added(), 0);
692    }
693
694    // ── Test 2: Single impulse → full pipeline ──
695
696    #[tokio::test]
697    async fn single_impulse_full_pipeline() {
698        let (cg, vector, iq, crs, demo) = make_loop();
699
700        emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 100);
701
702        let result = demo.tick().await;
703
704        assert_eq!(result.impulses_sensed, 1);
705        assert_eq!(result.embeddings_produced, 1);
706        assert_eq!(result.searches_performed, 1);
707        // No pre-existing neighbors, so no edges added.
708        assert_eq!(result.edges_added, 0);
709        // One cross-ref should be registered.
710        assert_eq!(result.crossrefs_added, 1);
711        // Causal graph should have one node.
712        assert_eq!(cg.node_count(), 1);
713        // Vector backend should have one entry.
714        assert_eq!(vector.len(), 1);
715        // CrossRefStore should have one entry.
716        assert_eq!(crs.count(), 1);
717        assert_eq!(demo.total_nodes_added(), 1);
718        // Default binding is HNSW.
719        assert_eq!(demo.vector_backend().backend_name(), "hnsw");
720    }
721
722    // ── Test 3: Multiple impulses in one tick — batch processing ──
723
724    #[tokio::test]
725    async fn multiple_impulses_batch_processing() {
726        let (cg, _vector, iq, crs, demo) = make_loop();
727
728        emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 100);
729        emit_test_impulse(&iq, ImpulseType::CoherenceAlert, 200);
730        emit_test_impulse(&iq, ImpulseType::NoveltyDetected, 300);
731
732        let result = demo.tick().await;
733
734        assert_eq!(result.impulses_sensed, 3);
735        assert_eq!(result.embeddings_produced, 3);
736        assert_eq!(result.searches_performed, 3);
737        assert_eq!(result.crossrefs_added, 3);
738        assert_eq!(cg.node_count(), 3);
739        assert_eq!(crs.count(), 3);
740    }
741
742    // ── Test 4: Tick respects budget (stops early if budget exceeded) ──
743
744    #[tokio::test]
745    async fn tick_respects_budget() {
746        // Use a budget of 0 microseconds so the tick must stop immediately.
747        let config = DemocritusConfig {
748            tick_budget_us: 0,
749            ..DemocritusConfig::default()
750        };
751        let (_cg, _vector, iq, _crs, demo) = make_loop_with_config(config);
752
753        // Emit several impulses.
754        for i in 0..10 {
755            emit_test_impulse(&iq, ImpulseType::BeliefUpdate, i);
756        }
757
758        let result = demo.tick().await;
759
760        // With a zero budget, the tick should have been cut short.
761        assert!(result.budget_exceeded);
762        // Tick counter still increments.
763        assert_eq!(demo.total_ticks(), 1);
764    }
765
766    // ── Test 5: CrossRef created linking new node to source entity ──
767
768    #[tokio::test]
769    async fn crossref_links_node_to_source() {
770        let (_cg, _vector, iq, crs, demo) = make_loop();
771
772        let source_node = [42u8; 32];
773        iq.emit(
774            StructureTag::ExoChain.as_u8(),
775            source_node,
776            StructureTag::HnswIndex.as_u8(),
777            ImpulseType::EdgeConfirmed,
778            serde_json::json!({"chain": "test"}),
779            500,
780        );
781
782        demo.tick().await;
783
784        // Verify cross-ref exists with the correct target (the source node).
785        let target_uni = UniversalNodeId::from_bytes(source_node);
786        let refs = crs.get_reverse(&target_uni);
787        assert_eq!(refs.len(), 1);
788        assert_eq!(refs[0].target_structure, StructureTag::ExoChain);
789        assert_eq!(refs[0].ref_type, CrossRefType::TriggeredBy);
790    }
791
792    // ── Test 6: Tick statistics increment correctly ──
793
794    #[tokio::test]
795    async fn tick_statistics_increment() {
796        let (_cg, _vector, iq, _crs, demo) = make_loop();
797
798        assert_eq!(demo.total_ticks(), 0);
799        assert_eq!(demo.total_nodes_added(), 0);
800        assert_eq!(demo.total_edges_added(), 0);
801
802        // Tick 1: one impulse.
803        emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 10);
804        demo.tick().await;
805        assert_eq!(demo.total_ticks(), 1);
806        assert_eq!(demo.total_nodes_added(), 1);
807
808        // Tick 2: two impulses.
809        emit_test_impulse(&iq, ImpulseType::CoherenceAlert, 20);
810        emit_test_impulse(&iq, ImpulseType::NoveltyDetected, 30);
811        demo.tick().await;
812        assert_eq!(demo.total_ticks(), 2);
813        assert_eq!(demo.total_nodes_added(), 3);
814    }
815
816    // ── Test 7: Vector search returns relevant neighbors ──
817
818    #[tokio::test]
819    async fn vector_returns_neighbors_on_second_tick() {
820        let (_cg, vector, iq, _crs, demo) = make_loop();
821
822        // First tick: insert a node.
823        emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 100);
824        demo.tick().await;
825        assert_eq!(vector.len(), 1);
826
827        // Second tick: same impulse type/payload should find the first as neighbor.
828        emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 200);
829        let result = demo.tick().await;
830
831        assert_eq!(result.searches_performed, 1);
832        // Two inserts: one per tick.
833        assert_eq!(vector.len(), 2);
834        // Search finds the prior entry (wiring exercised).
835        let emb: Arc<dyn EmbeddingProvider> = Arc::new(MockEmbeddingProvider::new(8));
836        let query = emb
837            .embed(&format!(
838                "{}:{}",
839                ImpulseType::BeliefUpdate,
840                serde_json::json!({"test": true})
841            ))
842            .await
843            .expect("mock embed");
844        let hits = vector.search(&query, 5);
845        assert!(!hits.is_empty(), "vector backend must return prior entries");
846    }
847
848    // ── Test 8: Causal edge type selection ──
849
850    #[tokio::test]
851    async fn edge_type_classification() {
852        let (_, _, _, _, demo) = make_loop();
853
854        let impulse_belief = Impulse {
855            id: 1,
856            source_structure: 0,
857            source_node: [0u8; 32],
858            target_structure: 2,
859            impulse_type: ImpulseType::BeliefUpdate,
860            payload: serde_json::json!({}),
861            hlc_timestamp: 0,
862            acknowledged: std::sync::atomic::AtomicBool::new(false),
863        };
864
865        // High similarity → Correlates.
866        assert_eq!(
867            demo.classify_edge(&impulse_belief, 0.9),
868            CausalEdgeType::Correlates
869        );
870
871        // Below threshold, BeliefUpdate → Follows.
872        assert_eq!(
873            demo.classify_edge(&impulse_belief, 0.3),
874            CausalEdgeType::Follows
875        );
876
877        // EdgeConfirmed → Causes.
878        let impulse_confirmed = Impulse {
879            impulse_type: ImpulseType::EdgeConfirmed,
880            ..impulse_belief.clone()
881        };
882        assert_eq!(
883            demo.classify_edge(&impulse_confirmed, 0.3),
884            CausalEdgeType::Causes
885        );
886
887        // CoherenceAlert → EvidenceFor.
888        let impulse_coherence = Impulse {
889            impulse_type: ImpulseType::CoherenceAlert,
890            ..impulse_belief.clone()
891        };
892        assert_eq!(
893            demo.classify_edge(&impulse_coherence, 0.3),
894            CausalEdgeType::EvidenceFor
895        );
896
897        // EmbeddingRefined → Enables.
898        let impulse_refined = Impulse {
899            impulse_type: ImpulseType::EmbeddingRefined,
900            ..impulse_belief.clone()
901        };
902        assert_eq!(
903            demo.classify_edge(&impulse_refined, 0.3),
904            CausalEdgeType::Enables
905        );
906    }
907
908    // ── Test 9: Commit phase logs and updates total_ticks ──
909
910    #[tokio::test]
911    async fn commit_updates_tick_counter() {
912        let (_, _, _, _, demo) = make_loop();
913
914        // Empty ticks still increment the tick counter.
915        demo.tick().await;
916        demo.tick().await;
917        demo.tick().await;
918
919        assert_eq!(demo.total_ticks(), 3);
920    }
921
922    // ── Test 10: Embedding errors handled gracefully ──
923
924    #[tokio::test]
925    async fn embedding_error_falls_back_gracefully() {
926        use crate::embedding::EmbeddingError;
927
928        /// Provider that always fails.
929        struct FailingProvider;
930
931        #[async_trait::async_trait]
932        impl EmbeddingProvider for FailingProvider {
933            async fn embed(&self, _text: &str) -> Result<Vec<f32>, EmbeddingError> {
934                Err(EmbeddingError::BackendError("test failure".into()))
935            }
936            async fn embed_batch(&self, _texts: &[&str]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
937                Err(EmbeddingError::BackendError("test failure".into()))
938            }
939            fn dimensions(&self) -> usize {
940                8
941            }
942            fn model_name(&self) -> &str {
943                "failing-test"
944            }
945        }
946
947        let cg = Arc::new(CausalGraph::new());
948        let vector: Arc<dyn VectorBackend> =
949            Arc::new(HnswBackend::new(HnswServiceConfig::default()));
950        let iq = Arc::new(ImpulseQueue::new());
951        let crs = Arc::new(CrossRefStore::new());
952        let emb: Arc<dyn EmbeddingProvider> = Arc::new(FailingProvider);
953
954        let demo = DemocritusLoop::new(
955            Arc::clone(&cg),
956            Arc::clone(&vector),
957            Arc::clone(&iq),
958            Arc::clone(&crs),
959            emb,
960            DemocritusConfig::default(),
961        );
962
963        emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 100);
964
965        let result = demo.tick().await;
966
967        // Embedding failed → empty vectors, but tick still completes.
968        assert_eq!(result.impulses_sensed, 1);
969        assert_eq!(result.embeddings_produced, 1); // fallback produces empty vecs
970        // Search with empty vector is skipped (non_empty_queries filter).
971        assert_eq!(result.searches_performed, 0);
972        // Node still added to causal graph.
973        assert_eq!(cg.node_count(), 1);
974        // But no vector insertion (empty embedding skipped).
975        assert_eq!(vector.len(), 0);
976        // Cross-ref still created.
977        assert_eq!(result.crossrefs_added, 1);
978    }
979
980    // ── Test 11: max_impulses_per_tick truncation ──
981
982    #[tokio::test]
983    async fn max_impulses_per_tick_truncation() {
984        let config = DemocritusConfig {
985            max_impulses_per_tick: 2,
986            ..DemocritusConfig::default()
987        };
988        let (_cg, _vector, iq, _crs, demo) = make_loop_with_config(config);
989
990        // Emit 5 impulses.
991        for i in 0..5 {
992            emit_test_impulse(&iq, ImpulseType::BeliefUpdate, i);
993        }
994
995        let result = demo.tick().await;
996
997        // Only 2 should be processed due to truncation.
998        assert_eq!(result.impulses_sensed, 2);
999        assert_eq!(result.embeddings_produced, 2);
1000    }
1001
1002    // ── Test 12: structure_tag_from_u8 mapping ──
1003
1004    #[test]
1005    fn structure_tag_roundtrip() {
1006        assert_eq!(structure_tag_from_u8(0x01), StructureTag::ExoChain);
1007        assert_eq!(structure_tag_from_u8(0x02), StructureTag::ResourceTree);
1008        assert_eq!(structure_tag_from_u8(0x03), StructureTag::CausalGraph);
1009        assert_eq!(structure_tag_from_u8(0x04), StructureTag::HnswIndex);
1010        assert_eq!(structure_tag_from_u8(0xFF), StructureTag::Custom(0xFF));
1011    }
1012
1013    // ── Sprint 11: Budget exhaustion tests ──────────────────────────
1014
1015    #[tokio::test]
1016    async fn budget_exhaustion_with_many_impulses() {
1017        // Use a budget of 0 microseconds with many impulses to force
1018        // budget exhaustion at different phases.
1019        let config = DemocritusConfig {
1020            tick_budget_us: 0,
1021            max_impulses_per_tick: 100,
1022            ..DemocritusConfig::default()
1023        };
1024        let (cg, _vector, iq, _crs, demo) = make_loop_with_config(config);
1025
1026        for i in 0..50 {
1027            emit_test_impulse(&iq, ImpulseType::BeliefUpdate, i);
1028        }
1029
1030        let result = demo.tick().await;
1031        assert!(result.budget_exceeded);
1032        // Even with budget exceeded, tick count increments.
1033        assert_eq!(demo.total_ticks(), 1);
1034        // Some impulses may have been sensed before budget check.
1035        assert!(result.impulses_sensed <= 50);
1036        // Causal graph nodes added should match embeddings completed
1037        // (may be fewer than sensed due to budget).
1038        assert!(cg.node_count() <= result.impulses_sensed as u64);
1039    }
1040
1041    #[tokio::test]
1042    async fn budget_exceeded_flag_only_set_when_needed() {
1043        // Large budget should not trigger budget_exceeded.
1044        let config = DemocritusConfig {
1045            tick_budget_us: 10_000_000, // 10 seconds
1046            ..DemocritusConfig::default()
1047        };
1048        let (_, _, iq, _, demo) = make_loop_with_config(config);
1049
1050        emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 1);
1051        let result = demo.tick().await;
1052        assert!(!result.budget_exceeded);
1053    }
1054
1055    // ── Sprint 11: ImpulseQueue overflow tests ──────────────────────
1056
1057    #[tokio::test]
1058    async fn impulse_queue_large_burst() {
1059        let config = DemocritusConfig {
1060            max_impulses_per_tick: 10,
1061            ..DemocritusConfig::default()
1062        };
1063        let (_, _, iq, _, demo) = make_loop_with_config(config);
1064
1065        // Emit far more impulses than per-tick limit.
1066        for i in 0..500 {
1067            emit_test_impulse(&iq, ImpulseType::BeliefUpdate, i);
1068        }
1069
1070        // First tick processes at most 10.
1071        let r1 = demo.tick().await;
1072        assert_eq!(r1.impulses_sensed, 10);
1073
1074        // Queue was drained fully (drain_ready takes all), but only 10 processed.
1075        // Remaining impulses are gone (drain clears the queue).
1076        let r2 = demo.tick().await;
1077        assert_eq!(r2.impulses_sensed, 0);
1078    }
1079
1080    #[tokio::test]
1081    async fn impulse_queue_interleaved_emit_and_tick() {
1082        let (cg, _, iq, _, demo) = make_loop();
1083
1084        // Emit, tick, emit, tick — verify state accumulates.
1085        emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 10);
1086        let r1 = demo.tick().await;
1087        assert_eq!(r1.impulses_sensed, 1);
1088        assert_eq!(cg.node_count(), 1);
1089
1090        emit_test_impulse(&iq, ImpulseType::CoherenceAlert, 20);
1091        emit_test_impulse(&iq, ImpulseType::NoveltyDetected, 30);
1092        let r2 = demo.tick().await;
1093        assert_eq!(r2.impulses_sensed, 2);
1094        assert_eq!(cg.node_count(), 3);
1095
1096        assert_eq!(demo.total_ticks(), 2);
1097        assert_eq!(demo.total_nodes_added(), 3);
1098    }
1099
1100    // ── Sprint 11: Embed failure recovery tests ─────────────────────
1101
1102    #[tokio::test]
1103    async fn embed_failure_still_creates_crossrefs() {
1104        use crate::embedding::EmbeddingError;
1105
1106        struct FailingProvider;
1107
1108        #[async_trait::async_trait]
1109        impl EmbeddingProvider for FailingProvider {
1110            async fn embed(&self, _text: &str) -> Result<Vec<f32>, EmbeddingError> {
1111                Err(EmbeddingError::BackendError("test failure".into()))
1112            }
1113            async fn embed_batch(&self, _texts: &[&str]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
1114                Err(EmbeddingError::BackendError("test failure".into()))
1115            }
1116            fn dimensions(&self) -> usize {
1117                8
1118            }
1119            fn model_name(&self) -> &str {
1120                "failing-test"
1121            }
1122        }
1123
1124        let cg = Arc::new(CausalGraph::new());
1125        let vector: Arc<dyn VectorBackend> =
1126            Arc::new(HnswBackend::new(HnswServiceConfig::default()));
1127        let iq = Arc::new(ImpulseQueue::new());
1128        let crs = Arc::new(CrossRefStore::new());
1129        let emb: Arc<dyn EmbeddingProvider> = Arc::new(FailingProvider);
1130
1131        let demo = DemocritusLoop::new(
1132            Arc::clone(&cg),
1133            Arc::clone(&vector),
1134            Arc::clone(&iq),
1135            Arc::clone(&crs),
1136            emb,
1137            DemocritusConfig::default(),
1138        );
1139
1140        // Emit multiple impulses.
1141        for i in 0..5 {
1142            emit_test_impulse(&iq, ImpulseType::BeliefUpdate, i * 100);
1143        }
1144
1145        let result = demo.tick().await;
1146        assert_eq!(result.impulses_sensed, 5);
1147        // Fallback: 5 empty vectors produced.
1148        assert_eq!(result.embeddings_produced, 5);
1149        // Causal nodes still created despite embed failure.
1150        assert_eq!(cg.node_count(), 5);
1151        // Cross-refs still created.
1152        assert_eq!(result.crossrefs_added, 5);
1153        assert_eq!(crs.count(), 5);
1154        // No vector insertions (empty embeddings skipped).
1155        assert_eq!(vector.len(), 0);
1156    }
1157
1158    #[tokio::test]
1159    async fn embed_failure_no_edges_added() {
1160        use crate::embedding::EmbeddingError;
1161
1162        struct FailingProvider;
1163
1164        #[async_trait::async_trait]
1165        impl EmbeddingProvider for FailingProvider {
1166            async fn embed(&self, _text: &str) -> Result<Vec<f32>, EmbeddingError> {
1167                Err(EmbeddingError::BackendError("fail".into()))
1168            }
1169            async fn embed_batch(&self, _texts: &[&str]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
1170                Err(EmbeddingError::BackendError("fail".into()))
1171            }
1172            fn dimensions(&self) -> usize {
1173                8
1174            }
1175            fn model_name(&self) -> &str {
1176                "fail"
1177            }
1178        }
1179
1180        let cg = Arc::new(CausalGraph::new());
1181        let vector: Arc<dyn VectorBackend> =
1182            Arc::new(HnswBackend::new(HnswServiceConfig::default()));
1183        let iq = Arc::new(ImpulseQueue::new());
1184        let crs = Arc::new(CrossRefStore::new());
1185        let emb: Arc<dyn EmbeddingProvider> = Arc::new(FailingProvider);
1186
1187        let demo = DemocritusLoop::new(
1188            Arc::clone(&cg),
1189            Arc::clone(&vector),
1190            Arc::clone(&iq),
1191            Arc::clone(&crs),
1192            emb,
1193            DemocritusConfig::default(),
1194        );
1195
1196        emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 100);
1197        let result = demo.tick().await;
1198        // With empty embeddings, search returns no neighbors, so no edges.
1199        assert_eq!(result.edges_added, 0);
1200        assert_eq!(demo.total_edges_added(), 0);
1201    }
1202
1203    // ── Sprint 11: Multiple sequential ticks with accumulated state ──
1204
1205    #[tokio::test]
1206    async fn multiple_sequential_ticks_accumulate_state() {
1207        let (cg, vector, iq, crs, demo) = make_loop();
1208
1209        // Tick 1: single impulse.
1210        emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 100);
1211        let r1 = demo.tick().await;
1212        assert_eq!(r1.impulses_sensed, 1);
1213        let nodes_after_1 = cg.node_count();
1214        let vector_after_1 = vector.len();
1215
1216        // Tick 2: two more impulses.
1217        emit_test_impulse(&iq, ImpulseType::CoherenceAlert, 200);
1218        emit_test_impulse(&iq, ImpulseType::NoveltyDetected, 300);
1219        let r2 = demo.tick().await;
1220        assert_eq!(r2.impulses_sensed, 2);
1221        assert_eq!(cg.node_count(), nodes_after_1 + 2);
1222        assert_eq!(vector.len(), vector_after_1 + 2);
1223
1224        // Tick 3: three more.
1225        emit_test_impulse(&iq, ImpulseType::EdgeConfirmed, 400);
1226        emit_test_impulse(&iq, ImpulseType::EmbeddingRefined, 500);
1227        emit_test_impulse(&iq, ImpulseType::Custom(42), 600);
1228        let r3 = demo.tick().await;
1229        assert_eq!(r3.impulses_sensed, 3);
1230        assert_eq!(cg.node_count(), nodes_after_1 + 5);
1231
1232        // Total statistics.
1233        assert_eq!(demo.total_ticks(), 3);
1234        assert_eq!(demo.total_nodes_added(), 6);
1235        // Cross-refs: one per impulse.
1236        assert_eq!(crs.count(), 6);
1237    }
1238
1239    #[tokio::test]
1240    async fn sequential_ticks_can_find_prior_neighbors() {
1241        let config = DemocritusConfig {
1242            correlation_threshold: 0.0, // accept all as correlated
1243            ..DemocritusConfig::default()
1244        };
1245        let (cg, vector, iq, _, demo) = make_loop_with_config(config);
1246
1247        // Tick 1: insert a node.
1248        emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 100);
1249        demo.tick().await;
1250        assert_eq!(vector.len(), 1);
1251
1252        // Tick 2: same type should find tick-1's node as neighbor.
1253        emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 200);
1254        let r2 = demo.tick().await;
1255        assert_eq!(r2.searches_performed, 1);
1256        // With threshold=0.0, any non-zero similarity creates an edge.
1257        // The mock provider produces deterministic vectors, so same impulse
1258        // type gets same embedding, yielding high similarity.
1259        // Edges depend on whether neighbor_id parses as a valid node_id.
1260        assert!(cg.node_count() >= 2);
1261    }
1262
1263    // ── Sprint 11: Config edge cases ────────────────────────────────
1264
1265    #[tokio::test]
1266    async fn zero_max_impulses_per_tick() {
1267        let config = DemocritusConfig {
1268            max_impulses_per_tick: 0,
1269            ..DemocritusConfig::default()
1270        };
1271        let (_, _, iq, _, demo) = make_loop_with_config(config);
1272
1273        emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 100);
1274        let result = demo.tick().await;
1275        // Truncation to 0 means no impulses processed.
1276        assert_eq!(result.impulses_sensed, 0);
1277        assert_eq!(result.embeddings_produced, 0);
1278    }
1279
1280    #[tokio::test]
1281    async fn tick_result_duration_is_positive() {
1282        let (_, _, iq, _, demo) = make_loop();
1283        emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 100);
1284        let result = demo.tick().await;
1285        // Duration should be non-negative (may be 0 on very fast systems).
1286        assert!(
1287            result.duration_us < 10_000_000,
1288            "tick should complete within 10s"
1289        );
1290    }
1291
1292    #[test]
1293    fn classify_edge_custom_impulse_type() {
1294        let (_, _, _, _, demo) = make_loop();
1295        let impulse = Impulse {
1296            id: 1,
1297            source_structure: 0,
1298            source_node: [0u8; 32],
1299            target_structure: 2,
1300            impulse_type: ImpulseType::Custom(99),
1301            payload: serde_json::json!({}),
1302            hlc_timestamp: 0,
1303            acknowledged: std::sync::atomic::AtomicBool::new(false),
1304        };
1305
1306        // Custom type below threshold → Follows.
1307        assert_eq!(demo.classify_edge(&impulse, 0.3), CausalEdgeType::Follows);
1308        // Custom type above threshold → Correlates.
1309        assert_eq!(
1310            demo.classify_edge(&impulse, 0.9),
1311            CausalEdgeType::Correlates
1312        );
1313    }
1314
1315    // ── WEFT-642: chain_seq joinability ──────────────────────────────
1316
1317    fn emit_impulse_with_chain_seq(
1318        iq: &ImpulseQueue,
1319        impulse_type: ImpulseType,
1320        chain_seq: u64,
1321        conv_id: &str,
1322    ) -> u64 {
1323        // Mirror session_tier::index_turn: payload carries chain_seq and
1324        // HLC is stamped with the same sequence.
1325        iq.emit(
1326            StructureTag::ExoChain.as_u8(),
1327            [7u8; 32],
1328            StructureTag::HnswIndex.as_u8(),
1329            impulse_type,
1330            serde_json::json!({ "chain_seq": chain_seq, "conv_id": conv_id }),
1331            chain_seq,
1332        )
1333    }
1334
1335    #[test]
1336    fn resolve_chain_seq_reads_payload() {
1337        let with_seq = Impulse {
1338            id: 1,
1339            source_structure: 0,
1340            source_node: [0u8; 32],
1341            target_structure: 2,
1342            impulse_type: ImpulseType::BeliefUpdate,
1343            payload: serde_json::json!({ "chain_seq": 8633u64, "conv_id": "c1" }),
1344            hlc_timestamp: 8633,
1345            acknowledged: std::sync::atomic::AtomicBool::new(false),
1346        };
1347        assert_eq!(resolve_chain_seq(&with_seq), 8633);
1348
1349        let missing = Impulse {
1350            payload: serde_json::json!({ "test": true }),
1351            ..with_seq.clone()
1352        };
1353        assert_eq!(resolve_chain_seq(&missing), 0);
1354
1355        let non_object = Impulse {
1356            payload: serde_json::json!("plain"),
1357            ..with_seq
1358        };
1359        assert_eq!(resolve_chain_seq(&non_object), 0);
1360    }
1361
1362    /// Joinability assertion (WEFT-642): when the emitter supplies
1363    /// `payload.chain_seq`, every DEMOCRITUS projection must carry that
1364    /// sequence — causal node metadata, HNSW metadata, and CrossRef.
1365    /// A zero chain_seq on any of those would re-open the panopticon
1366    /// "looks alive but does not join back" class.
1367    #[tokio::test]
1368    async fn chain_seq_joinable_on_hnsw_node_and_crossref() {
1369        let (cg, vector, iq, crs, demo) = make_loop();
1370        const SEQ: u64 = 42_001;
1371
1372        emit_impulse_with_chain_seq(&iq, ImpulseType::BeliefUpdate, SEQ, "conv-weft-642");
1373        let result = demo.tick().await;
1374
1375        assert_eq!(result.impulses_sensed, 1);
1376        assert_eq!(result.crossrefs_added, 1);
1377        assert_eq!(cg.node_count(), 1);
1378        assert_eq!(vector.len(), 1);
1379
1380        // Causal node metadata carries chain_seq.
1381        let node_ids = cg.node_ids();
1382        assert_eq!(node_ids.len(), 1);
1383        let node = cg.get_node(node_ids[0]).expect("node present");
1384        assert_eq!(
1385            node.metadata.get("chain_seq").and_then(|v| v.as_u64()),
1386            Some(SEQ),
1387            "causal node must join to atom spine via chain_seq"
1388        );
1389
1390        // Vector backend entry metadata carries the same chain_seq (join key).
1391        // Use a real embed of the same shape as the loop (type:payload).
1392        let emb: Arc<dyn EmbeddingProvider> = Arc::new(MockEmbeddingProvider::new(8));
1393        let query = emb
1394            .embed(&format!(
1395                "{}:{}",
1396                ImpulseType::BeliefUpdate,
1397                serde_json::json!({ "chain_seq": SEQ, "conv_id": "conv-weft-642" })
1398            ))
1399            .await
1400            .expect("mock embed");
1401        let hits = vector.search(&query, 5);
1402        assert!(
1403            !hits.is_empty(),
1404            "vector backend must return the inserted brain entry"
1405        );
1406        let meta = &hits[0].metadata;
1407        assert_eq!(
1408            meta.get("chain_seq").and_then(|v| v.as_u64()),
1409            Some(SEQ),
1410            "vector metadata must carry real chain_seq (not hardcoded 0)"
1411        );
1412        assert_eq!(
1413            meta.get("causal_node_id").and_then(|v| v.as_u64()),
1414            Some(node_ids[0]),
1415            "vector metadata should point back at the causal node id"
1416        );
1417
1418        // CrossRef chain_seq must match.
1419        let refs = crs.all();
1420        assert_eq!(refs.len(), 1);
1421        assert_eq!(
1422            refs[0].chain_seq, SEQ,
1423            "CrossRef must carry real chain_seq (not hardcoded 0)"
1424        );
1425    }
1426
1427    /// Edge path (democritus.rs link site): neighbor edges must inherit the
1428    /// impulse's chain_seq so causal edges also join the atom spine.
1429    #[tokio::test]
1430    async fn chain_seq_joinable_on_causal_edges() {
1431        let config = DemocritusConfig {
1432            correlation_threshold: 0.0, // force edges on any neighbor hit
1433            ..DemocritusConfig::default()
1434        };
1435        let (cg, vector, iq, _, demo) = make_loop_with_config(config);
1436
1437        // Seed a neighbor with a known sequence.
1438        emit_impulse_with_chain_seq(&iq, ImpulseType::BeliefUpdate, 100, "conv-edge");
1439        demo.tick().await;
1440        assert_eq!(vector.len(), 1);
1441
1442        // Second tick: same impulse type → same mock embedding → neighbor hit.
1443        const SEQ2: u64 = 101;
1444        emit_impulse_with_chain_seq(&iq, ImpulseType::BeliefUpdate, SEQ2, "conv-edge");
1445        let r2 = demo.tick().await;
1446        assert_eq!(r2.searches_performed, 1);
1447
1448        // Find the newer node and assert its outgoing edges carry SEQ2.
1449        let mut nodes: Vec<_> = cg
1450            .node_ids()
1451            .into_iter()
1452            .filter_map(|id| cg.get_node(id))
1453            .collect();
1454        nodes.sort_by_key(|n| n.id);
1455        assert!(nodes.len() >= 2, "expected at least two causal nodes");
1456        let newer = nodes.last().expect("newer node");
1457        assert_eq!(
1458            newer.metadata.get("chain_seq").and_then(|v| v.as_u64()),
1459            Some(SEQ2)
1460        );
1461
1462        let edges = cg.get_forward_edges(newer.id);
1463        // With correlation_threshold=0 and a prior neighbor, at least one edge.
1464        assert!(
1465            !edges.is_empty(),
1466            "expected similarity edge(s) from second impulse to first"
1467        );
1468        for edge in &edges {
1469            assert_eq!(
1470                edge.chain_seq, SEQ2,
1471                "causal edge must carry the impulse chain_seq (not hardcoded 0)"
1472            );
1473        }
1474    }
1475
1476    // ── WEFT-124: VectorBackend wiring ───────────────────────────────
1477
1478    /// Backend swap is wiring-only: DemocritusLoop talks exclusively to
1479    /// `Arc<dyn VectorBackend>`. A non-HNSW counting backend still runs
1480    /// the full Sense→Embed→Search→Update→Commit path.
1481    #[tokio::test]
1482    async fn backend_swap_is_wiring_only() {
1483        let cg = Arc::new(CausalGraph::new());
1484        let counter = Arc::new(CountingBackend::new("counting-mock"));
1485        let vector: Arc<dyn VectorBackend> = counter.clone();
1486        let iq = Arc::new(ImpulseQueue::new());
1487        let crs = Arc::new(CrossRefStore::new());
1488        let emb: Arc<dyn EmbeddingProvider> = Arc::new(MockEmbeddingProvider::new(8));
1489
1490        let demo = DemocritusLoop::new(
1491            Arc::clone(&cg),
1492            Arc::clone(&vector),
1493            Arc::clone(&iq),
1494            Arc::clone(&crs),
1495            emb,
1496            DemocritusConfig {
1497                correlation_threshold: 0.0,
1498                ..DemocritusConfig::default()
1499            },
1500        );
1501
1502        assert_eq!(demo.vector_backend().backend_name(), "counting-mock");
1503
1504        // Tick 1: insert via the swapped backend.
1505        emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 100);
1506        let r1 = demo.tick().await;
1507        assert_eq!(r1.impulses_sensed, 1);
1508        assert_eq!(r1.searches_performed, 1);
1509        assert_eq!(counter.insert_count(), 1);
1510        assert_eq!(counter.search_count(), 1);
1511        assert_eq!(vector.len(), 1);
1512        assert_eq!(cg.node_count(), 1);
1513
1514        // Tick 2: search should hit the prior entry through the same backend.
1515        emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 200);
1516        let r2 = demo.tick().await;
1517        assert_eq!(r2.searches_performed, 1);
1518        assert_eq!(counter.insert_count(), 2);
1519        assert_eq!(counter.search_count(), 2);
1520        assert_eq!(vector.len(), 2);
1521        // With threshold=0 and a prior neighbor, an edge should form.
1522        assert!(
1523            r2.edges_added >= 1 || demo.total_edges_added() >= 1,
1524            "swapped backend neighbor hit should produce causal edges"
1525        );
1526    }
1527
1528    #[tokio::test]
1529    async fn default_binding_is_hnsw_backend() {
1530        let (_cg, vector, _iq, _crs, demo) = make_loop();
1531        assert_eq!(vector.backend_name(), "hnsw");
1532        assert_eq!(demo.vector_backend().backend_name(), "hnsw");
1533    }
1534}