1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct DemocritusConfig {
38 pub max_impulses_per_tick: usize,
40 pub search_k: usize,
42 pub correlation_threshold: f32,
44 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, }
56 }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct DemocritusTickResult {
66 pub impulses_sensed: usize,
68 pub embeddings_produced: usize,
70 pub searches_performed: usize,
72 pub edges_added: usize,
74 pub crossrefs_added: usize,
76 pub budget_exceeded: bool,
78 pub duration_us: u64,
80}
81
82pub struct DemocritusLoop {
94 causal_graph: Arc<CausalGraph>,
96 vector: Arc<dyn VectorBackend>,
98 impulse_queue: Arc<ImpulseQueue>,
99 crossref_store: Arc<CrossRefStore>,
100 embedding_provider: Arc<dyn EmbeddingProvider>,
101 config: DemocritusConfig,
103 total_ticks: AtomicU64,
105 total_nodes_added: AtomicU64,
106 total_edges_added: AtomicU64,
107}
108
109impl DemocritusLoop {
110 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 pub fn vector_backend(&self) -> &dyn VectorBackend {
137 self.vector.as_ref()
138 }
139
140 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 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 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 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 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 result.duration_us = start.elapsed().as_micros() as u64;
219 self.commit(&result);
220 result
221 }
222
223 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 async fn embed(&self, impulses: &[Impulse]) -> Vec<Vec<f32>> {
238 let texts: Vec<String> = impulses
239 .iter()
240 .map(|imp| {
241 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 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 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 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 let chain_seq = resolve_chain_seq(impulse);
306
307 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 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 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 let source_tag = structure_tag_from_u8(impulse.source_structure);
361 let uni_id = UniversalNodeId::new(
362 &StructureTag::CausalGraph,
363 label.as_bytes(),
364 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 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 fn classify_edge(&self, impulse: &Impulse, score: f32) -> CausalEdgeType {
409 if score >= self.config.correlation_threshold {
411 return CausalEdgeType::Correlates;
412 }
413
414 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 ImpulseType::EndOfUtterance
424 | ImpulseType::TurnClaim
425 | ImpulseType::TurnShift
426 | ImpulseType::Backchannel => CausalEdgeType::Follows,
427 ImpulseType::Custom(_) => CausalEdgeType::Follows,
428 }
429 }
430
431 fn budget_exceeded(&self, start: Instant) -> bool {
433 start.elapsed().as_micros() as u64 > self.config.tick_budget_us
434 }
435
436 pub fn total_ticks(&self) -> u64 {
440 self.total_ticks.load(Ordering::Relaxed)
441 }
442
443 pub fn total_nodes_added(&self) -> u64 {
445 self.total_nodes_added.load(Ordering::Relaxed)
446 }
447
448 pub fn total_edges_added(&self) -> u64 {
450 self.total_edges_added.load(Ordering::Relaxed)
451 }
452}
453
454fn 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
465fn 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
482fn 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#[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 type LoopParts = (
520 Arc<CausalGraph>,
521 Arc<dyn VectorBackend>,
522 Arc<ImpulseQueue>,
523 Arc<CrossRefStore>,
524 DemocritusLoop,
525 );
526
527 type CountedEntry = (u64, String, Vec<f32>, serde_json::Value);
529
530 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 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 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 #[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 #[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 assert_eq!(result.edges_added, 0);
709 assert_eq!(result.crossrefs_added, 1);
711 assert_eq!(cg.node_count(), 1);
713 assert_eq!(vector.len(), 1);
715 assert_eq!(crs.count(), 1);
717 assert_eq!(demo.total_nodes_added(), 1);
718 assert_eq!(demo.vector_backend().backend_name(), "hnsw");
720 }
721
722 #[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 #[tokio::test]
745 async fn tick_respects_budget() {
746 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 for i in 0..10 {
755 emit_test_impulse(&iq, ImpulseType::BeliefUpdate, i);
756 }
757
758 let result = demo.tick().await;
759
760 assert!(result.budget_exceeded);
762 assert_eq!(demo.total_ticks(), 1);
764 }
765
766 #[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 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 #[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 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 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 #[tokio::test]
819 async fn vector_returns_neighbors_on_second_tick() {
820 let (_cg, vector, iq, _crs, demo) = make_loop();
821
822 emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 100);
824 demo.tick().await;
825 assert_eq!(vector.len(), 1);
826
827 emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 200);
829 let result = demo.tick().await;
830
831 assert_eq!(result.searches_performed, 1);
832 assert_eq!(vector.len(), 2);
834 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 #[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 assert_eq!(
867 demo.classify_edge(&impulse_belief, 0.9),
868 CausalEdgeType::Correlates
869 );
870
871 assert_eq!(
873 demo.classify_edge(&impulse_belief, 0.3),
874 CausalEdgeType::Follows
875 );
876
877 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 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 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 #[tokio::test]
911 async fn commit_updates_tick_counter() {
912 let (_, _, _, _, demo) = make_loop();
913
914 demo.tick().await;
916 demo.tick().await;
917 demo.tick().await;
918
919 assert_eq!(demo.total_ticks(), 3);
920 }
921
922 #[tokio::test]
925 async fn embedding_error_falls_back_gracefully() {
926 use crate::embedding::EmbeddingError;
927
928 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 assert_eq!(result.impulses_sensed, 1);
969 assert_eq!(result.embeddings_produced, 1); assert_eq!(result.searches_performed, 0);
972 assert_eq!(cg.node_count(), 1);
974 assert_eq!(vector.len(), 0);
976 assert_eq!(result.crossrefs_added, 1);
978 }
979
980 #[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 for i in 0..5 {
992 emit_test_impulse(&iq, ImpulseType::BeliefUpdate, i);
993 }
994
995 let result = demo.tick().await;
996
997 assert_eq!(result.impulses_sensed, 2);
999 assert_eq!(result.embeddings_produced, 2);
1000 }
1001
1002 #[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 #[tokio::test]
1016 async fn budget_exhaustion_with_many_impulses() {
1017 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 assert_eq!(demo.total_ticks(), 1);
1034 assert!(result.impulses_sensed <= 50);
1036 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 let config = DemocritusConfig {
1045 tick_budget_us: 10_000_000, ..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 #[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 for i in 0..500 {
1067 emit_test_impulse(&iq, ImpulseType::BeliefUpdate, i);
1068 }
1069
1070 let r1 = demo.tick().await;
1072 assert_eq!(r1.impulses_sensed, 10);
1073
1074 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_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 #[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 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 assert_eq!(result.embeddings_produced, 5);
1149 assert_eq!(cg.node_count(), 5);
1151 assert_eq!(result.crossrefs_added, 5);
1153 assert_eq!(crs.count(), 5);
1154 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 assert_eq!(result.edges_added, 0);
1200 assert_eq!(demo.total_edges_added(), 0);
1201 }
1202
1203 #[tokio::test]
1206 async fn multiple_sequential_ticks_accumulate_state() {
1207 let (cg, vector, iq, crs, demo) = make_loop();
1208
1209 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 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 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 assert_eq!(demo.total_ticks(), 3);
1234 assert_eq!(demo.total_nodes_added(), 6);
1235 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, ..DemocritusConfig::default()
1244 };
1245 let (cg, vector, iq, _, demo) = make_loop_with_config(config);
1246
1247 emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 100);
1249 demo.tick().await;
1250 assert_eq!(vector.len(), 1);
1251
1252 emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 200);
1254 let r2 = demo.tick().await;
1255 assert_eq!(r2.searches_performed, 1);
1256 assert!(cg.node_count() >= 2);
1261 }
1262
1263 #[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 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 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 assert_eq!(demo.classify_edge(&impulse, 0.3), CausalEdgeType::Follows);
1308 assert_eq!(
1310 demo.classify_edge(&impulse, 0.9),
1311 CausalEdgeType::Correlates
1312 );
1313 }
1314
1315 fn emit_impulse_with_chain_seq(
1318 iq: &ImpulseQueue,
1319 impulse_type: ImpulseType,
1320 chain_seq: u64,
1321 conv_id: &str,
1322 ) -> u64 {
1323 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 #[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 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 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 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 #[tokio::test]
1430 async fn chain_seq_joinable_on_causal_edges() {
1431 let config = DemocritusConfig {
1432 correlation_threshold: 0.0, ..DemocritusConfig::default()
1434 };
1435 let (cg, vector, iq, _, demo) = make_loop_with_config(config);
1436
1437 emit_impulse_with_chain_seq(&iq, ImpulseType::BeliefUpdate, 100, "conv-edge");
1439 demo.tick().await;
1440 assert_eq!(vector.len(), 1);
1441
1442 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 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 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 #[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 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 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 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}