use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant;
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use crate::causal::{CausalEdgeType, CausalGraph};
use crate::crossref::{CrossRef, CrossRefStore, CrossRefType, StructureTag, UniversalNodeId};
use crate::embedding::{EmbeddingProvider};
use crate::hnsw_service::HnswService;
use crate::impulse::{Impulse, ImpulseQueue, ImpulseType};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DemocritusConfig {
pub max_impulses_per_tick: usize,
pub search_k: usize,
pub correlation_threshold: f32,
pub tick_budget_us: u64,
}
impl Default for DemocritusConfig {
fn default() -> Self {
Self {
max_impulses_per_tick: 64,
search_k: 5,
correlation_threshold: 0.7,
tick_budget_us: 15_000, }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DemocritusTickResult {
pub impulses_sensed: usize,
pub embeddings_produced: usize,
pub searches_performed: usize,
pub edges_added: usize,
pub crossrefs_added: usize,
pub budget_exceeded: bool,
pub duration_us: u64,
}
pub struct DemocritusLoop {
causal_graph: Arc<CausalGraph>,
hnsw: Arc<HnswService>,
impulse_queue: Arc<ImpulseQueue>,
crossref_store: Arc<CrossRefStore>,
embedding_provider: Arc<dyn EmbeddingProvider>,
config: DemocritusConfig,
total_ticks: AtomicU64,
total_nodes_added: AtomicU64,
total_edges_added: AtomicU64,
}
impl DemocritusLoop {
pub fn new(
causal_graph: Arc<CausalGraph>,
hnsw: Arc<HnswService>,
impulse_queue: Arc<ImpulseQueue>,
crossref_store: Arc<CrossRefStore>,
embedding_provider: Arc<dyn EmbeddingProvider>,
config: DemocritusConfig,
) -> Self {
Self {
causal_graph,
hnsw,
impulse_queue,
crossref_store,
embedding_provider,
config,
total_ticks: AtomicU64::new(0),
total_nodes_added: AtomicU64::new(0),
total_edges_added: AtomicU64::new(0),
}
}
pub async fn tick(&self) -> DemocritusTickResult {
let start = Instant::now();
let mut result = DemocritusTickResult {
impulses_sensed: 0,
embeddings_produced: 0,
searches_performed: 0,
edges_added: 0,
crossrefs_added: 0,
budget_exceeded: false,
duration_us: 0,
};
let impulses = self.sense();
result.impulses_sensed = impulses.len();
if impulses.is_empty() {
result.duration_us = start.elapsed().as_micros() as u64;
self.commit(&result);
return result;
}
let embedded = self.embed(&impulses).await;
result.embeddings_produced = embedded.len();
if self.budget_exceeded(start) {
result.budget_exceeded = true;
result.duration_us = start.elapsed().as_micros() as u64;
self.commit(&result);
return result;
}
let non_empty_queries: Vec<(usize, &[f32])> = embedded
.iter()
.enumerate()
.filter(|(_, emb)| !emb.is_empty())
.map(|(i, emb)| (i, emb.as_slice()))
.collect();
let query_slices: Vec<&[f32]> = non_empty_queries.iter().map(|(_, s)| *s).collect();
let batch_results = if !query_slices.is_empty() {
self.hnsw.search_batch(&query_slices, self.config.search_k)
} else {
Vec::new()
};
let mut search_results_by_index: Vec<Vec<(String, f32)>> =
vec![Vec::new(); embedded.len()];
for (batch_idx, &(orig_idx, _)) in non_empty_queries.iter().enumerate() {
search_results_by_index[orig_idx] = batch_results
.get(batch_idx)
.map(|results| results.iter().map(|r| (r.id.clone(), r.score)).collect())
.unwrap_or_default();
}
result.searches_performed = non_empty_queries.len();
let mut neighbors_per_event: Vec<(&Impulse, &Vec<f32>, Vec<(String, f32)>)> =
Vec::with_capacity(embedded.len());
for (i, (impulse, embedding)) in impulses.iter().zip(embedded.iter()).enumerate() {
if self.budget_exceeded(start) {
result.budget_exceeded = true;
break;
}
let neighbors = std::mem::take(&mut search_results_by_index[i]);
neighbors_per_event.push((impulse, embedding, neighbors));
}
for (impulse, embedding, neighbors) in &neighbors_per_event {
if self.budget_exceeded(start) {
result.budget_exceeded = true;
break;
}
let (edges, crossrefs) = self.update(impulse, embedding, neighbors);
result.edges_added += edges;
result.crossrefs_added += crossrefs;
}
result.duration_us = start.elapsed().as_micros() as u64;
self.commit(&result);
result
}
fn sense(&self) -> Vec<Impulse> {
let mut impulses = self.impulse_queue.drain_ready();
impulses.truncate(self.config.max_impulses_per_tick);
impulses
}
async fn embed(&self, impulses: &[Impulse]) -> Vec<Vec<f32>> {
let texts: Vec<String> = impulses
.iter()
.map(|imp| {
let type_str = imp.impulse_type.to_string();
let payload_str = imp.payload.to_string();
format!("{type_str}:{payload_str}")
})
.collect();
let text_refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
match self.embedding_provider.embed_batch(&text_refs).await {
Ok(vecs) => vecs,
Err(e) => {
warn!("DEMOCRITUS embed phase failed, falling back to empty vectors: {e}");
vec![Vec::new(); impulses.len()]
}
}
}
#[allow(dead_code)]
fn search(&self, embedding: &[f32]) -> Vec<(String, f32)> {
if embedding.is_empty() {
return Vec::new();
}
self.hnsw
.search(embedding, self.config.search_k)
.into_iter()
.map(|r| (r.id, r.score))
.collect()
}
fn update(
&self,
impulse: &Impulse,
embedding: &[f32],
neighbors: &[(String, f32)],
) -> (usize, usize) {
let mut edges_added = 0usize;
let mut crossrefs_added = 0usize;
let label = format!("impulse:{}:{}", impulse.impulse_type, impulse.id);
let node_id = self.causal_graph.add_node(
label.clone(),
impulse.payload.clone(),
);
self.total_nodes_added.fetch_add(1, Ordering::Relaxed);
if !embedding.is_empty() {
self.hnsw.insert(
node_id.to_string(),
embedding.to_vec(),
serde_json::json!({
"impulse_id": impulse.id,
"impulse_type": impulse.impulse_type.to_string(),
"hlc": impulse.hlc_timestamp,
}),
);
}
for (neighbor_id_str, score) in neighbors {
let edge_type = self.classify_edge(impulse, *score);
if let Ok(neighbor_node_id) = neighbor_id_str.parse::<u64>() {
let linked = self.causal_graph.link(
node_id,
neighbor_node_id,
edge_type,
*score,
impulse.hlc_timestamp,
0, );
if linked {
edges_added += 1;
self.total_edges_added.fetch_add(1, Ordering::Relaxed);
}
}
}
let source_tag = structure_tag_from_u8(impulse.source_structure);
let uni_id = UniversalNodeId::new(
&StructureTag::CausalGraph,
label.as_bytes(),
impulse.hlc_timestamp,
&impulse.source_node,
&[0u8; 32],
);
let source_uni_id = UniversalNodeId::from_bytes(impulse.source_node);
self.crossref_store.insert(CrossRef {
source: uni_id,
source_structure: StructureTag::CausalGraph,
target: source_uni_id,
target_structure: source_tag,
ref_type: CrossRefType::TriggeredBy,
created_at: impulse.hlc_timestamp,
chain_seq: 0,
});
crossrefs_added += 1;
(edges_added, crossrefs_added)
}
fn commit(&self, result: &DemocritusTickResult) {
self.total_ticks.fetch_add(1, Ordering::Relaxed);
debug!(
"DEMOCRITUS tick #{}: sensed={}, embedded={}, searched={}, edges={}, crossrefs={}, budget_exceeded={}, duration={}us",
self.total_ticks.load(Ordering::Relaxed),
result.impulses_sensed,
result.embeddings_produced,
result.searches_performed,
result.edges_added,
result.crossrefs_added,
result.budget_exceeded,
result.duration_us,
);
}
fn classify_edge(&self, impulse: &Impulse, score: f32) -> CausalEdgeType {
if score >= self.config.correlation_threshold {
return CausalEdgeType::Correlates;
}
match &impulse.impulse_type {
ImpulseType::BeliefUpdate | ImpulseType::NoveltyDetected => CausalEdgeType::Follows,
ImpulseType::EdgeConfirmed => CausalEdgeType::Causes,
ImpulseType::CoherenceAlert => CausalEdgeType::EvidenceFor,
ImpulseType::EmbeddingRefined => CausalEdgeType::Enables,
ImpulseType::Custom(_) => CausalEdgeType::Follows,
}
}
fn budget_exceeded(&self, start: Instant) -> bool {
start.elapsed().as_micros() as u64 > self.config.tick_budget_us
}
pub fn total_ticks(&self) -> u64 {
self.total_ticks.load(Ordering::Relaxed)
}
pub fn total_nodes_added(&self) -> u64 {
self.total_nodes_added.load(Ordering::Relaxed)
}
pub fn total_edges_added(&self) -> u64 {
self.total_edges_added.load(Ordering::Relaxed)
}
}
fn structure_tag_from_u8(tag: u8) -> StructureTag {
match tag {
0x01 => StructureTag::ExoChain,
0x02 => StructureTag::ResourceTree,
0x03 => StructureTag::CausalGraph,
0x04 => StructureTag::HnswIndex,
other => StructureTag::Custom(other),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::embedding::MockEmbeddingProvider;
use crate::hnsw_service::HnswServiceConfig;
fn make_loop() -> (
Arc<CausalGraph>,
Arc<HnswService>,
Arc<ImpulseQueue>,
Arc<CrossRefStore>,
DemocritusLoop,
) {
make_loop_with_config(DemocritusConfig::default())
}
fn make_loop_with_config(
config: DemocritusConfig,
) -> (
Arc<CausalGraph>,
Arc<HnswService>,
Arc<ImpulseQueue>,
Arc<CrossRefStore>,
DemocritusLoop,
) {
let cg = Arc::new(CausalGraph::new());
let hnsw = Arc::new(HnswService::new(HnswServiceConfig {
default_dimensions: 8,
..HnswServiceConfig::default()
}));
let iq = Arc::new(ImpulseQueue::new());
let crs = Arc::new(CrossRefStore::new());
let emb: Arc<dyn EmbeddingProvider> = Arc::new(MockEmbeddingProvider::new(8));
let democritus = DemocritusLoop::new(
Arc::clone(&cg),
Arc::clone(&hnsw),
Arc::clone(&iq),
Arc::clone(&crs),
emb,
config,
);
(cg, hnsw, iq, crs, democritus)
}
fn emit_test_impulse(iq: &ImpulseQueue, impulse_type: ImpulseType, ts: u64) -> u64 {
iq.emit(
StructureTag::CausalGraph.as_u8(),
[0u8; 32],
StructureTag::HnswIndex.as_u8(),
impulse_type,
serde_json::json!({"test": true}),
ts,
)
}
#[tokio::test]
async fn empty_queue_produces_no_work() {
let (_cg, _hnsw, _iq, _crs, demo) = make_loop();
let result = demo.tick().await;
assert_eq!(result.impulses_sensed, 0);
assert_eq!(result.embeddings_produced, 0);
assert_eq!(result.searches_performed, 0);
assert_eq!(result.edges_added, 0);
assert_eq!(result.crossrefs_added, 0);
assert!(!result.budget_exceeded);
assert_eq!(demo.total_ticks(), 1);
assert_eq!(demo.total_nodes_added(), 0);
}
#[tokio::test]
async fn single_impulse_full_pipeline() {
let (cg, hnsw, iq, crs, demo) = make_loop();
emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 100);
let result = demo.tick().await;
assert_eq!(result.impulses_sensed, 1);
assert_eq!(result.embeddings_produced, 1);
assert_eq!(result.searches_performed, 1);
assert_eq!(result.edges_added, 0);
assert_eq!(result.crossrefs_added, 1);
assert_eq!(cg.node_count(), 1);
assert_eq!(hnsw.len(), 1);
assert_eq!(crs.count(), 1);
assert_eq!(demo.total_nodes_added(), 1);
}
#[tokio::test]
async fn multiple_impulses_batch_processing() {
let (cg, _hnsw, iq, crs, demo) = make_loop();
emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 100);
emit_test_impulse(&iq, ImpulseType::CoherenceAlert, 200);
emit_test_impulse(&iq, ImpulseType::NoveltyDetected, 300);
let result = demo.tick().await;
assert_eq!(result.impulses_sensed, 3);
assert_eq!(result.embeddings_produced, 3);
assert_eq!(result.searches_performed, 3);
assert_eq!(result.crossrefs_added, 3);
assert_eq!(cg.node_count(), 3);
assert_eq!(crs.count(), 3);
}
#[tokio::test]
async fn tick_respects_budget() {
let config = DemocritusConfig {
tick_budget_us: 0,
..DemocritusConfig::default()
};
let (_cg, _hnsw, iq, _crs, demo) = make_loop_with_config(config);
for i in 0..10 {
emit_test_impulse(&iq, ImpulseType::BeliefUpdate, i);
}
let result = demo.tick().await;
assert!(result.budget_exceeded);
assert_eq!(demo.total_ticks(), 1);
}
#[tokio::test]
async fn crossref_links_node_to_source() {
let (_cg, _hnsw, iq, crs, demo) = make_loop();
let source_node = [42u8; 32];
iq.emit(
StructureTag::ExoChain.as_u8(),
source_node,
StructureTag::HnswIndex.as_u8(),
ImpulseType::EdgeConfirmed,
serde_json::json!({"chain": "test"}),
500,
);
demo.tick().await;
let target_uni = UniversalNodeId::from_bytes(source_node);
let refs = crs.get_reverse(&target_uni);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].target_structure, StructureTag::ExoChain);
assert_eq!(refs[0].ref_type, CrossRefType::TriggeredBy);
}
#[tokio::test]
async fn tick_statistics_increment() {
let (_cg, _hnsw, iq, _crs, demo) = make_loop();
assert_eq!(demo.total_ticks(), 0);
assert_eq!(demo.total_nodes_added(), 0);
assert_eq!(demo.total_edges_added(), 0);
emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 10);
demo.tick().await;
assert_eq!(demo.total_ticks(), 1);
assert_eq!(demo.total_nodes_added(), 1);
emit_test_impulse(&iq, ImpulseType::CoherenceAlert, 20);
emit_test_impulse(&iq, ImpulseType::NoveltyDetected, 30);
demo.tick().await;
assert_eq!(demo.total_ticks(), 2);
assert_eq!(demo.total_nodes_added(), 3);
}
#[tokio::test]
async fn hnsw_returns_neighbors_on_second_tick() {
let (_cg, hnsw, iq, _crs, demo) = make_loop();
emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 100);
demo.tick().await;
assert_eq!(hnsw.len(), 1);
emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 200);
let result = demo.tick().await;
assert_eq!(result.searches_performed, 1);
assert_eq!(hnsw.search_count(), 2);
}
#[tokio::test]
async fn edge_type_classification() {
let (_, _, _, _, demo) = make_loop();
let impulse_belief = Impulse {
id: 1,
source_structure: 0,
source_node: [0u8; 32],
target_structure: 2,
impulse_type: ImpulseType::BeliefUpdate,
payload: serde_json::json!({}),
hlc_timestamp: 0,
acknowledged: std::sync::atomic::AtomicBool::new(false),
};
assert_eq!(
demo.classify_edge(&impulse_belief, 0.9),
CausalEdgeType::Correlates
);
assert_eq!(
demo.classify_edge(&impulse_belief, 0.3),
CausalEdgeType::Follows
);
let impulse_confirmed = Impulse {
impulse_type: ImpulseType::EdgeConfirmed,
..impulse_belief.clone()
};
assert_eq!(
demo.classify_edge(&impulse_confirmed, 0.3),
CausalEdgeType::Causes
);
let impulse_coherence = Impulse {
impulse_type: ImpulseType::CoherenceAlert,
..impulse_belief.clone()
};
assert_eq!(
demo.classify_edge(&impulse_coherence, 0.3),
CausalEdgeType::EvidenceFor
);
let impulse_refined = Impulse {
impulse_type: ImpulseType::EmbeddingRefined,
..impulse_belief.clone()
};
assert_eq!(
demo.classify_edge(&impulse_refined, 0.3),
CausalEdgeType::Enables
);
}
#[tokio::test]
async fn commit_updates_tick_counter() {
let (_, _, _, _, demo) = make_loop();
demo.tick().await;
demo.tick().await;
demo.tick().await;
assert_eq!(demo.total_ticks(), 3);
}
#[tokio::test]
async fn embedding_error_falls_back_gracefully() {
use crate::embedding::EmbeddingError;
struct FailingProvider;
#[async_trait::async_trait]
impl EmbeddingProvider for FailingProvider {
async fn embed(&self, _text: &str) -> Result<Vec<f32>, EmbeddingError> {
Err(EmbeddingError::BackendError("test failure".into()))
}
async fn embed_batch(
&self,
_texts: &[&str],
) -> Result<Vec<Vec<f32>>, EmbeddingError> {
Err(EmbeddingError::BackendError("test failure".into()))
}
fn dimensions(&self) -> usize {
8
}
fn model_name(&self) -> &str {
"failing-test"
}
}
let cg = Arc::new(CausalGraph::new());
let hnsw = Arc::new(HnswService::new(HnswServiceConfig::default()));
let iq = Arc::new(ImpulseQueue::new());
let crs = Arc::new(CrossRefStore::new());
let emb: Arc<dyn EmbeddingProvider> = Arc::new(FailingProvider);
let demo = DemocritusLoop::new(
Arc::clone(&cg),
Arc::clone(&hnsw),
Arc::clone(&iq),
Arc::clone(&crs),
emb,
DemocritusConfig::default(),
);
emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 100);
let result = demo.tick().await;
assert_eq!(result.impulses_sensed, 1);
assert_eq!(result.embeddings_produced, 1); assert_eq!(result.searches_performed, 0);
assert_eq!(cg.node_count(), 1);
assert_eq!(hnsw.len(), 0);
assert_eq!(result.crossrefs_added, 1);
}
#[tokio::test]
async fn max_impulses_per_tick_truncation() {
let config = DemocritusConfig {
max_impulses_per_tick: 2,
..DemocritusConfig::default()
};
let (_cg, _hnsw, iq, _crs, demo) = make_loop_with_config(config);
for i in 0..5 {
emit_test_impulse(&iq, ImpulseType::BeliefUpdate, i);
}
let result = demo.tick().await;
assert_eq!(result.impulses_sensed, 2);
assert_eq!(result.embeddings_produced, 2);
}
#[test]
fn structure_tag_roundtrip() {
assert_eq!(structure_tag_from_u8(0x01), StructureTag::ExoChain);
assert_eq!(structure_tag_from_u8(0x02), StructureTag::ResourceTree);
assert_eq!(structure_tag_from_u8(0x03), StructureTag::CausalGraph);
assert_eq!(structure_tag_from_u8(0x04), StructureTag::HnswIndex);
assert_eq!(structure_tag_from_u8(0xFF), StructureTag::Custom(0xFF));
}
#[tokio::test]
async fn budget_exhaustion_with_many_impulses() {
let config = DemocritusConfig {
tick_budget_us: 0,
max_impulses_per_tick: 100,
..DemocritusConfig::default()
};
let (cg, _hnsw, iq, _crs, demo) = make_loop_with_config(config);
for i in 0..50 {
emit_test_impulse(&iq, ImpulseType::BeliefUpdate, i);
}
let result = demo.tick().await;
assert!(result.budget_exceeded);
assert_eq!(demo.total_ticks(), 1);
assert!(result.impulses_sensed <= 50);
assert!(cg.node_count() <= result.impulses_sensed as u64);
}
#[tokio::test]
async fn budget_exceeded_flag_only_set_when_needed() {
let config = DemocritusConfig {
tick_budget_us: 10_000_000, ..DemocritusConfig::default()
};
let (_, _, iq, _, demo) = make_loop_with_config(config);
emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 1);
let result = demo.tick().await;
assert!(!result.budget_exceeded);
}
#[tokio::test]
async fn impulse_queue_large_burst() {
let config = DemocritusConfig {
max_impulses_per_tick: 10,
..DemocritusConfig::default()
};
let (_, _, iq, _, demo) = make_loop_with_config(config);
for i in 0..500 {
emit_test_impulse(&iq, ImpulseType::BeliefUpdate, i);
}
let r1 = demo.tick().await;
assert_eq!(r1.impulses_sensed, 10);
let r2 = demo.tick().await;
assert_eq!(r2.impulses_sensed, 0);
}
#[tokio::test]
async fn impulse_queue_interleaved_emit_and_tick() {
let (cg, _, iq, _, demo) = make_loop();
emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 10);
let r1 = demo.tick().await;
assert_eq!(r1.impulses_sensed, 1);
assert_eq!(cg.node_count(), 1);
emit_test_impulse(&iq, ImpulseType::CoherenceAlert, 20);
emit_test_impulse(&iq, ImpulseType::NoveltyDetected, 30);
let r2 = demo.tick().await;
assert_eq!(r2.impulses_sensed, 2);
assert_eq!(cg.node_count(), 3);
assert_eq!(demo.total_ticks(), 2);
assert_eq!(demo.total_nodes_added(), 3);
}
#[tokio::test]
async fn embed_failure_still_creates_crossrefs() {
use crate::embedding::EmbeddingError;
struct FailingProvider;
#[async_trait::async_trait]
impl EmbeddingProvider for FailingProvider {
async fn embed(&self, _text: &str) -> Result<Vec<f32>, EmbeddingError> {
Err(EmbeddingError::BackendError("test failure".into()))
}
async fn embed_batch(
&self,
_texts: &[&str],
) -> Result<Vec<Vec<f32>>, EmbeddingError> {
Err(EmbeddingError::BackendError("test failure".into()))
}
fn dimensions(&self) -> usize {
8
}
fn model_name(&self) -> &str {
"failing-test"
}
}
let cg = Arc::new(CausalGraph::new());
let hnsw = Arc::new(HnswService::new(HnswServiceConfig::default()));
let iq = Arc::new(ImpulseQueue::new());
let crs = Arc::new(CrossRefStore::new());
let emb: Arc<dyn EmbeddingProvider> = Arc::new(FailingProvider);
let demo = DemocritusLoop::new(
Arc::clone(&cg),
Arc::clone(&hnsw),
Arc::clone(&iq),
Arc::clone(&crs),
emb,
DemocritusConfig::default(),
);
for i in 0..5 {
emit_test_impulse(&iq, ImpulseType::BeliefUpdate, i * 100);
}
let result = demo.tick().await;
assert_eq!(result.impulses_sensed, 5);
assert_eq!(result.embeddings_produced, 5);
assert_eq!(cg.node_count(), 5);
assert_eq!(result.crossrefs_added, 5);
assert_eq!(crs.count(), 5);
assert_eq!(hnsw.len(), 0);
}
#[tokio::test]
async fn embed_failure_no_edges_added() {
use crate::embedding::EmbeddingError;
struct FailingProvider;
#[async_trait::async_trait]
impl EmbeddingProvider for FailingProvider {
async fn embed(&self, _text: &str) -> Result<Vec<f32>, EmbeddingError> {
Err(EmbeddingError::BackendError("fail".into()))
}
async fn embed_batch(
&self,
_texts: &[&str],
) -> Result<Vec<Vec<f32>>, EmbeddingError> {
Err(EmbeddingError::BackendError("fail".into()))
}
fn dimensions(&self) -> usize {
8
}
fn model_name(&self) -> &str {
"fail"
}
}
let cg = Arc::new(CausalGraph::new());
let hnsw = Arc::new(HnswService::new(HnswServiceConfig::default()));
let iq = Arc::new(ImpulseQueue::new());
let crs = Arc::new(CrossRefStore::new());
let emb: Arc<dyn EmbeddingProvider> = Arc::new(FailingProvider);
let demo = DemocritusLoop::new(
Arc::clone(&cg),
Arc::clone(&hnsw),
Arc::clone(&iq),
Arc::clone(&crs),
emb,
DemocritusConfig::default(),
);
emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 100);
let result = demo.tick().await;
assert_eq!(result.edges_added, 0);
assert_eq!(demo.total_edges_added(), 0);
}
#[tokio::test]
async fn multiple_sequential_ticks_accumulate_state() {
let (cg, hnsw, iq, crs, demo) = make_loop();
emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 100);
let r1 = demo.tick().await;
assert_eq!(r1.impulses_sensed, 1);
let nodes_after_1 = cg.node_count();
let hnsw_after_1 = hnsw.len();
emit_test_impulse(&iq, ImpulseType::CoherenceAlert, 200);
emit_test_impulse(&iq, ImpulseType::NoveltyDetected, 300);
let r2 = demo.tick().await;
assert_eq!(r2.impulses_sensed, 2);
assert_eq!(cg.node_count(), nodes_after_1 + 2);
assert_eq!(hnsw.len(), hnsw_after_1 + 2);
emit_test_impulse(&iq, ImpulseType::EdgeConfirmed, 400);
emit_test_impulse(&iq, ImpulseType::EmbeddingRefined, 500);
emit_test_impulse(&iq, ImpulseType::Custom(42), 600);
let r3 = demo.tick().await;
assert_eq!(r3.impulses_sensed, 3);
assert_eq!(cg.node_count(), nodes_after_1 + 5);
assert_eq!(demo.total_ticks(), 3);
assert_eq!(demo.total_nodes_added(), 6);
assert_eq!(crs.count(), 6);
}
#[tokio::test]
async fn sequential_ticks_can_find_prior_neighbors() {
let config = DemocritusConfig {
correlation_threshold: 0.0, ..DemocritusConfig::default()
};
let (cg, hnsw, iq, _, demo) = make_loop_with_config(config);
emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 100);
demo.tick().await;
assert_eq!(hnsw.len(), 1);
emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 200);
let r2 = demo.tick().await;
assert_eq!(r2.searches_performed, 1);
assert!(cg.node_count() >= 2);
}
#[tokio::test]
async fn zero_max_impulses_per_tick() {
let config = DemocritusConfig {
max_impulses_per_tick: 0,
..DemocritusConfig::default()
};
let (_, _, iq, _, demo) = make_loop_with_config(config);
emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 100);
let result = demo.tick().await;
assert_eq!(result.impulses_sensed, 0);
assert_eq!(result.embeddings_produced, 0);
}
#[tokio::test]
async fn tick_result_duration_is_positive() {
let (_, _, iq, _, demo) = make_loop();
emit_test_impulse(&iq, ImpulseType::BeliefUpdate, 100);
let result = demo.tick().await;
assert!(result.duration_us < 10_000_000, "tick should complete within 10s");
}
#[test]
fn classify_edge_custom_impulse_type() {
let (_, _, _, _, demo) = make_loop();
let impulse = Impulse {
id: 1,
source_structure: 0,
source_node: [0u8; 32],
target_structure: 2,
impulse_type: ImpulseType::Custom(99),
payload: serde_json::json!({}),
hlc_timestamp: 0,
acknowledged: std::sync::atomic::AtomicBool::new(false),
};
assert_eq!(
demo.classify_edge(&impulse, 0.3),
CausalEdgeType::Follows
);
assert_eq!(
demo.classify_edge(&impulse, 0.9),
CausalEdgeType::Correlates
);
}
}