1#![allow(clippy::needless_range_loop)]
13
14use dashmap::DashMap;
15use serde::{Deserialize, Serialize};
16use std::collections::{HashMap, VecDeque};
17use std::fmt;
18use std::sync::Mutex;
19use std::sync::atomic::{AtomicU64, Ordering};
20
21#[cfg(feature = "exochain")]
22use std::sync::Arc;
23
24#[derive(Debug, Default)]
35struct ComponentIndex {
36 parent: HashMap<NodeId, NodeId>,
37 rank: HashMap<NodeId, u8>,
38 count: u64,
40}
41
42impl ComponentIndex {
43 fn new() -> Self {
44 Self::default()
45 }
46
47 fn clear(&mut self) {
48 self.parent.clear();
49 self.rank.clear();
50 self.count = 0;
51 }
52
53 fn add(&mut self, id: NodeId) {
55 if self.parent.contains_key(&id) {
56 return;
57 }
58 self.parent.insert(id, id);
59 self.rank.insert(id, 0);
60 self.count += 1;
61 }
62
63 fn find(&mut self, id: NodeId) -> Option<NodeId> {
64 if !self.parent.contains_key(&id) {
65 return None;
66 }
67 let mut root = id;
69 while self.parent[&root] != root {
70 root = self.parent[&root];
71 }
72 let mut cur = id;
73 while cur != root {
74 let p = self.parent[&cur];
75 self.parent.insert(cur, root);
76 cur = p;
77 }
78 Some(root)
79 }
80
81 fn union(&mut self, a: NodeId, b: NodeId) -> bool {
84 let Some(ra) = self.find(a) else {
85 return false;
86 };
87 let Some(rb) = self.find(b) else {
88 return false;
89 };
90 if ra == rb {
91 return false;
92 }
93 let rank_a = self.rank[&ra];
94 let rank_b = self.rank[&rb];
95 if rank_a < rank_b {
96 self.parent.insert(ra, rb);
97 } else if rank_a > rank_b {
98 self.parent.insert(rb, ra);
99 } else {
100 self.parent.insert(rb, ra);
101 self.rank.insert(ra, rank_a + 1);
102 }
103 self.count = self.count.saturating_sub(1);
104 true
105 }
106}
107
108pub type NodeId = u64;
113
114#[non_exhaustive]
120#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
121pub enum CausalEdgeType {
122 Causes,
124 Inhibits,
126 Correlates,
129 Enables,
131 Follows,
133 Contradicts,
135 TriggeredBy,
137 EvidenceFor,
139}
140
141impl fmt::Display for CausalEdgeType {
142 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143 match self {
144 Self::Causes => write!(f, "Causes"),
145 Self::Inhibits => write!(f, "Inhibits"),
146 Self::Correlates => write!(f, "Correlates"),
147 Self::Enables => write!(f, "Enables"),
148 Self::Follows => write!(f, "Follows"),
149 Self::Contradicts => write!(f, "Contradicts"),
150 Self::TriggeredBy => write!(f, "TriggeredBy"),
151 Self::EvidenceFor => write!(f, "EvidenceFor"),
152 }
153 }
154}
155
156#[derive(Serialize, Deserialize, Debug, Clone)]
162pub struct CausalEdge {
163 pub source: NodeId,
165 pub target: NodeId,
167 pub edge_type: CausalEdgeType,
169 pub weight: f32,
171 pub timestamp: u64,
173 pub chain_seq: u64,
175 pub source_universal_id: [u8; 32],
177 pub target_universal_id: [u8; 32],
179}
180
181#[derive(Serialize, Deserialize, Debug, Clone)]
187pub struct CausalNode {
188 pub id: NodeId,
190 pub label: String,
192 pub created_at: u64,
194 pub metadata: serde_json::Value,
196}
197
198pub struct CausalGraph {
209 nodes: DashMap<NodeId, CausalNode>,
210 forward_edges: DashMap<NodeId, Vec<CausalEdge>>,
211 reverse_edges: DashMap<NodeId, Vec<CausalEdge>>,
212 next_node_id: AtomicU64,
213 node_count: AtomicU64,
214 edge_count: AtomicU64,
215 components: Mutex<ComponentIndex>,
220 #[cfg(feature = "exochain")]
222 chain_manager: Option<Arc<crate::chain::ChainManager>>,
223 #[cfg(feature = "exochain")]
225 governance_engine: Option<Arc<crate::governance::GovernanceEngine>>,
226}
227
228impl CausalGraph {
229 pub fn new() -> Self {
231 Self {
232 nodes: DashMap::new(),
233 forward_edges: DashMap::new(),
234 reverse_edges: DashMap::new(),
235 next_node_id: AtomicU64::new(1),
236 node_count: AtomicU64::new(0),
237 edge_count: AtomicU64::new(0),
238 components: Mutex::new(ComponentIndex::new()),
239 #[cfg(feature = "exochain")]
240 chain_manager: None,
241 #[cfg(feature = "exochain")]
242 governance_engine: None,
243 }
244 }
245
246 #[cfg(feature = "exochain")]
248 pub fn set_chain_manager(&mut self, cm: Arc<crate::chain::ChainManager>) {
249 self.chain_manager = Some(cm);
250 }
251
252 #[cfg(feature = "exochain")]
254 pub fn set_governance_engine(&mut self, ge: Arc<crate::governance::GovernanceEngine>) {
255 self.governance_engine = Some(ge);
256 }
257
258 pub fn add_node(&self, label: String, metadata: serde_json::Value) -> NodeId {
260 let id = self.next_node_id.fetch_add(1, Ordering::SeqCst);
261 let node = CausalNode {
262 id,
263 label: label.clone(),
264 created_at: 0, metadata,
266 };
267 self.nodes.insert(id, node);
268 self.forward_edges.insert(id, Vec::new());
269 self.reverse_edges.insert(id, Vec::new());
270 self.node_count.fetch_add(1, Ordering::SeqCst);
271 if let Ok(mut idx) = self.components.lock() {
272 idx.add(id);
273 }
274
275 #[cfg(feature = "exochain")]
276 if let Some(ref cm) = self.chain_manager {
277 cm.append(
278 "causal",
279 crate::chain::EVENT_KIND_CAUSAL_NODE_ADD,
280 Some(serde_json::json!({
281 "node_id": id,
282 "label": label,
283 })),
284 );
285 }
286
287 id
288 }
289
290 pub fn get_node(&self, id: NodeId) -> Option<CausalNode> {
292 self.nodes.get(&id).map(|r| r.value().clone())
293 }
294
295 pub fn set_node_state(&self, id: NodeId, state: &str) -> bool {
310 match self.nodes.get_mut(&id) {
311 Some(mut node) => {
312 let previous = node
313 .metadata
314 .get("state")
315 .and_then(|v| v.as_str())
316 .map(|s| s.to_string());
317 let changed = previous.as_deref() != Some(state);
318 if let Some(obj) = node.metadata.as_object_mut() {
319 obj.insert("state".to_string(), serde_json::Value::from(state));
320 } else {
321 node.metadata = serde_json::json!({ "state": state });
322 }
323 drop(node);
326
327 #[cfg(feature = "exochain")]
328 if changed && let Some(ref cm) = self.chain_manager {
329 cm.append(
330 "causal",
331 crate::chain::EVENT_KIND_CAUSAL_NODE_STATE,
332 Some(serde_json::json!({
333 "node_id": id,
334 "from": previous,
335 "to": state,
336 })),
337 );
338 }
339
340 let _ = previous; let _ = changed;
342 true
343 }
344 None => false,
345 }
346 }
347
348 const PROTECTED_METADATA_KEYS: &'static [&'static str] =
357 &["state", "uid", "chain_seq", "text", "conv_id"];
358
359 pub fn merge_node_metadata(
381 &self,
382 id: NodeId,
383 patch: &serde_json::Map<String, serde_json::Value>,
384 ) -> bool {
385 match self.nodes.get_mut(&id) {
386 Some(mut node) => {
387 if !node.metadata.is_object() {
390 node.metadata = serde_json::Value::Object(serde_json::Map::new());
391 }
392 let obj = node
393 .metadata
394 .as_object_mut()
395 .expect("metadata was just ensured to be an object");
396 for (key, value) in patch {
397 if Self::PROTECTED_METADATA_KEYS.contains(&key.as_str()) {
398 tracing::warn!(
399 node_id = id,
400 key = key.as_str(),
401 "merge_node_metadata ignored a patch on a protected key"
402 );
403 continue;
404 }
405 obj.insert(key.clone(), value.clone());
406 }
407 true
408 }
409 None => false,
410 }
411 }
412
413 pub fn remove_node(&self, id: NodeId) -> Option<CausalNode> {
417 let (_, node) = self.nodes.remove(&id)?;
418
419 if let Some((_, fwd)) = self.forward_edges.remove(&id) {
421 let removed = fwd.len() as u64;
422 for edge in &fwd {
423 if let Some(mut rev) = self.reverse_edges.get_mut(&edge.target) {
424 rev.retain(|e| e.source != id);
425 }
426 }
427 self.edge_count.fetch_sub(removed, Ordering::SeqCst);
428 }
429
430 if let Some((_, rev)) = self.reverse_edges.remove(&id) {
432 let removed = rev.len() as u64;
433 for edge in &rev {
434 if let Some(mut fwd) = self.forward_edges.get_mut(&edge.source) {
435 fwd.retain(|e| e.target != id);
436 }
437 }
438 self.edge_count.fetch_sub(removed, Ordering::SeqCst);
439 }
440
441 self.node_count.fetch_sub(1, Ordering::SeqCst);
442 self.rebuild_component_index();
444
445 #[cfg(feature = "exochain")]
446 if let Some(ref cm) = self.chain_manager {
447 cm.append(
448 "causal",
449 crate::chain::EVENT_KIND_CAUSAL_NODE_REMOVE,
450 Some(serde_json::json!({
451 "node_id": id,
452 "label": node.label,
453 })),
454 );
455 }
456
457 Some(node)
458 }
459
460 pub fn link(
464 &self,
465 source: NodeId,
466 target: NodeId,
467 edge_type: CausalEdgeType,
468 weight: f32,
469 timestamp: u64,
470 chain_seq: u64,
471 ) -> bool {
472 if !self.nodes.contains_key(&source) || !self.nodes.contains_key(&target) {
473 return false;
474 }
475
476 let edge = CausalEdge {
477 source,
478 target,
479 edge_type,
480 weight,
481 timestamp,
482 chain_seq,
483 source_universal_id: [0u8; 32],
484 target_universal_id: [0u8; 32],
485 };
486
487 if let Some(mut fwd) = self.forward_edges.get_mut(&source) {
488 fwd.push(edge.clone());
489 }
490 if let Some(mut rev) = self.reverse_edges.get_mut(&target) {
491 rev.push(edge.clone());
492 }
493
494 self.edge_count.fetch_add(1, Ordering::SeqCst);
495 if let Ok(mut idx) = self.components.lock() {
497 let _ = idx.union(source, target);
498 }
499
500 #[cfg(feature = "exochain")]
501 if let Some(ref cm) = self.chain_manager {
502 cm.append(
503 "causal",
504 crate::chain::EVENT_KIND_CAUSAL_EDGE_ADD,
505 Some(serde_json::json!({
506 "source": source,
507 "target": target,
508 "edge_type": edge.edge_type.to_string(),
509 "weight": edge.weight,
510 })),
511 );
512 }
513
514 true
515 }
516
517 pub fn unlink(&self, source: NodeId, target: NodeId) -> usize {
521 let mut count = 0usize;
522
523 if let Some(mut fwd) = self.forward_edges.get_mut(&source) {
524 let before = fwd.len();
525 fwd.retain(|e| e.target != target);
526 count = before - fwd.len();
527 }
528
529 if let Some(mut rev) = self.reverse_edges.get_mut(&target) {
530 rev.retain(|e| e.source != source);
531 }
532
533 self.edge_count.fetch_sub(count as u64, Ordering::SeqCst);
534 if count > 0 {
535 self.rebuild_component_index();
537 }
538
539 #[cfg(feature = "exochain")]
540 if let Some(ref cm) = self.chain_manager {
541 cm.append(
542 "causal",
543 crate::chain::EVENT_KIND_CAUSAL_EDGE_REMOVE,
544 Some(serde_json::json!({
545 "source": source,
546 "target": target,
547 "removed_count": count,
548 })),
549 );
550 }
551
552 count
553 }
554
555 pub fn component_count_fast(&self) -> u64 {
561 self.components
562 .lock()
563 .map(|idx| idx.count)
564 .unwrap_or_else(|_| self.connected_components().len() as u64)
565 }
566
567 fn rebuild_component_index(&self) {
569 let Ok(mut idx) = self.components.lock() else {
570 return;
571 };
572 idx.clear();
573 let ids = self.node_ids();
574 for &id in &ids {
575 idx.add(id);
576 }
577 for &id in &ids {
578 for edge in self.get_forward_edges(id) {
581 let _ = idx.union(edge.source, edge.target);
582 }
583 }
584 }
585
586 pub fn get_forward_edges(&self, id: NodeId) -> Vec<CausalEdge> {
588 self.forward_edges
589 .get(&id)
590 .map(|r| r.value().clone())
591 .unwrap_or_default()
592 }
593
594 pub fn get_reverse_edges(&self, id: NodeId) -> Vec<CausalEdge> {
596 self.reverse_edges
597 .get(&id)
598 .map(|r| r.value().clone())
599 .unwrap_or_default()
600 }
601
602 pub fn get_edges_by_type(&self, id: NodeId, edge_type: &CausalEdgeType) -> Vec<CausalEdge> {
604 self.forward_edges
605 .get(&id)
606 .map(|r| {
607 r.value()
608 .iter()
609 .filter(|e| &e.edge_type == edge_type)
610 .cloned()
611 .collect()
612 })
613 .unwrap_or_default()
614 }
615
616 pub fn node_count(&self) -> u64 {
618 self.node_count.load(Ordering::SeqCst)
619 }
620
621 pub fn edge_count(&self) -> u64 {
623 self.edge_count.load(Ordering::SeqCst)
624 }
625
626 pub fn clear(&self) -> Result<(), crate::error::KernelError> {
632 #[cfg(feature = "exochain")]
634 if let Some(ref ge) = self.governance_engine {
635 let request = crate::governance::GovernanceRequest::new("causal", "causal.clear")
636 .with_effect(crate::governance::EffectVector {
637 risk: 0.8,
638 ..Default::default()
639 });
640 let result = ge.evaluate(&request);
641 if matches!(
642 result.decision,
643 crate::governance::GovernanceDecision::Deny(_)
644 | crate::governance::GovernanceDecision::EscalateToHuman(_)
645 ) {
646 return Err(crate::error::KernelError::GovernanceDenied(format!(
647 "causal.clear denied: {}",
648 result.decision
649 )));
650 }
651 }
652
653 let prev_nodes = self.node_count.load(Ordering::SeqCst);
654 let prev_edges = self.edge_count.load(Ordering::SeqCst);
655
656 self.nodes.clear();
657 self.forward_edges.clear();
658 self.reverse_edges.clear();
659 self.node_count.store(0, Ordering::SeqCst);
660 self.edge_count.store(0, Ordering::SeqCst);
661 if let Ok(mut idx) = self.components.lock() {
662 idx.clear();
663 }
664 #[cfg(feature = "exochain")]
668 if let Some(ref cm) = self.chain_manager {
669 cm.append(
670 "causal",
671 crate::chain::EVENT_KIND_CAUSAL_CLEAR,
672 Some(serde_json::json!({
673 "node_count": prev_nodes,
674 "edge_count": prev_edges,
675 })),
676 );
677 }
678
679 Ok(())
680 }
681
682 pub fn traverse_forward(&self, start: NodeId, depth: usize) -> Vec<NodeId> {
686 self.bfs(start, depth, true)
687 }
688
689 pub fn traverse_reverse(&self, start: NodeId, depth: usize) -> Vec<NodeId> {
694 self.bfs(start, depth, false)
695 }
696
697 pub fn find_path(&self, from: NodeId, to: NodeId, max_depth: usize) -> Option<Vec<NodeId>> {
703 if from == to {
704 return Some(vec![from]);
705 }
706 if !self.nodes.contains_key(&from) || !self.nodes.contains_key(&to) {
707 return None;
708 }
709
710 let mut visited: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
712 let mut parent: std::collections::HashMap<NodeId, NodeId> =
713 std::collections::HashMap::new();
714 let mut queue: VecDeque<(NodeId, usize)> = VecDeque::new();
715
716 visited.insert(from);
717 queue.push_back((from, 0));
718
719 while let Some((current, d)) = queue.pop_front() {
720 if d >= max_depth {
721 continue;
722 }
723 let edges = self.get_forward_edges(current);
724 for edge in edges {
725 if visited.contains(&edge.target) {
726 continue;
727 }
728 visited.insert(edge.target);
729 parent.insert(edge.target, current);
730
731 if edge.target == to {
732 let mut path = Vec::new();
734 let mut cur = to;
735 while cur != from {
736 path.push(cur);
737 cur = parent[&cur];
738 }
739 path.push(from);
740 path.reverse();
741 return Some(path);
742 }
743
744 queue.push_back((edge.target, d + 1));
745 }
746 }
747
748 None
749 }
750
751 pub fn trace_causal_chain(
760 &self,
761 from: NodeId,
762 to: NodeId,
763 max_depth: usize,
764 ) -> Option<CausalChain> {
765 if from == to {
766 return Some(CausalChain {
767 path: Vec::new(),
768 explanation: self
769 .get_node(from)
770 .map(|n| n.label.clone())
771 .unwrap_or_else(|| format!("node:{from}")),
772 total_weight: 0.0,
773 });
774 }
775 if !self.nodes.contains_key(&from) || !self.nodes.contains_key(&to) {
776 return None;
777 }
778
779 let mut visited: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
781 let mut parent: std::collections::HashMap<NodeId, (NodeId, CausalEdgeType, f32)> =
783 std::collections::HashMap::new();
784 let mut queue: VecDeque<(NodeId, usize)> = VecDeque::new();
785
786 visited.insert(from);
787 queue.push_back((from, 0));
788
789 while let Some((current, d)) = queue.pop_front() {
790 if d >= max_depth {
791 continue;
792 }
793 let edges = self.get_forward_edges(current);
794 for edge in edges {
795 if visited.contains(&edge.target) {
796 continue;
797 }
798 visited.insert(edge.target);
799 parent.insert(edge.target, (current, edge.edge_type.clone(), edge.weight));
800
801 if edge.target == to {
802 let mut steps: Vec<(NodeId, CausalEdgeType, NodeId)> = Vec::new();
804 let mut total_weight: f32 = 0.0;
805 let mut cur = to;
806 while cur != from {
807 let (prev, etype, w) = parent[&cur].clone();
808 steps.push((prev, etype, cur));
809 total_weight += w;
810 cur = prev;
811 }
812 steps.reverse();
813
814 let label_for = |id: NodeId| -> String {
816 self.get_node(id)
817 .map(|n| n.label.clone())
818 .unwrap_or_else(|| format!("node:{id}"))
819 };
820
821 let mut explanation = String::new();
822 for (i, (src, etype, tgt)) in steps.iter().enumerate() {
823 if i == 0 {
824 explanation.push_str(&label_for(*src));
825 }
826 explanation.push_str(&format!(" --{}--> {}", etype, label_for(*tgt)));
827 }
828
829 return Some(CausalChain {
830 path: steps,
831 explanation,
832 total_weight,
833 });
834 }
835
836 queue.push_back((edge.target, d + 1));
837 }
838 }
839
840 None
841 }
842
843 fn bfs(&self, start: NodeId, depth: usize, forward: bool) -> Vec<NodeId> {
846 let mut visited: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
847 let mut result = Vec::new();
848 let mut queue: VecDeque<(NodeId, usize)> = VecDeque::new();
849
850 visited.insert(start);
851 queue.push_back((start, 0));
852
853 while let Some((current, d)) = queue.pop_front() {
854 if d >= depth {
855 continue;
856 }
857 let edges = if forward {
858 self.get_forward_edges(current)
859 } else {
860 self.get_reverse_edges(current)
861 };
862 for edge in edges {
863 let neighbor = if forward { edge.target } else { edge.source };
864 if visited.contains(&neighbor) {
865 continue;
866 }
867 visited.insert(neighbor);
868 result.push(neighbor);
869 queue.push_back((neighbor, d + 1));
870 }
871 }
872
873 result
874 }
875
876 pub fn node_ids(&self) -> Vec<NodeId> {
878 self.nodes.iter().map(|r| *r.key()).collect()
879 }
880
881 pub fn nodes_for_conv(&self, conv_id: &str) -> Vec<CausalNode> {
890 self.nodes
891 .iter()
892 .filter(|r| {
893 r.value()
894 .metadata
895 .get("conv_id")
896 .and_then(|v| v.as_str())
897 == Some(conv_id)
898 })
899 .map(|r| r.value().clone())
900 .collect()
901 }
902
903 pub fn edges_for_conv(&self, conv_id: &str) -> Vec<CausalEdge> {
911 let in_conv: std::collections::HashSet<NodeId> = self
912 .nodes
913 .iter()
914 .filter(|r| {
915 r.value()
916 .metadata
917 .get("conv_id")
918 .and_then(|v| v.as_str())
919 == Some(conv_id)
920 })
921 .map(|r| *r.key())
922 .collect();
923 in_conv
924 .iter()
925 .flat_map(|id| self.get_forward_edges(*id))
926 .filter(|e| in_conv.contains(&e.source) && in_conv.contains(&e.target))
927 .collect()
928 }
929
930 pub fn degree(&self, id: NodeId) -> usize {
932 let fwd = self.forward_edges.get(&id).map_or(0, |e| e.len());
933 let rev = self.reverse_edges.get(&id).map_or(0, |e| e.len());
934 fwd + rev
935 }
936
937 pub fn in_degree(&self, id: NodeId) -> usize {
939 self.reverse_edges.get(&id).map_or(0, |e| e.len())
940 }
941
942 pub fn out_degree(&self, id: NodeId) -> usize {
944 self.forward_edges.get(&id).map_or(0, |e| e.len())
945 }
946
947 pub fn connected_components(&self) -> Vec<Vec<NodeId>> {
956 let ids = self.node_ids();
957 let mut visited: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
958 let mut components = Vec::new();
959
960 for &id in &ids {
961 if visited.contains(&id) {
962 continue;
963 }
964 let mut component = Vec::new();
966 let mut queue: VecDeque<NodeId> = VecDeque::new();
967 visited.insert(id);
968 queue.push_back(id);
969
970 while let Some(current) = queue.pop_front() {
971 component.push(current);
972 for edge in self.get_forward_edges(current) {
974 if visited.insert(edge.target) {
975 queue.push_back(edge.target);
976 }
977 }
978 for edge in self.get_reverse_edges(current) {
980 if visited.insert(edge.source) {
981 queue.push_back(edge.source);
982 }
983 }
984 }
985 component.sort();
986 components.push(component);
987 }
988
989 components.sort_by_key(|b| std::cmp::Reverse(b.len()));
990 components
991 }
992
993 pub fn detect_communities(&self, max_iterations: usize) -> Vec<Vec<NodeId>> {
1006 let ids = self.node_ids();
1007 if ids.is_empty() {
1008 return Vec::new();
1009 }
1010
1011 let mut labels: std::collections::HashMap<NodeId, NodeId> =
1013 std::collections::HashMap::new();
1014 for &id in &ids {
1015 labels.insert(id, id);
1016 }
1017
1018 for _iter in 0..max_iterations {
1019 let mut changed = false;
1020
1021 let mut process_order = ids.clone();
1023 process_order.sort();
1024
1025 for &id in &process_order {
1026 let mut label_weights: std::collections::HashMap<NodeId, f32> =
1028 std::collections::HashMap::new();
1029
1030 for edge in self.get_forward_edges(id) {
1031 if let Some(&lbl) = labels.get(&edge.target) {
1032 *label_weights.entry(lbl).or_insert(0.0) += edge.weight;
1033 }
1034 }
1035 for edge in self.get_reverse_edges(id) {
1036 if let Some(&lbl) = labels.get(&edge.source) {
1037 *label_weights.entry(lbl).or_insert(0.0) += edge.weight;
1038 }
1039 }
1040
1041 if label_weights.is_empty() {
1042 continue; }
1044
1045 let best_label = label_weights
1048 .iter()
1049 .max_by(|a, b| {
1050 a.1.partial_cmp(b.1)
1051 .unwrap_or(std::cmp::Ordering::Equal)
1052 .then_with(|| b.0.cmp(a.0)) })
1054 .map(|(&lbl, _)| lbl)
1055 .unwrap();
1056
1057 if labels[&id] != best_label {
1058 labels.insert(id, best_label);
1059 changed = true;
1060 }
1061 }
1062
1063 if !changed {
1064 break;
1065 }
1066 }
1067
1068 let mut communities: std::collections::HashMap<NodeId, Vec<NodeId>> =
1070 std::collections::HashMap::new();
1071 for (&node, &label) in &labels {
1072 communities.entry(label).or_default().push(node);
1073 }
1074
1075 let mut result: Vec<Vec<NodeId>> = communities.into_values().collect();
1076 for community in &mut result {
1077 community.sort();
1078 }
1079 result.sort_by_key(|b| std::cmp::Reverse(b.len()));
1080 result
1081 }
1082
1083 pub fn spectral_analysis(&self, max_iterations: usize) -> SpectralResult {
1101 let ids = self.node_ids();
1102 let n = ids.len();
1103
1104 if n < 2 {
1105 return SpectralResult {
1106 lambda_2: 0.0,
1107 fiedler_vector: Vec::new(),
1108 node_ids: ids,
1109 };
1110 }
1111
1112 let mut id_to_idx: std::collections::HashMap<NodeId, usize> =
1114 std::collections::HashMap::new();
1115 let mut sorted_ids = ids.clone();
1116 sorted_ids.sort();
1117 for (i, &id) in sorted_ids.iter().enumerate() {
1118 id_to_idx.insert(id, i);
1119 }
1120
1121 let mut adj: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
1124 let mut degree: Vec<f64> = vec![0.0; n];
1125
1126 for &id in &sorted_ids {
1127 let i = id_to_idx[&id];
1128
1129 for edge in self.get_forward_edges(id) {
1131 if let Some(&j) = id_to_idx.get(&edge.target)
1132 && i != j
1133 {
1134 let w = edge.weight as f64;
1135 adj[i].push((j, w));
1136 adj[j].push((i, w));
1137 degree[i] += w;
1138 degree[j] += w;
1139 }
1140 }
1141 for edge in self.get_reverse_edges(id) {
1143 if let Some(&j) = id_to_idx.get(&edge.source)
1144 && i != j
1145 && j > i
1146 {
1147 let w = edge.weight as f64;
1148 adj[i].push((j, w));
1149 adj[j].push((i, w));
1150 degree[i] += w;
1151 degree[j] += w;
1152 }
1153 }
1154 }
1155
1156 for i in 0..n {
1159 let mut d = 0.0f64;
1160 for &(_, w) in &adj[i] {
1161 d += w;
1162 }
1163 degree[i] = d;
1164 }
1165
1166 let laplacian_mul = |x: &[f64], out: &mut [f64]| {
1168 for i in 0..n {
1169 let mut sum = degree[i] * x[i]; for &(j, w) in &adj[i] {
1171 sum -= w * x[j]; }
1173 out[i] = sum;
1174 }
1175 };
1176
1177 let inv_sqrt_n = 1.0 / (n as f64).sqrt();
1186
1187 let mut q: Vec<f64> = (0..n)
1189 .map(|i| (i as f64) - (n as f64 - 1.0) / 2.0)
1190 .collect();
1191
1192 let dot_ones: f64 = q.iter().sum::<f64>() * inv_sqrt_n;
1194 for qi in q.iter_mut() {
1195 *qi -= dot_ones * inv_sqrt_n;
1196 }
1197 normalize_vec(&mut q);
1198
1199 let k = max_iterations.min(n - 1); let mut alpha: Vec<f64> = Vec::with_capacity(k); let mut beta: Vec<f64> = Vec::with_capacity(k); let mut basis: Vec<Vec<f64>> = Vec::with_capacity(k); let mut q_prev: Vec<f64> = vec![0.0; n];
1205 let mut w_buf: Vec<f64> = vec![0.0; n];
1206
1207 for j in 0..k {
1208 basis.push(q.clone());
1209
1210 laplacian_mul(&q, &mut w_buf);
1212
1213 let aj: f64 = q.iter().zip(w_buf.iter()).map(|(a, b)| a * b).sum();
1215 alpha.push(aj);
1216
1217 let bj_prev = if j > 0 { beta[j - 1] } else { 0.0 };
1219 for i in 0..n {
1220 w_buf[i] -= aj * q[i] + bj_prev * q_prev[i];
1221 }
1222
1223 let dot_c: f64 = w_buf.iter().sum::<f64>() * inv_sqrt_n;
1226 for wi in w_buf.iter_mut() {
1227 *wi -= dot_c * inv_sqrt_n;
1228 }
1229 for prev in &basis {
1230 let dot: f64 = w_buf.iter().zip(prev.iter()).map(|(a, b)| a * b).sum();
1231 for i in 0..n {
1232 w_buf[i] -= dot * prev[i];
1233 }
1234 }
1235
1236 let bj: f64 = w_buf.iter().map(|x| x * x).sum::<f64>().sqrt();
1237 beta.push(bj);
1238
1239 if bj < 1e-12 {
1240 break;
1242 }
1243
1244 q_prev = q.clone();
1246 q = w_buf.iter().map(|&x| x / bj).collect();
1247 }
1248
1249 let m = alpha.len();
1254 let (evals, evecs) = tridiag_eigen(
1255 &alpha,
1256 &beta[..m.saturating_sub(1).min(beta.len())],
1257 m,
1258 );
1259
1260 let mut min_idx = 0;
1262 let mut min_val = f64::MAX;
1263 for (i, &ev) in evals.iter().enumerate() {
1264 if ev < min_val {
1265 min_val = ev;
1266 min_idx = i;
1267 }
1268 }
1269 let lambda_2 = min_val.max(0.0);
1270
1271 let s = &evecs[min_idx];
1274 let mut fiedler = vec![0.0f64; n];
1275 for (j, bvec) in basis.iter().enumerate() {
1276 if j < s.len() {
1277 let sj = s[j];
1278 for i in 0..n {
1279 fiedler[i] += sj * bvec[i];
1280 }
1281 }
1282 }
1283 normalize_vec(&mut fiedler);
1284
1285 SpectralResult {
1286 lambda_2,
1287 fiedler_vector: fiedler,
1288 node_ids: sorted_ids,
1289 }
1290 }
1291
1292 pub fn spectral_analysis_rff(&self, num_features: usize, max_iter: usize) -> SpectralResult {
1316 let ids = self.node_ids();
1317 let n = ids.len();
1318
1319 if n < 2 {
1320 return SpectralResult {
1321 lambda_2: 0.0,
1322 fiedler_vector: Vec::new(),
1323 node_ids: ids,
1324 };
1325 }
1326
1327 let mut id_to_idx: std::collections::HashMap<NodeId, usize> =
1329 std::collections::HashMap::new();
1330 let mut sorted_ids = ids.clone();
1331 sorted_ids.sort();
1332 for (i, &id) in sorted_ids.iter().enumerate() {
1333 id_to_idx.insert(id, i);
1334 }
1335
1336 let mut adj: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
1338 let mut degree: Vec<f64> = vec![0.0; n];
1339
1340 for &id in &sorted_ids {
1341 let i = id_to_idx[&id];
1342 for edge in self.get_forward_edges(id) {
1343 if let Some(&j) = id_to_idx.get(&edge.target)
1344 && i != j
1345 {
1346 let w = edge.weight as f64;
1347 adj[i].push((j, w));
1348 adj[j].push((i, w));
1349 degree[i] += w;
1350 degree[j] += w;
1351 }
1352 }
1353 for edge in self.get_reverse_edges(id) {
1354 if let Some(&j) = id_to_idx.get(&edge.source)
1355 && i != j
1356 && j > i
1357 {
1358 let w = edge.weight as f64;
1359 adj[i].push((j, w));
1360 adj[j].push((i, w));
1361 degree[i] += w;
1362 degree[j] += w;
1363 }
1364 }
1365 }
1366 for i in 0..n {
1368 degree[i] = adj[i].iter().map(|&(_, w)| w).sum();
1369 }
1370
1371 let laplacian_mul = |x: &[f64], out: &mut [f64]| {
1373 for i in 0..n {
1374 let mut sum = degree[i] * x[i];
1375 for &(j, w) in &adj[i] {
1376 sum -= w * x[j];
1377 }
1378 out[i] = sum;
1379 }
1380 };
1381
1382 let m = num_features.min(n - 1);
1383 let inv_sqrt_n = 1.0 / (n as f64).sqrt();
1384
1385 let mut seed: u64 = 0xDEAD_BEEF_CAFE_BABEu64;
1388 let next_gaussian = |seed: &mut u64| -> f64 {
1389 let uniform = |s: &mut u64| -> f64 {
1391 *s = s
1392 .wrapping_mul(6364136223846793005)
1393 .wrapping_add(1442695040888963407);
1394 (*s >> 11) as f64 / (1u64 << 53) as f64
1395 };
1396 let u1 = uniform(seed).max(1e-15);
1397 let u2 = uniform(seed);
1398 (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
1399 };
1400
1401 let mut z_vecs: Vec<Vec<f64>> = Vec::with_capacity(m);
1403 let mut lz_vecs: Vec<Vec<f64>> = Vec::with_capacity(m);
1404
1405 for _ in 0..m {
1406 let mut z: Vec<f64> = (0..n).map(|_| next_gaussian(&mut seed)).collect();
1407 let dot_ones: f64 = z.iter().sum::<f64>() * inv_sqrt_n;
1409 for zi in z.iter_mut() {
1410 *zi -= dot_ones * inv_sqrt_n;
1411 }
1412 normalize_vec(&mut z);
1413
1414 let mut lz = vec![0.0; n];
1415 laplacian_mul(&z, &mut lz);
1416
1417 z_vecs.push(z);
1418 lz_vecs.push(lz);
1419 }
1420
1421 let mut c_mat = vec![vec![0.0f64; m]; m];
1423 for i in 0..m {
1424 for j in i..m {
1425 let dot: f64 = z_vecs[i]
1426 .iter()
1427 .zip(lz_vecs[j].iter())
1428 .map(|(a, b)| a * b)
1429 .sum();
1430 c_mat[i][j] = dot;
1431 c_mat[j][i] = dot;
1432 }
1433 }
1434
1435 let _diag: Vec<f64> = (0..m).map(|i| c_mat[i][i]).collect();
1437 let _off: Vec<f64> = if m > 1 {
1438 (0..m - 1).map(|i| c_mat[i][i + 1]).collect()
1439 } else {
1440 Vec::new()
1441 };
1442
1443 let (evals, evecs) = dense_jacobi_eigen(&c_mat, m, max_iter);
1445
1446 let mut eval_indices: Vec<(usize, f64)> =
1448 evals.iter().enumerate().map(|(i, &v)| (i, v)).collect();
1449 eval_indices.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1450
1451 let (lambda2_idx, lambda_2) = if eval_indices.len() >= 2 {
1454 (eval_indices[1].0, eval_indices[1].1.max(0.0))
1455 } else {
1456 (eval_indices[0].0, eval_indices[0].1.max(0.0))
1457 };
1458
1459 let s = &evecs[lambda2_idx];
1462 let mut fiedler = vec![0.0f64; n];
1463 for (j, z) in z_vecs.iter().enumerate() {
1464 if j < s.len() {
1465 let sj = s[j];
1466 for i in 0..n {
1467 fiedler[i] += sj * z[i];
1468 }
1469 }
1470 }
1471 normalize_vec(&mut fiedler);
1472
1473 SpectralResult {
1474 lambda_2,
1475 fiedler_vector: fiedler,
1476 node_ids: sorted_ids,
1477 }
1478 }
1479
1480 pub fn spectral_analysis_auto(&self) -> (SpectralMethod, SpectralResult) {
1497 let n = self.node_count() as usize;
1498 let policy = select_spectral_method(n);
1499 match policy {
1500 SpectralMethod::Lanczos => {
1501 let k = 50.min(n.saturating_sub(1).max(1));
1502 (SpectralMethod::Lanczos, self.spectral_analysis(k))
1503 }
1504 SpectralMethod::Rff | SpectralMethod::Eml => {
1507 (SpectralMethod::Rff, self.spectral_analysis_rff(64, 50))
1508 }
1509 }
1510 }
1511
1512 pub fn spectral_analysis_with_method(&self, method: SpectralMethod) -> SpectralResult {
1518 let n = self.node_count() as usize;
1519 match method {
1520 SpectralMethod::Lanczos => {
1521 let k = 50.min(n.saturating_sub(1).max(1));
1522 self.spectral_analysis(k)
1523 }
1524 SpectralMethod::Rff | SpectralMethod::Eml => self.spectral_analysis_rff(64, 50),
1525 }
1526 }
1527
1528 pub fn spectral_partition(&self) -> (Vec<NodeId>, Vec<NodeId>) {
1534 let result = self.spectral_analysis(50);
1535 let mut a = Vec::new();
1536 let mut b = Vec::new();
1537
1538 for (i, &id) in result.node_ids.iter().enumerate() {
1539 if i < result.fiedler_vector.len() && result.fiedler_vector[i] >= 0.0 {
1540 a.push(id);
1541 } else {
1542 b.push(id);
1543 }
1544 }
1545 (a, b)
1546 }
1547
1548 pub fn compute_coupling(&self, change_events: &[ChangeEvent]) -> Vec<CouplingPair> {
1564 let mut change_count: std::collections::HashMap<NodeId, usize> =
1565 std::collections::HashMap::new();
1566 let mut co_change_count: std::collections::HashMap<(NodeId, NodeId), usize> =
1567 std::collections::HashMap::new();
1568
1569 for event in change_events {
1570 let mut nodes: Vec<NodeId> = event.node_ids.clone();
1571 nodes.sort();
1572 nodes.dedup();
1573
1574 for &id in &nodes {
1575 *change_count.entry(id).or_insert(0) += 1;
1576 }
1577 for i in 0..nodes.len() {
1579 for j in (i + 1)..nodes.len() {
1580 let key = (nodes[i], nodes[j]);
1581 *co_change_count.entry(key).or_insert(0) += 1;
1582 }
1583 }
1584 }
1585
1586 let mut pairs: Vec<CouplingPair> = co_change_count
1587 .iter()
1588 .map(|(&(a, b), &co)| {
1589 let max_changes = change_count
1590 .get(&a)
1591 .copied()
1592 .unwrap_or(1)
1593 .max(change_count.get(&b).copied().unwrap_or(1));
1594 CouplingPair {
1595 node_a: a,
1596 node_b: b,
1597 co_changes: co,
1598 coupling_score: co as f64 / max_changes as f64,
1599 }
1600 })
1601 .collect();
1602
1603 pairs.sort_by(|a, b| {
1604 b.coupling_score
1605 .partial_cmp(&a.coupling_score)
1606 .unwrap_or(std::cmp::Ordering::Equal)
1607 });
1608 pairs
1609 }
1610
1611 pub fn predict_changes(
1624 &self,
1625 change_events: &[ChangeEvent],
1626 window_size: usize,
1627 baseline_factor: f64,
1628 ) -> Vec<ChangePrediction> {
1629 if change_events.is_empty() {
1630 return Vec::new();
1631 }
1632
1633 let mut sorted_events = change_events.to_vec();
1635 sorted_events.sort_by_key(|e| e.timestamp);
1636
1637 let total = sorted_events.len();
1638 let window_start = total.saturating_sub(window_size);
1639
1640 let mut total_counts: std::collections::HashMap<NodeId, usize> =
1642 std::collections::HashMap::new();
1643 for event in &sorted_events {
1644 for &id in &event.node_ids {
1645 *total_counts.entry(id).or_insert(0) += 1;
1646 }
1647 }
1648
1649 let mut window_counts: std::collections::HashMap<NodeId, usize> =
1651 std::collections::HashMap::new();
1652 for event in &sorted_events[window_start..] {
1653 for &id in &event.node_ids {
1654 *window_counts.entry(id).or_insert(0) += 1;
1655 }
1656 }
1657
1658 let window_len = total - window_start;
1659
1660 let mut burst_nodes: Vec<(NodeId, f64)> = Vec::new();
1662 for (&id, &window_count) in &window_counts {
1663 let total_count = total_counts.get(&id).copied().unwrap_or(0);
1664 let baseline_rate = total_count as f64 / total as f64;
1665 let window_rate = window_count as f64 / window_len as f64;
1666
1667 if baseline_rate > 0.0 && window_rate / baseline_rate >= baseline_factor {
1668 burst_nodes.push((id, window_rate / baseline_rate));
1669 }
1670 }
1671
1672 let coupling = self.compute_coupling(change_events);
1674 let coupling_map: std::collections::HashMap<(NodeId, NodeId), f64> = coupling
1675 .iter()
1676 .map(|p| ((p.node_a, p.node_b), p.coupling_score))
1677 .collect();
1678
1679 let mut predictions: std::collections::HashMap<NodeId, f64> =
1681 std::collections::HashMap::new();
1682
1683 for &(id, burst_ratio) in &burst_nodes {
1685 *predictions.entry(id).or_insert(0.0) += burst_ratio * 0.6;
1686 }
1687
1688 for &(burst_id, burst_ratio) in &burst_nodes {
1690 for (&(a, b), &coupling_score) in &coupling_map {
1691 let partner = if a == burst_id {
1692 Some(b)
1693 } else if b == burst_id {
1694 Some(a)
1695 } else {
1696 None
1697 };
1698 if let Some(partner_id) = partner {
1699 *predictions.entry(partner_id).or_insert(0.0) +=
1700 burst_ratio * coupling_score * 0.4;
1701 }
1702 }
1703 }
1704
1705 let recency_window = (window_size / 3).max(1);
1707 let recency_start = total.saturating_sub(recency_window);
1708 for event in &sorted_events[recency_start..] {
1709 for &id in &event.node_ids {
1710 *predictions.entry(id).or_insert(0.0) += 0.1;
1711 }
1712 }
1713
1714 let mut result: Vec<ChangePrediction> = predictions
1715 .into_iter()
1716 .map(|(id, confidence)| {
1717 let label = self
1718 .get_node(id)
1719 .map(|n| n.label.clone())
1720 .unwrap_or_else(|| format!("node:{id}"));
1721 let in_burst = burst_nodes.iter().any(|&(bid, _)| bid == id);
1722 ChangePrediction {
1723 node_id: id,
1724 label,
1725 confidence: confidence.min(1.0),
1726 in_burst,
1727 recent_changes: window_counts.get(&id).copied().unwrap_or(0),
1728 }
1729 })
1730 .collect();
1731
1732 result.sort_by(|a, b| {
1733 b.confidence
1734 .partial_cmp(&a.confidence)
1735 .unwrap_or(std::cmp::Ordering::Equal)
1736 });
1737 result
1738 }
1739}
1740
1741#[derive(Debug, Clone)]
1751pub struct CausalChain {
1752 pub path: Vec<(NodeId, CausalEdgeType, NodeId)>,
1754 pub explanation: String,
1756 pub total_weight: f32,
1758}
1759
1760#[derive(Debug, Clone)]
1762pub struct SpectralResult {
1763 pub lambda_2: f64,
1766 pub fiedler_vector: Vec<f64>,
1768 pub node_ids: Vec<NodeId>,
1770}
1771
1772#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1793pub enum SpectralMethod {
1794 Lanczos,
1796 Rff,
1798 Eml,
1800}
1801
1802impl SpectralMethod {
1803 pub fn as_str(self) -> &'static str {
1805 match self {
1806 Self::Lanczos => "lanczos",
1807 Self::Rff => "rff",
1808 Self::Eml => "eml",
1809 }
1810 }
1811}
1812
1813pub const SPECTRAL_RFF_MIN_NODES: usize = 10_000;
1818
1819pub const SPECTRAL_EML_MIN_NODES: usize = 100_000;
1826
1827pub fn select_spectral_method(node_count: usize) -> SpectralMethod {
1832 if node_count >= SPECTRAL_EML_MIN_NODES {
1833 SpectralMethod::Eml
1834 } else if node_count >= SPECTRAL_RFF_MIN_NODES {
1835 SpectralMethod::Rff
1836 } else {
1837 SpectralMethod::Lanczos
1838 }
1839}
1840
1841#[derive(Debug, Clone)]
1843pub struct ChangeEvent {
1844 pub node_ids: Vec<NodeId>,
1846 pub timestamp: u64,
1848}
1849
1850#[derive(Debug, Clone)]
1852pub struct CouplingPair {
1853 pub node_a: NodeId,
1855 pub node_b: NodeId,
1857 pub co_changes: usize,
1859 pub coupling_score: f64,
1861}
1862
1863#[derive(Debug, Clone)]
1865pub struct ChangePrediction {
1866 pub node_id: NodeId,
1868 pub label: String,
1870 pub confidence: f64,
1872 pub in_burst: bool,
1874 pub recent_changes: usize,
1876}
1877
1878fn normalize_vec(v: &mut [f64]) {
1880 let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
1881 if norm > 1e-12 {
1882 v.iter_mut().for_each(|x| *x /= norm);
1883 }
1884}
1885
1886fn tridiag_eigen(diag: &[f64], off: &[f64], m: usize) -> (Vec<f64>, Vec<Vec<f64>>) {
1899 if m == 0 {
1900 return (Vec::new(), Vec::new());
1901 }
1902 if m == 1 {
1903 return (vec![diag[0]], vec![vec![1.0]]);
1904 }
1905
1906 let mut a = vec![vec![0.0f64; m]; m];
1908 for i in 0..m {
1909 a[i][i] = diag[i];
1910 }
1911 let off_len = off.len().min(m - 1);
1912 for i in 0..off_len {
1913 a[i][i + 1] = off[i];
1914 a[i + 1][i] = off[i];
1915 }
1916
1917 let mut v = vec![vec![0.0f64; m]; m];
1919 for i in 0..m {
1920 v[i][i] = 1.0;
1921 }
1922
1923 for _ in 0..100 * m {
1925 let mut max_off = 0.0f64;
1927 let mut p = 0usize;
1928 let mut q = 1usize;
1929 for i in 0..m {
1930 for j in (i + 1)..m {
1931 if a[i][j].abs() > max_off {
1932 max_off = a[i][j].abs();
1933 p = i;
1934 q = j;
1935 }
1936 }
1937 }
1938
1939 if max_off < 1e-15 {
1940 break;
1941 }
1942
1943 let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
1945 let t = theta.signum() / (theta.abs() + (1.0 + theta * theta).sqrt());
1946 let c = 1.0 / (1.0 + t * t).sqrt();
1947 let s = t * c;
1948
1949 let app = a[p][p];
1951 let aqq = a[q][q];
1952 let apq = a[p][q];
1953 a[p][p] = c * c * app - 2.0 * s * c * apq + s * s * aqq;
1954 a[q][q] = s * s * app + 2.0 * s * c * apq + c * c * aqq;
1955 a[p][q] = 0.0;
1956 a[q][p] = 0.0;
1957
1958 for r in 0..m {
1959 if r != p && r != q {
1960 let arp = a[r][p];
1961 let arq = a[r][q];
1962 a[r][p] = c * arp - s * arq;
1963 a[p][r] = a[r][p];
1964 a[r][q] = s * arp + c * arq;
1965 a[q][r] = a[r][q];
1966 }
1967 }
1968
1969 for r in 0..m {
1971 let vp = v[r][p];
1972 let vq = v[r][q];
1973 v[r][p] = c * vp - s * vq;
1974 v[r][q] = s * vp + c * vq;
1975 }
1976 }
1977
1978 let eigenvalues: Vec<f64> = (0..m).map(|i| a[i][i]).collect();
1980
1981 let eigenvectors: Vec<Vec<f64>> = (0..m).map(|j| (0..m).map(|i| v[i][j]).collect()).collect();
1984
1985 (eigenvalues, eigenvectors)
1986}
1987
1988fn dense_jacobi_eigen(mat: &[Vec<f64>], m: usize, max_iter: usize) -> (Vec<f64>, Vec<Vec<f64>>) {
1997 if m == 0 {
1998 return (Vec::new(), Vec::new());
1999 }
2000 if m == 1 {
2001 return (vec![mat[0][0]], vec![vec![1.0]]);
2002 }
2003
2004 let mut a = mat.to_vec();
2005
2006 let mut v = vec![vec![0.0f64; m]; m];
2008 for i in 0..m {
2009 v[i][i] = 1.0;
2010 }
2011
2012 for _ in 0..max_iter {
2013 let mut max_off = 0.0f64;
2015 let mut p = 0usize;
2016 let mut q = 1usize;
2017 for i in 0..m {
2018 for j in (i + 1)..m {
2019 if a[i][j].abs() > max_off {
2020 max_off = a[i][j].abs();
2021 p = i;
2022 q = j;
2023 }
2024 }
2025 }
2026
2027 if max_off < 1e-15 {
2028 break;
2029 }
2030
2031 let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
2032 let t = theta.signum() / (theta.abs() + (1.0 + theta * theta).sqrt());
2033 let c = 1.0 / (1.0 + t * t).sqrt();
2034 let s = t * c;
2035
2036 let app = a[p][p];
2037 let aqq = a[q][q];
2038 let apq = a[p][q];
2039 a[p][p] = c * c * app - 2.0 * s * c * apq + s * s * aqq;
2040 a[q][q] = s * s * app + 2.0 * s * c * apq + c * c * aqq;
2041 a[p][q] = 0.0;
2042 a[q][p] = 0.0;
2043
2044 for r in 0..m {
2045 if r != p && r != q {
2046 let arp = a[r][p];
2047 let arq = a[r][q];
2048 a[r][p] = c * arp - s * arq;
2049 a[p][r] = a[r][p];
2050 a[r][q] = s * arp + c * arq;
2051 a[q][r] = a[r][q];
2052 }
2053 }
2054
2055 for r in 0..m {
2056 let vp = v[r][p];
2057 let vq = v[r][q];
2058 v[r][p] = c * vp - s * vq;
2059 v[r][q] = s * vp + c * vq;
2060 }
2061 }
2062
2063 let eigenvalues: Vec<f64> = (0..m).map(|i| a[i][i]).collect();
2064 let eigenvectors: Vec<Vec<f64>> = (0..m).map(|j| (0..m).map(|i| v[i][j]).collect()).collect();
2065
2066 (eigenvalues, eigenvectors)
2067}
2068
2069#[derive(Serialize, Deserialize)]
2075struct CausalGraphSnapshot {
2076 next_node_id: u64,
2077 nodes: Vec<CausalNode>,
2078 forward_edges: std::collections::HashMap<NodeId, Vec<CausalEdge>>,
2079}
2080
2081impl CausalGraph {
2082 pub fn save_to_writer<W: std::io::Write>(&self, writer: W) -> Result<(), std::io::Error> {
2084 let snapshot = self.to_snapshot();
2085 serde_json::to_writer_pretty(writer, &snapshot)
2086 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))
2087 }
2088
2089 pub fn load_from_reader<R: std::io::Read>(reader: R) -> Result<Self, std::io::Error> {
2091 let snapshot: CausalGraphSnapshot = serde_json::from_reader(reader)
2092 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
2093 Ok(Self::from_snapshot(snapshot))
2094 }
2095
2096 pub fn save_to_file(&self, path: &std::path::Path) -> Result<(), std::io::Error> {
2098 if let Some(parent) = path.parent() {
2099 std::fs::create_dir_all(parent)?;
2100 }
2101 let file = std::fs::File::create(path)?;
2102 let writer = std::io::BufWriter::new(file);
2103 self.save_to_writer(writer)
2104 }
2105
2106 pub fn load_from_file(path: &std::path::Path) -> Result<Self, std::io::Error> {
2108 let file = std::fs::File::open(path)?;
2109 let reader = std::io::BufReader::new(file);
2110 Self::load_from_reader(reader)
2111 }
2112
2113 fn to_snapshot(&self) -> CausalGraphSnapshot {
2114 let nodes: Vec<CausalNode> = self.nodes.iter().map(|r| r.value().clone()).collect();
2115 let mut forward_edges = std::collections::HashMap::new();
2116 for entry in self.forward_edges.iter() {
2117 if !entry.value().is_empty() {
2118 forward_edges.insert(*entry.key(), entry.value().clone());
2119 }
2120 }
2121 CausalGraphSnapshot {
2122 next_node_id: self.next_node_id.load(Ordering::SeqCst),
2123 nodes,
2124 forward_edges,
2125 }
2126 }
2127
2128 fn from_snapshot(snapshot: CausalGraphSnapshot) -> Self {
2129 let graph = Self {
2130 nodes: DashMap::new(),
2131 forward_edges: DashMap::new(),
2132 reverse_edges: DashMap::new(),
2133 next_node_id: AtomicU64::new(snapshot.next_node_id),
2134 node_count: AtomicU64::new(0),
2135 edge_count: AtomicU64::new(0),
2136 components: Mutex::new(ComponentIndex::new()),
2137 #[cfg(feature = "exochain")]
2138 chain_manager: None,
2139 #[cfg(feature = "exochain")]
2140 governance_engine: None,
2141 };
2142
2143 for node in &snapshot.nodes {
2145 graph.nodes.insert(node.id, node.clone());
2146 graph.forward_edges.insert(node.id, Vec::new());
2147 graph.reverse_edges.insert(node.id, Vec::new());
2148 }
2149 graph
2150 .node_count
2151 .store(snapshot.nodes.len() as u64, Ordering::SeqCst);
2152
2153 let mut total_edges: u64 = 0;
2155 for (source_id, edges) in &snapshot.forward_edges {
2156 for edge in edges {
2157 if let Some(mut fwd) = graph.forward_edges.get_mut(source_id) {
2158 fwd.push(edge.clone());
2159 }
2160 if let Some(mut rev) = graph.reverse_edges.get_mut(&edge.target) {
2161 rev.push(edge.clone());
2162 }
2163 total_edges += 1;
2164 }
2165 }
2166 graph.edge_count.store(total_edges, Ordering::SeqCst);
2167 graph.rebuild_component_index();
2168
2169 graph
2170 }
2171}
2172
2173impl fmt::Debug for CausalGraph {
2174 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2175 f.debug_struct("CausalGraph")
2176 .field("node_count", &self.node_count.load(Ordering::Relaxed))
2177 .field("edge_count", &self.edge_count.load(Ordering::Relaxed))
2178 .finish()
2179 }
2180}
2181
2182impl Default for CausalGraph {
2183 fn default() -> Self {
2184 Self::new()
2185 }
2186}
2187
2188#[derive(Debug, Clone, Serialize, Deserialize)]
2194pub struct ShadowingConfig {
2195 pub decay_rate: f64,
2197 pub shadow_radius: usize,
2199}
2200
2201impl Default for ShadowingConfig {
2202 fn default() -> Self {
2203 Self {
2204 decay_rate: 0.1,
2205 shadow_radius: 3,
2206 }
2207 }
2208}
2209
2210fn edge_type_volatility(edge_type: &CausalEdgeType) -> f64 {
2213 match edge_type {
2214 CausalEdgeType::Correlates => 2.0,
2216 CausalEdgeType::Follows => 1.5,
2218 CausalEdgeType::EvidenceFor => 1.2,
2220 CausalEdgeType::TriggeredBy => 1.3,
2222 CausalEdgeType::Contradicts => 0.5,
2224 CausalEdgeType::Causes => 0.3,
2226 CausalEdgeType::Enables => 0.4,
2227 CausalEdgeType::Inhibits => 0.4,
2229 }
2230}
2231
2232pub fn max_volatility_batched<'a, I>(edges: I) -> f64
2246where
2247 I: IntoIterator<Item = &'a CausalEdge>,
2248{
2249 let kinds: Vec<&CausalEdgeType> = edges.into_iter().map(|e| &e.edge_type).collect();
2251 if kinds.is_empty() {
2252 return 1.0;
2253 }
2254
2255 match clawft_treecalc::triage(kinds.iter().copied()) {
2256 clawft_treecalc::Form::Sequence => {
2258 edge_type_volatility(kinds[0]).max(1.0)
2260 }
2261 clawft_treecalc::Form::Atom => 1.0,
2264 clawft_treecalc::Form::Branch => {
2267 let mut seen: std::collections::HashSet<std::mem::Discriminant<CausalEdgeType>> =
2268 std::collections::HashSet::new();
2269 let mut max = 1.0_f64;
2270 for k in &kinds {
2271 if seen.insert(std::mem::discriminant(*k)) {
2272 max = max.max(edge_type_volatility(k));
2273 }
2274 }
2275 max
2276 }
2277 }
2278}
2279
2280pub fn compute_shadows(
2294 graph: &CausalGraph,
2295 config: &ShadowingConfig,
2296 current_epoch: u64,
2297) -> std::collections::HashMap<NodeId, f64> {
2298 let node_ids = graph.node_ids();
2299 let mut weights: std::collections::HashMap<NodeId, f64> = std::collections::HashMap::new();
2300
2301 if config.decay_rate <= 0.0 || config.decay_rate > 1.0 {
2302 for &id in &node_ids {
2304 weights.insert(id, 1.0);
2305 }
2306 return weights;
2307 }
2308
2309 for &id in &node_ids {
2317 let node = match graph.get_node(id) {
2318 Some(n) => n,
2319 None => continue,
2320 };
2321
2322 let age = current_epoch.saturating_sub(node.created_at) as f64;
2323
2324 let fwd_edges = graph.get_forward_edges(id);
2325 let rev_edges = graph.get_reverse_edges(id);
2326 let max_volatility = max_volatility_batched(fwd_edges.iter().chain(rev_edges.iter()));
2327
2328 let base_weight = (1.0 - config.decay_rate).powf(age * max_volatility);
2330 weights.insert(id, base_weight.clamp(0.0, 1.0));
2331 }
2332
2333 if config.shadow_radius > 0 {
2336 let mut sorted: Vec<(NodeId, f64)> = weights.iter().map(|(&k, &v)| (k, v)).collect();
2338 sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
2339
2340 let shadow_reductions: Vec<(NodeId, f64)> = {
2342 let mut reductions = Vec::new();
2343 for &(caster_id, caster_weight) in &sorted {
2344 if caster_weight < 0.5 {
2345 break; }
2347
2348 let nearby = graph.traverse_forward(caster_id, config.shadow_radius);
2350 let nearby_rev = graph.traverse_reverse(caster_id, config.shadow_radius);
2351
2352 for neighbor_id in nearby.iter().chain(nearby_rev.iter()) {
2353 let neighbor_weight = weights.get(neighbor_id).copied().unwrap_or(1.0);
2354 if neighbor_weight < caster_weight {
2356 let suppression = caster_weight * config.decay_rate;
2360 let new_weight = (neighbor_weight - suppression).max(0.0);
2361 reductions.push((*neighbor_id, new_weight));
2362 }
2363 }
2364 }
2365 reductions
2366 };
2367
2368 for (id, new_weight) in shadow_reductions {
2369 let entry = weights.entry(id).or_insert(1.0);
2370 if new_weight < *entry {
2371 *entry = new_weight;
2372 }
2373 }
2374 }
2375
2376 weights
2377}
2378
2379#[derive(Debug, Clone, Serialize, Deserialize)]
2389pub struct VqCodebook {
2390 centroids: Vec<Vec<f32>>,
2392 assignments: std::collections::HashMap<String, usize>,
2394 dim: usize,
2396}
2397
2398impl VqCodebook {
2399 pub fn new(k: usize, dim: usize) -> Self {
2403 Self {
2404 centroids: vec![vec![0.0; dim]; k],
2405 assignments: std::collections::HashMap::new(),
2406 dim,
2407 }
2408 }
2409
2410 pub fn k(&self) -> usize {
2412 self.centroids.len()
2413 }
2414
2415 pub fn dim(&self) -> usize {
2417 self.dim
2418 }
2419
2420 pub fn train(&mut self, embeddings: &[(String, Vec<f32>)]) {
2427 if embeddings.is_empty() || self.centroids.is_empty() {
2428 return;
2429 }
2430
2431 let k = self.centroids.len();
2432 let n = embeddings.len();
2433
2434 self.centroids[0] = embeddings[0].1.clone();
2437 self.centroids[0].resize(self.dim, 0.0);
2438
2439 for ci in 1..k {
2440 if ci >= n {
2441 self.centroids[ci] = self.centroids[ci - 1].clone();
2443 continue;
2444 }
2445
2446 let mut best_dist = f32::NEG_INFINITY;
2448 let mut best_idx = ci;
2449 for (ei, (_etype, vec)) in embeddings.iter().enumerate() {
2450 let min_dist = (0..ci)
2451 .map(|c| Self::distance_sq(vec, &self.centroids[c]))
2452 .fold(f32::INFINITY, f32::min);
2453 if min_dist > best_dist {
2454 best_dist = min_dist;
2455 best_idx = ei;
2456 }
2457 }
2458 let mut v = embeddings[best_idx].1.clone();
2459 v.resize(self.dim, 0.0);
2460 self.centroids[ci] = v;
2461 }
2462
2463 let max_lloyd_iterations = 50;
2465 for _iter in 0..max_lloyd_iterations {
2466 let mut cluster_sums: Vec<Vec<f64>> = vec![vec![0.0; self.dim]; k];
2468 let mut cluster_counts: Vec<usize> = vec![0; k];
2469 let mut assigns: Vec<usize> = Vec::with_capacity(n);
2470
2471 for (_etype, vec) in embeddings {
2472 let (ci, _dist) = self.quantize(vec);
2473 assigns.push(ci);
2474 cluster_counts[ci] += 1;
2475 for (d, &val) in vec.iter().enumerate().take(self.dim) {
2476 cluster_sums[ci][d] += val as f64;
2477 }
2478 }
2479
2480 let mut changed = false;
2482 for ci in 0..k {
2483 if cluster_counts[ci] == 0 {
2484 continue;
2485 }
2486 let count = cluster_counts[ci] as f64;
2487 for d in 0..self.dim {
2488 let new_val = (cluster_sums[ci][d] / count) as f32;
2489 if (new_val - self.centroids[ci][d]).abs() > 1e-6 {
2490 changed = true;
2491 }
2492 self.centroids[ci][d] = new_val;
2493 }
2494 }
2495
2496 if !changed {
2497 break;
2498 }
2499 }
2500
2501 self.assignments.clear();
2503 for (etype, vec) in embeddings {
2504 let (ci, _dist) = self.quantize(vec);
2505 self.assignments.insert(etype.clone(), ci);
2506 }
2507 }
2508
2509 pub fn lookup(&self, entity_type: &str) -> Option<&[f32]> {
2513 self.assignments
2514 .get(entity_type)
2515 .map(|&ci| self.centroids[ci].as_slice())
2516 }
2517
2518 pub fn quantize(&self, vector: &[f32]) -> (usize, f32) {
2522 let mut best_ci = 0;
2523 let mut best_dist = f32::INFINITY;
2524 for (ci, centroid) in self.centroids.iter().enumerate() {
2525 let dist = Self::distance_sq(vector, centroid);
2526 if dist < best_dist {
2527 best_dist = dist;
2528 best_ci = ci;
2529 }
2530 }
2531 (best_ci, best_dist)
2532 }
2533
2534 fn distance_sq(a: &[f32], b: &[f32]) -> f32 {
2536 let len = a.len().min(b.len());
2537 let mut sum = 0.0f32;
2538 for i in 0..len {
2539 let diff = a[i] - b[i];
2540 sum += diff * diff;
2541 }
2542 for val in a.iter().skip(len) {
2544 sum += val * val;
2545 }
2546 for val in b.iter().skip(len) {
2547 sum += val * val;
2548 }
2549 sum
2550 }
2551}
2552
2553#[cfg(test)]
2558mod tests {
2559 use super::*;
2560
2561 fn make_graph() -> CausalGraph {
2562 CausalGraph::new()
2563 }
2564
2565 #[test]
2567 fn new_graph_empty() {
2568 let g = make_graph();
2569 assert_eq!(g.node_count(), 0);
2570 assert_eq!(g.edge_count(), 0);
2571 }
2572
2573 #[test]
2575 fn add_node_returns_id() {
2576 let g = make_graph();
2577 let id1 = g.add_node("A".into(), serde_json::json!({}));
2578 let id2 = g.add_node("B".into(), serde_json::json!({}));
2579 assert_ne!(id1, id2);
2580 assert_eq!(g.node_count(), 2);
2581 }
2582
2583 #[test]
2585 fn get_node() {
2586 let g = make_graph();
2587 let id = g.add_node("hello".into(), serde_json::json!({"key": "val"}));
2588 let node = g.get_node(id).unwrap();
2589 assert_eq!(node.label, "hello");
2590 assert_eq!(node.metadata["key"], "val");
2591 }
2592
2593 #[test]
2595 fn set_node_state_mirrors_into_metadata() {
2596 let g = make_graph();
2597 let id = g.add_node("turn".into(), serde_json::json!({"chain_seq": 7}));
2599 assert!(g.set_node_state(id, "frontier"));
2600 let node = g.get_node(id).unwrap();
2601 assert_eq!(node.metadata["state"], "frontier");
2602 assert_eq!(node.metadata["chain_seq"], 7);
2603 assert!(g.set_node_state(id, "committed"));
2604 assert_eq!(g.get_node(id).unwrap().metadata["state"], "committed");
2605
2606 let id2 = g.add_node("scalar".into(), serde_json::json!(42));
2608 assert!(g.set_node_state(id2, "pruned"));
2609 assert_eq!(g.get_node(id2).unwrap().metadata["state"], "pruned");
2610
2611 assert!(!g.set_node_state(99_999, "frontier"));
2613 }
2614
2615 #[test]
2617 fn merge_node_metadata_merges_and_preserves() {
2618 let g = make_graph();
2619 let id = g.add_node(
2620 "turn".into(),
2621 serde_json::json!({ "chain_seq": 7, "classification": { "tier": "keyword" } }),
2622 );
2623
2624 let mut patch = serde_json::Map::new();
2625 patch.insert(
2626 "classification".into(),
2627 serde_json::json!({ "tier": "llm", "intent": "question" }),
2628 );
2629 assert!(g.merge_node_metadata(id, &patch));
2630
2631 let node = g.get_node(id).unwrap();
2632 assert_eq!(node.metadata["classification"]["tier"], "llm");
2634 assert_eq!(node.metadata["classification"]["intent"], "question");
2635 assert_eq!(node.metadata["chain_seq"], 7);
2637
2638 assert!(!g.merge_node_metadata(99_999, &patch));
2640 }
2641
2642 #[test]
2644 fn merge_node_metadata_rejects_protected_keys() {
2645 let g = make_graph();
2646 let id = g.add_node(
2647 "turn".into(),
2648 serde_json::json!({
2649 "state": "committed",
2650 "uid": "abc",
2651 "chain_seq": 7,
2652 "text": "hello",
2653 "conv_id": "conv-1",
2654 }),
2655 );
2656
2657 let mut patch = serde_json::Map::new();
2658 for key in ["state", "uid", "chain_seq", "text", "conv_id"] {
2659 patch.insert(key.into(), serde_json::json!("HACKED"));
2660 }
2661 patch.insert("classification".into(), serde_json::json!({ "tier": "llm" }));
2663
2664 assert!(g.merge_node_metadata(id, &patch));
2666
2667 let node = g.get_node(id).unwrap();
2668 assert_eq!(node.metadata["state"], "committed");
2669 assert_eq!(node.metadata["uid"], "abc");
2670 assert_eq!(node.metadata["chain_seq"], 7);
2671 assert_eq!(node.metadata["text"], "hello");
2672 assert_eq!(node.metadata["conv_id"], "conv-1");
2673 assert_eq!(node.metadata["classification"]["tier"], "llm");
2674 }
2675
2676 #[test]
2678 fn merge_node_metadata_seeds_non_object() {
2679 let g = make_graph();
2680 let id = g.add_node("scalar".into(), serde_json::json!(42));
2681 let mut patch = serde_json::Map::new();
2682 patch.insert("classification".into(), serde_json::json!({ "tier": "llm" }));
2683 assert!(g.merge_node_metadata(id, &patch));
2684 assert_eq!(
2685 g.get_node(id).unwrap().metadata["classification"]["tier"],
2686 "llm"
2687 );
2688 }
2689
2690 #[test]
2695 fn merge_node_metadata_races_with_set_node_state() {
2696 use std::sync::Arc;
2697 use std::thread;
2698
2699 for _round in 0..64 {
2700 let g = Arc::new(make_graph());
2701 let id = g.add_node(
2702 "turn".into(),
2703 serde_json::json!({ "chain_seq": 1, "classification": { "tier": "keyword" } }),
2704 );
2705
2706 let g_state = Arc::clone(&g);
2707 let state_thread = thread::spawn(move || {
2708 for _ in 0..200 {
2709 g_state.set_node_state(id, "committed");
2710 }
2711 });
2712 let g_merge = Arc::clone(&g);
2713 let merge_thread = thread::spawn(move || {
2714 let mut patch = serde_json::Map::new();
2715 patch.insert("classification".into(), serde_json::json!({ "tier": "llm" }));
2716 for _ in 0..200 {
2717 g_merge.merge_node_metadata(id, &patch);
2718 }
2719 });
2720 state_thread.join().unwrap();
2721 merge_thread.join().unwrap();
2722
2723 let node = g.get_node(id).unwrap();
2726 assert_eq!(node.metadata["state"], "committed");
2727 assert_eq!(node.metadata["classification"]["tier"], "llm");
2728 assert_eq!(node.metadata["chain_seq"], 1);
2729 }
2730 }
2731
2732 #[test]
2734 fn remove_node() {
2735 let g = make_graph();
2736 let id = g.add_node("X".into(), serde_json::json!({}));
2737 assert!(g.get_node(id).is_some());
2738 let removed = g.remove_node(id).unwrap();
2739 assert_eq!(removed.label, "X");
2740 assert!(g.get_node(id).is_none());
2741 assert_eq!(g.node_count(), 0);
2742 }
2743
2744 #[test]
2746 fn link_creates_edge() {
2747 let g = make_graph();
2748 let a = g.add_node("A".into(), serde_json::json!({}));
2749 let b = g.add_node("B".into(), serde_json::json!({}));
2750 assert!(g.link(a, b, CausalEdgeType::Causes, 0.9, 100, 1));
2751 assert_eq!(g.edge_count(), 1);
2752 }
2753
2754 #[test]
2756 fn link_invalid_source_returns_false() {
2757 let g = make_graph();
2758 let b = g.add_node("B".into(), serde_json::json!({}));
2759 assert!(!g.link(9999, b, CausalEdgeType::Causes, 0.5, 0, 0));
2760 assert_eq!(g.edge_count(), 0);
2761 }
2762
2763 #[test]
2765 fn link_invalid_target_returns_false() {
2766 let g = make_graph();
2767 let a = g.add_node("A".into(), serde_json::json!({}));
2768 assert!(!g.link(a, 9999, CausalEdgeType::Causes, 0.5, 0, 0));
2769 assert_eq!(g.edge_count(), 0);
2770 }
2771
2772 #[test]
2774 fn get_forward_edges() {
2775 let g = make_graph();
2776 let a = g.add_node("A".into(), serde_json::json!({}));
2777 let b = g.add_node("B".into(), serde_json::json!({}));
2778 let c = g.add_node("C".into(), serde_json::json!({}));
2779 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
2780 g.link(a, c, CausalEdgeType::Enables, 0.5, 0, 0);
2781 let fwd = g.get_forward_edges(a);
2782 assert_eq!(fwd.len(), 2);
2783 assert!(fwd.iter().any(|e| e.target == b));
2784 assert!(fwd.iter().any(|e| e.target == c));
2785 }
2786
2787 #[test]
2789 fn get_reverse_edges() {
2790 let g = make_graph();
2791 let a = g.add_node("A".into(), serde_json::json!({}));
2792 let b = g.add_node("B".into(), serde_json::json!({}));
2793 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
2794 let rev = g.get_reverse_edges(b);
2795 assert_eq!(rev.len(), 1);
2796 assert_eq!(rev[0].source, a);
2797 }
2798
2799 #[test]
2801 fn get_edges_by_type() {
2802 let g = make_graph();
2803 let a = g.add_node("A".into(), serde_json::json!({}));
2804 let b = g.add_node("B".into(), serde_json::json!({}));
2805 let c = g.add_node("C".into(), serde_json::json!({}));
2806 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
2807 g.link(a, c, CausalEdgeType::Inhibits, 0.3, 0, 0);
2808 let causes = g.get_edges_by_type(a, &CausalEdgeType::Causes);
2809 assert_eq!(causes.len(), 1);
2810 assert_eq!(causes[0].target, b);
2811 }
2812
2813 #[test]
2815 fn unlink_removes_edges() {
2816 let g = make_graph();
2817 let a = g.add_node("A".into(), serde_json::json!({}));
2818 let b = g.add_node("B".into(), serde_json::json!({}));
2819 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
2820 g.link(a, b, CausalEdgeType::Enables, 0.5, 0, 0);
2821 assert_eq!(g.edge_count(), 2);
2822 let removed = g.unlink(a, b);
2823 assert_eq!(removed, 2);
2824 assert_eq!(g.edge_count(), 0);
2825 assert!(g.get_forward_edges(a).is_empty());
2826 assert!(g.get_reverse_edges(b).is_empty());
2827 }
2828
2829 #[test]
2831 fn node_count() {
2832 let g = make_graph();
2833 assert_eq!(g.node_count(), 0);
2834 g.add_node("A".into(), serde_json::json!({}));
2835 assert_eq!(g.node_count(), 1);
2836 let id = g.add_node("B".into(), serde_json::json!({}));
2837 assert_eq!(g.node_count(), 2);
2838 g.remove_node(id);
2839 assert_eq!(g.node_count(), 1);
2840 }
2841
2842 #[test]
2844 fn edge_count() {
2845 let g = make_graph();
2846 let a = g.add_node("A".into(), serde_json::json!({}));
2847 let b = g.add_node("B".into(), serde_json::json!({}));
2848 let c = g.add_node("C".into(), serde_json::json!({}));
2849 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
2850 g.link(b, c, CausalEdgeType::Follows, 0.8, 0, 0);
2851 assert_eq!(g.edge_count(), 2);
2852 }
2853
2854 #[test]
2856 fn clear_empties_graph() {
2857 let g = make_graph();
2858 let a = g.add_node("A".into(), serde_json::json!({}));
2859 let b = g.add_node("B".into(), serde_json::json!({}));
2860 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
2861 g.clear().unwrap();
2862 assert_eq!(g.node_count(), 0);
2863 assert_eq!(g.edge_count(), 0);
2864 }
2865
2866 #[test]
2868 fn traverse_forward_single_hop() {
2869 let g = make_graph();
2870 let a = g.add_node("A".into(), serde_json::json!({}));
2871 let b = g.add_node("B".into(), serde_json::json!({}));
2872 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
2873 let reachable = g.traverse_forward(a, 1);
2874 assert_eq!(reachable, vec![b]);
2875 }
2876
2877 #[test]
2879 fn traverse_forward_multi_hop() {
2880 let g = make_graph();
2881 let a = g.add_node("A".into(), serde_json::json!({}));
2882 let b = g.add_node("B".into(), serde_json::json!({}));
2883 let c = g.add_node("C".into(), serde_json::json!({}));
2884 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
2885 g.link(b, c, CausalEdgeType::Causes, 1.0, 0, 0);
2886 let reachable = g.traverse_forward(a, 2);
2887 assert!(reachable.contains(&b));
2888 assert!(reachable.contains(&c));
2889 assert_eq!(reachable.len(), 2);
2890 }
2891
2892 #[test]
2894 fn traverse_forward_depth_limit() {
2895 let g = make_graph();
2896 let a = g.add_node("A".into(), serde_json::json!({}));
2897 let b = g.add_node("B".into(), serde_json::json!({}));
2898 let c = g.add_node("C".into(), serde_json::json!({}));
2899 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
2900 g.link(b, c, CausalEdgeType::Causes, 1.0, 0, 0);
2901 let reachable = g.traverse_forward(a, 1);
2902 assert_eq!(reachable, vec![b]);
2903 assert!(!reachable.contains(&c));
2904 }
2905
2906 #[test]
2908 fn traverse_reverse() {
2909 let g = make_graph();
2910 let a = g.add_node("A".into(), serde_json::json!({}));
2911 let b = g.add_node("B".into(), serde_json::json!({}));
2912 let c = g.add_node("C".into(), serde_json::json!({}));
2913 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
2914 g.link(b, c, CausalEdgeType::Causes, 1.0, 0, 0);
2915 let reachable = g.traverse_reverse(c, 2);
2916 assert!(reachable.contains(&b));
2917 assert!(reachable.contains(&a));
2918 }
2919
2920 #[test]
2922 fn find_path_exists() {
2923 let g = make_graph();
2924 let a = g.add_node("A".into(), serde_json::json!({}));
2925 let b = g.add_node("B".into(), serde_json::json!({}));
2926 let c = g.add_node("C".into(), serde_json::json!({}));
2927 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
2928 g.link(b, c, CausalEdgeType::Causes, 1.0, 0, 0);
2929 let path = g.find_path(a, c, 5).unwrap();
2930 assert_eq!(path, vec![a, b, c]);
2931 }
2932
2933 #[test]
2935 fn find_path_no_path() {
2936 let g = make_graph();
2937 let a = g.add_node("A".into(), serde_json::json!({}));
2938 let b = g.add_node("B".into(), serde_json::json!({}));
2939 assert!(g.find_path(a, b, 5).is_none());
2941 }
2942
2943 #[test]
2945 fn concurrent_add_nodes() {
2946 use std::sync::Arc;
2947 use std::thread;
2948
2949 let g = Arc::new(CausalGraph::new());
2950 let mut handles = Vec::new();
2951
2952 for t in 0..4 {
2953 let g = Arc::clone(&g);
2954 handles.push(thread::spawn(move || {
2955 for i in 0..25 {
2956 g.add_node(format!("t{t}-n{i}"), serde_json::json!({}));
2957 }
2958 }));
2959 }
2960
2961 for h in handles {
2962 h.join().unwrap();
2963 }
2964
2965 assert_eq!(g.node_count(), 100);
2966 }
2967
2968 #[test]
2970 fn causal_edge_type_display() {
2971 assert_eq!(CausalEdgeType::Causes.to_string(), "Causes");
2972 assert_eq!(CausalEdgeType::Inhibits.to_string(), "Inhibits");
2973 assert_eq!(CausalEdgeType::Correlates.to_string(), "Correlates");
2974 assert_eq!(CausalEdgeType::Enables.to_string(), "Enables");
2975 assert_eq!(CausalEdgeType::Follows.to_string(), "Follows");
2976 assert_eq!(CausalEdgeType::Contradicts.to_string(), "Contradicts");
2977 assert_eq!(CausalEdgeType::TriggeredBy.to_string(), "TriggeredBy");
2978 assert_eq!(CausalEdgeType::EvidenceFor.to_string(), "EvidenceFor");
2979 }
2980
2981 #[test]
2987 fn degree_computation() {
2988 let g = make_graph();
2989 let a = g.add_node("A".into(), serde_json::json!({}));
2990 let b = g.add_node("B".into(), serde_json::json!({}));
2991 let c = g.add_node("C".into(), serde_json::json!({}));
2992 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
2993 g.link(a, c, CausalEdgeType::Enables, 0.5, 0, 0);
2994 g.link(b, c, CausalEdgeType::Follows, 0.8, 0, 0);
2995 assert_eq!(g.out_degree(a), 2);
2996 assert_eq!(g.in_degree(a), 0);
2997 assert_eq!(g.degree(a), 2);
2998 assert_eq!(g.in_degree(c), 2);
2999 assert_eq!(g.out_degree(c), 0);
3000 assert_eq!(g.degree(c), 2);
3001 assert_eq!(g.degree(b), 2); }
3003
3004 #[test]
3010 fn connected_components_single() {
3011 let g = make_graph();
3012 let a = g.add_node("A".into(), serde_json::json!({}));
3013 let b = g.add_node("B".into(), serde_json::json!({}));
3014 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
3015 let cc = g.connected_components();
3016 assert_eq!(cc.len(), 1);
3017 assert_eq!(cc[0].len(), 2);
3018 }
3019
3020 #[test]
3022 fn connected_components_two_islands() {
3023 let g = make_graph();
3024 let a = g.add_node("A".into(), serde_json::json!({}));
3025 let b = g.add_node("B".into(), serde_json::json!({}));
3026 let c = g.add_node("C".into(), serde_json::json!({}));
3027 let d = g.add_node("D".into(), serde_json::json!({}));
3028 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
3029 g.link(c, d, CausalEdgeType::Causes, 1.0, 0, 0);
3030 let cc = g.connected_components();
3031 assert_eq!(cc.len(), 2);
3032 assert_eq!(cc[0].len(), 2);
3033 assert_eq!(cc[1].len(), 2);
3034 }
3035
3036 #[test]
3038 fn component_count_fast_matches_connected_components() {
3039 let g = make_graph();
3040 assert_eq!(g.component_count_fast(), 0);
3041
3042 let a = g.add_node("A".into(), serde_json::json!({}));
3043 let b = g.add_node("B".into(), serde_json::json!({}));
3044 let c = g.add_node("C".into(), serde_json::json!({}));
3045 assert_eq!(g.component_count_fast(), 3);
3046 assert_eq!(g.component_count_fast() as usize, g.connected_components().len());
3047
3048 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
3049 assert_eq!(g.component_count_fast(), 2);
3050
3051 g.link(b, c, CausalEdgeType::Causes, 1.0, 0, 0);
3052 assert_eq!(g.component_count_fast(), 1);
3053 assert_eq!(g.component_count_fast() as usize, g.connected_components().len());
3054
3055 g.link(a, b, CausalEdgeType::Enables, 0.5, 0, 0);
3057 assert_eq!(g.component_count_fast(), 1);
3058
3059 g.unlink(a, b);
3062 assert_eq!(g.component_count_fast(), 2);
3065 assert_eq!(g.component_count_fast() as usize, g.connected_components().len());
3066
3067 g.unlink(b, c);
3068 assert_eq!(g.component_count_fast(), 3);
3069
3070 g.remove_node(c);
3071 assert_eq!(g.component_count_fast(), 2);
3072 assert_eq!(g.component_count_fast() as usize, g.connected_components().len());
3073 }
3074
3075 #[test]
3077 fn connected_components_isolated_node() {
3078 let g = make_graph();
3079 let a = g.add_node("A".into(), serde_json::json!({}));
3080 let b = g.add_node("B".into(), serde_json::json!({}));
3081 g.add_node("isolated".into(), serde_json::json!({}));
3082 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
3083 let cc = g.connected_components();
3084 assert_eq!(cc.len(), 2);
3085 assert_eq!(cc[0].len(), 2); assert_eq!(cc[1].len(), 1); }
3088
3089 #[test]
3095 fn community_detection_two_clusters() {
3096 let g = make_graph();
3097 let a = g.add_node("A".into(), serde_json::json!({}));
3099 let b = g.add_node("B".into(), serde_json::json!({}));
3100 let c = g.add_node("C".into(), serde_json::json!({}));
3101 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
3102 g.link(b, c, CausalEdgeType::Causes, 1.0, 0, 0);
3103 g.link(c, a, CausalEdgeType::Causes, 1.0, 0, 0);
3104
3105 let d = g.add_node("D".into(), serde_json::json!({}));
3107 let e = g.add_node("E".into(), serde_json::json!({}));
3108 let f = g.add_node("F".into(), serde_json::json!({}));
3109 g.link(d, e, CausalEdgeType::Causes, 1.0, 0, 0);
3110 g.link(e, f, CausalEdgeType::Causes, 1.0, 0, 0);
3111 g.link(f, d, CausalEdgeType::Causes, 1.0, 0, 0);
3112
3113 g.link(c, d, CausalEdgeType::Correlates, 0.1, 0, 0);
3115
3116 let communities = g.detect_communities(20);
3117 assert!(!communities.is_empty());
3121 assert!(communities.len() <= 3);
3123 }
3124
3125 #[test]
3127 fn community_detection_isolated_nodes() {
3128 let g = make_graph();
3129 g.add_node("A".into(), serde_json::json!({}));
3130 g.add_node("B".into(), serde_json::json!({}));
3131 let communities = g.detect_communities(10);
3132 assert_eq!(communities.len(), 2);
3134 }
3135
3136 #[test]
3138 fn community_detection_empty_graph() {
3139 let g = make_graph();
3140 let communities = g.detect_communities(10);
3141 assert!(communities.is_empty());
3142 }
3143
3144 #[test]
3150 fn spectral_connected_graph_positive_lambda2() {
3151 let g = make_graph();
3152 let a = g.add_node("A".into(), serde_json::json!({}));
3153 let b = g.add_node("B".into(), serde_json::json!({}));
3154 let c = g.add_node("C".into(), serde_json::json!({}));
3155 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
3156 g.link(b, c, CausalEdgeType::Causes, 1.0, 0, 0);
3157 g.link(a, c, CausalEdgeType::Causes, 1.0, 0, 0);
3158
3159 let result = g.spectral_analysis(50);
3160 assert!(
3161 result.lambda_2 > 0.0,
3162 "connected graph should have lambda_2 > 0, got {}",
3163 result.lambda_2
3164 );
3165 assert_eq!(result.fiedler_vector.len(), 3);
3166 assert_eq!(result.node_ids.len(), 3);
3167 }
3168
3169 #[test]
3171 fn spectral_disconnected_graph_zero_lambda2() {
3172 let g = make_graph();
3173 let _a = g.add_node("A".into(), serde_json::json!({}));
3174 let _b = g.add_node("B".into(), serde_json::json!({}));
3175 let result = g.spectral_analysis(50);
3177 assert!(
3178 result.lambda_2 < 0.01,
3179 "disconnected graph should have lambda_2 ~ 0, got {}",
3180 result.lambda_2
3181 );
3182 assert_eq!(result.node_ids.len(), 2);
3183 }
3184
3185 #[test]
3187 fn spectral_partition_splits_graph() {
3188 let g = make_graph();
3189 let a = g.add_node("A".into(), serde_json::json!({}));
3190 let b = g.add_node("B".into(), serde_json::json!({}));
3191 let c = g.add_node("C".into(), serde_json::json!({}));
3192 let d = g.add_node("D".into(), serde_json::json!({}));
3193 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
3195 g.link(c, d, CausalEdgeType::Causes, 1.0, 0, 0);
3196 g.link(b, c, CausalEdgeType::Correlates, 0.1, 0, 0);
3197
3198 let (part_a, part_b) = g.spectral_partition();
3199 assert!(!part_a.is_empty());
3200 assert!(!part_b.is_empty());
3201 assert_eq!(part_a.len() + part_b.len(), 4);
3202 }
3203
3204 #[test]
3206 fn spectral_single_node() {
3207 let g = make_graph();
3208 g.add_node("A".into(), serde_json::json!({}));
3209 let result = g.spectral_analysis(50);
3210 assert_eq!(result.lambda_2, 0.0);
3211 }
3212
3213 #[test]
3219 fn coupling_basic() {
3220 let g = make_graph();
3221 let a = g.add_node("A".into(), serde_json::json!({}));
3222 let b = g.add_node("B".into(), serde_json::json!({}));
3223 let c = g.add_node("C".into(), serde_json::json!({}));
3224
3225 let events = vec![
3226 ChangeEvent {
3227 node_ids: vec![a, b],
3228 timestamp: 1,
3229 },
3230 ChangeEvent {
3231 node_ids: vec![a, b],
3232 timestamp: 2,
3233 },
3234 ChangeEvent {
3235 node_ids: vec![a, c],
3236 timestamp: 3,
3237 },
3238 ChangeEvent {
3239 node_ids: vec![b, c],
3240 timestamp: 4,
3241 },
3242 ];
3243
3244 let coupling = g.compute_coupling(&events);
3245 assert!(!coupling.is_empty());
3246
3247 let ab = coupling
3249 .iter()
3250 .find(|p| (p.node_a == a && p.node_b == b) || (p.node_a == b && p.node_b == a));
3251 assert!(ab.is_some());
3252 let ab = ab.unwrap();
3253 assert_eq!(ab.co_changes, 2);
3254 assert!((ab.coupling_score - 2.0 / 3.0).abs() < 0.01);
3255 }
3256
3257 #[test]
3259 fn coupling_empty_events() {
3260 let g = make_graph();
3261 let coupling = g.compute_coupling(&[]);
3262 assert!(coupling.is_empty());
3263 }
3264
3265 #[test]
3267 fn predict_changes_burst_detection() {
3268 let g = make_graph();
3269 let a = g.add_node("module_a".into(), serde_json::json!({}));
3270 let b = g.add_node("module_b".into(), serde_json::json!({}));
3271 let c = g.add_node("module_c".into(), serde_json::json!({}));
3272
3273 let mut events = Vec::new();
3276 for i in 0..50 {
3277 events.push(ChangeEvent {
3278 node_ids: vec![c],
3279 timestamp: i,
3280 });
3281 if i % 10 == 0 {
3282 events.push(ChangeEvent {
3283 node_ids: vec![a],
3284 timestamp: i,
3285 });
3286 }
3287 }
3288 for i in 50..60 {
3290 events.push(ChangeEvent {
3291 node_ids: vec![a, b],
3292 timestamp: i,
3293 });
3294 }
3295 let predictions = g.predict_changes(&events, 10, 1.5);
3300 assert!(!predictions.is_empty());
3301
3302 let pred_a = predictions.iter().find(|p| p.node_id == a);
3304 assert!(pred_a.is_some(), "module_a should be in predictions");
3305 assert!(pred_a.unwrap().in_burst, "module_a should be in burst");
3306
3307 let pred_b = predictions.iter().find(|p| p.node_id == b);
3309 assert!(
3310 pred_b.is_some(),
3311 "module_b should be predicted via coupling"
3312 );
3313 }
3314
3315 #[test]
3317 fn predict_changes_empty_events() {
3318 let g = make_graph();
3319 let predictions = g.predict_changes(&[], 5, 2.0);
3320 assert!(predictions.is_empty());
3321 }
3322
3323 #[test]
3325 fn node_ids_returns_all() {
3326 let g = make_graph();
3327 let a = g.add_node("A".into(), serde_json::json!({}));
3328 let b = g.add_node("B".into(), serde_json::json!({}));
3329 let ids = g.node_ids();
3330 assert_eq!(ids.len(), 2);
3331 assert!(ids.contains(&a));
3332 assert!(ids.contains(&b));
3333 }
3334
3335 #[test]
3337 fn spectral_strongly_connected_high_lambda2() {
3338 let g = make_graph();
3340 let nodes: Vec<NodeId> = (0..4)
3341 .map(|i| g.add_node(format!("N{i}"), serde_json::json!({})))
3342 .collect();
3343 for i in 0..4 {
3344 for j in 0..4 {
3345 if i != j {
3346 g.link(nodes[i], nodes[j], CausalEdgeType::Causes, 1.0, 0, 0);
3347 }
3348 }
3349 }
3350 let result = g.spectral_analysis(50);
3351 assert!(
3353 result.lambda_2 > 3.0,
3354 "complete graph K4 lambda_2 should be ~4.0, got {}",
3355 result.lambda_2
3356 );
3357 }
3358
3359 fn tmp_path(name: &str) -> std::path::PathBuf {
3362 std::env::temp_dir().join(format!(
3363 "causal_test_{name}_{}",
3364 std::time::SystemTime::now()
3365 .duration_since(std::time::UNIX_EPOCH)
3366 .unwrap()
3367 .as_nanos()
3368 ))
3369 }
3370
3371 #[test]
3373 fn persist_empty_graph_roundtrip() {
3374 let g = make_graph();
3375 let path = tmp_path("empty");
3376 g.save_to_file(&path).unwrap();
3377 let loaded = CausalGraph::load_from_file(&path).unwrap();
3378 assert_eq!(loaded.node_count(), 0);
3379 assert_eq!(loaded.edge_count(), 0);
3380 let _ = std::fs::remove_file(&path);
3381 }
3382
3383 #[test]
3385 fn persist_nodes_and_edges_roundtrip() {
3386 let g = make_graph();
3387 let a = g.add_node("Alpha".into(), serde_json::json!({"role": "source"}));
3388 let b = g.add_node("Beta".into(), serde_json::json!({"role": "target"}));
3389 let c = g.add_node("Gamma".into(), serde_json::json!({}));
3390 g.link(a, b, CausalEdgeType::Causes, 0.9, 100, 1);
3391 g.link(b, c, CausalEdgeType::Enables, 0.5, 200, 2);
3392 g.link(a, c, CausalEdgeType::Inhibits, 0.3, 300, 3);
3393
3394 let path = tmp_path("nodes_edges");
3395 g.save_to_file(&path).unwrap();
3396 let loaded = CausalGraph::load_from_file(&path).unwrap();
3397
3398 assert_eq!(loaded.node_count(), 3);
3399 assert_eq!(loaded.edge_count(), 3);
3400
3401 let na = loaded.get_node(a).unwrap();
3403 assert_eq!(na.label, "Alpha");
3404 assert_eq!(na.metadata["role"], "source");
3405
3406 let nb = loaded.get_node(b).unwrap();
3407 assert_eq!(nb.label, "Beta");
3408
3409 let fwd_a = loaded.get_forward_edges(a);
3411 assert_eq!(fwd_a.len(), 2);
3412 assert!(
3413 fwd_a
3414 .iter()
3415 .any(|e| e.target == b && e.edge_type == CausalEdgeType::Causes)
3416 );
3417 assert!(
3418 fwd_a
3419 .iter()
3420 .any(|e| e.target == c && e.edge_type == CausalEdgeType::Inhibits)
3421 );
3422
3423 let _ = std::fs::remove_file(&path);
3424 }
3425
3426 #[test]
3428 fn persist_node_metadata_survives() {
3429 let g = make_graph();
3430 let id = g.add_node(
3431 "rich".into(),
3432 serde_json::json!({
3433 "tags": ["a", "b"],
3434 "count": 42,
3435 "nested": {"x": true}
3436 }),
3437 );
3438
3439 let path = tmp_path("metadata");
3440 g.save_to_file(&path).unwrap();
3441 let loaded = CausalGraph::load_from_file(&path).unwrap();
3442 let node = loaded.get_node(id).unwrap();
3443 assert_eq!(node.metadata["tags"][0], "a");
3444 assert_eq!(node.metadata["count"], 42);
3445 assert_eq!(node.metadata["nested"]["x"], true);
3446 let _ = std::fs::remove_file(&path);
3447 }
3448
3449 #[test]
3451 fn persist_edge_types_and_weights() {
3452 let g = make_graph();
3453 let a = g.add_node("A".into(), serde_json::json!({}));
3454 let b = g.add_node("B".into(), serde_json::json!({}));
3455 g.link(a, b, CausalEdgeType::Contradicts, 0.77, 555, 10);
3456
3457 let path = tmp_path("edge_types");
3458 g.save_to_file(&path).unwrap();
3459 let loaded = CausalGraph::load_from_file(&path).unwrap();
3460 let edges = loaded.get_forward_edges(a);
3461 assert_eq!(edges.len(), 1);
3462 assert_eq!(edges[0].edge_type, CausalEdgeType::Contradicts);
3463 assert!((edges[0].weight - 0.77).abs() < 0.001);
3464 assert_eq!(edges[0].timestamp, 555);
3465 assert_eq!(edges[0].chain_seq, 10);
3466 let _ = std::fs::remove_file(&path);
3467 }
3468
3469 #[test]
3471 fn persist_next_node_id_preserved() {
3472 let g = make_graph();
3473 let _a = g.add_node("A".into(), serde_json::json!({}));
3474 let _b = g.add_node("B".into(), serde_json::json!({}));
3475 let _c = g.add_node("C".into(), serde_json::json!({}));
3476 let path = tmp_path("next_id");
3479 g.save_to_file(&path).unwrap();
3480 let loaded = CausalGraph::load_from_file(&path).unwrap();
3481
3482 let new_id = loaded.add_node("D".into(), serde_json::json!({}));
3484 assert!(new_id >= 4, "new node should get id >= 4, got {new_id}");
3485 assert!(loaded.get_node(new_id).is_some());
3486 assert_eq!(loaded.node_count(), 4);
3487 let _ = std::fs::remove_file(&path);
3488 }
3489
3490 #[test]
3492 fn persist_writer_reader_roundtrip() {
3493 let g = make_graph();
3494 let a = g.add_node("X".into(), serde_json::json!({}));
3495 let b = g.add_node("Y".into(), serde_json::json!({}));
3496 g.link(a, b, CausalEdgeType::Follows, 1.0, 0, 0);
3497
3498 let mut buf = Vec::new();
3499 g.save_to_writer(&mut buf).unwrap();
3500
3501 let loaded = CausalGraph::load_from_reader(buf.as_slice()).unwrap();
3502 assert_eq!(loaded.node_count(), 2);
3503 assert_eq!(loaded.edge_count(), 1);
3504 let edges = loaded.get_forward_edges(a);
3505 assert_eq!(edges[0].edge_type, CausalEdgeType::Follows);
3506 }
3507
3508 #[test]
3510 fn persist_reverse_edges_rebuilt() {
3511 let g = make_graph();
3512 let a = g.add_node("A".into(), serde_json::json!({}));
3513 let b = g.add_node("B".into(), serde_json::json!({}));
3514 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
3515
3516 let path = tmp_path("reverse");
3517 g.save_to_file(&path).unwrap();
3518 let loaded = CausalGraph::load_from_file(&path).unwrap();
3519
3520 let rev = loaded.get_reverse_edges(b);
3522 assert_eq!(rev.len(), 1);
3523 assert_eq!(rev[0].source, a);
3524 let _ = std::fs::remove_file(&path);
3525 }
3526
3527 fn dense_spectral_lambda2(g: &CausalGraph, _max_iterations: usize) -> f64 {
3534 let ids = g.node_ids();
3535 let n = ids.len();
3536 if n < 2 {
3537 return 0.0;
3538 }
3539
3540 let mut id_to_idx: std::collections::HashMap<NodeId, usize> =
3541 std::collections::HashMap::new();
3542 let mut sorted_ids = ids.clone();
3543 sorted_ids.sort();
3544 for (i, &id) in sorted_ids.iter().enumerate() {
3545 id_to_idx.insert(id, i);
3546 }
3547
3548 let mut laplacian = vec![vec![0.0f64; n]; n];
3549 for &id in &sorted_ids {
3550 let i = id_to_idx[&id];
3551 for edge in g.get_forward_edges(id) {
3552 if let Some(&j) = id_to_idx.get(&edge.target)
3553 && i != j
3554 {
3555 let w = edge.weight as f64;
3556 laplacian[i][j] -= w;
3557 laplacian[j][i] -= w;
3558 }
3559 }
3560 for edge in g.get_reverse_edges(id) {
3561 if let Some(&j) = id_to_idx.get(&edge.source)
3562 && i != j
3563 && j > i
3564 {
3565 let w = edge.weight as f64;
3566 laplacian[i][j] -= w;
3567 laplacian[j][i] -= w;
3568 }
3569 }
3570 }
3571 for i in 0..n {
3572 let off_sum: f64 = (0..n).filter(|&j| j != i).map(|j| -laplacian[i][j]).sum();
3573 laplacian[i][i] = off_sum;
3574 }
3575
3576 let mut a = laplacian;
3578 for _ in 0..100 * n {
3579 let mut max_off = 0.0f64;
3580 let mut p = 0usize;
3581 let mut q = 1usize;
3582 for i in 0..n {
3583 for j in (i + 1)..n {
3584 if a[i][j].abs() > max_off {
3585 max_off = a[i][j].abs();
3586 p = i;
3587 q = j;
3588 }
3589 }
3590 }
3591 if max_off < 1e-15 {
3592 break;
3593 }
3594 let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
3595 let t = theta.signum() / (theta.abs() + (1.0 + theta * theta).sqrt());
3596 let c = 1.0 / (1.0 + t * t).sqrt();
3597 let s = t * c;
3598 let app = a[p][p];
3599 let aqq = a[q][q];
3600 let apq = a[p][q];
3601 a[p][p] = c * c * app - 2.0 * s * c * apq + s * s * aqq;
3602 a[q][q] = s * s * app + 2.0 * s * c * apq + c * c * aqq;
3603 a[p][q] = 0.0;
3604 a[q][p] = 0.0;
3605 for r in 0..n {
3606 if r != p && r != q {
3607 let arp = a[r][p];
3608 let arq = a[r][q];
3609 a[r][p] = c * arp - s * arq;
3610 a[p][r] = a[r][p];
3611 a[r][q] = s * arp + c * arq;
3612 a[q][r] = a[r][q];
3613 }
3614 }
3615 }
3616
3617 let mut evals: Vec<f64> = (0..n).map(|i| a[i][i]).collect();
3619 evals.sort_by(|a, b| a.partial_cmp(b).unwrap());
3620 if n >= 2 { evals[1].max(0.0) } else { 0.0 }
3621 }
3622
3623 #[test]
3625 fn spectral_lanczos_matches_dense_triangle() {
3626 let g = make_graph();
3628 let a = g.add_node("A".into(), serde_json::json!({}));
3629 let b = g.add_node("B".into(), serde_json::json!({}));
3630 let c = g.add_node("C".into(), serde_json::json!({}));
3631 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
3632 g.link(b, c, CausalEdgeType::Causes, 1.0, 0, 0);
3633 g.link(a, c, CausalEdgeType::Causes, 1.0, 0, 0);
3634
3635 let sparse_result = g.spectral_analysis(50);
3636 let dense_lambda2 = dense_spectral_lambda2(&g, 200);
3637
3638 assert!(
3639 (sparse_result.lambda_2 - dense_lambda2).abs() < 0.5,
3640 "Lanczos lambda_2={} vs dense lambda_2={} differ too much",
3641 sparse_result.lambda_2,
3642 dense_lambda2,
3643 );
3644 assert!(
3646 sparse_result.lambda_2 > 1.0,
3647 "lambda_2 should be > 1 for K3"
3648 );
3649 }
3650
3651 #[test]
3653 fn spectral_lanczos_matches_dense_path() {
3654 let g = make_graph();
3656 let a = g.add_node("A".into(), serde_json::json!({}));
3657 let b = g.add_node("B".into(), serde_json::json!({}));
3658 let c = g.add_node("C".into(), serde_json::json!({}));
3659 let d = g.add_node("D".into(), serde_json::json!({}));
3660 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
3661 g.link(b, c, CausalEdgeType::Causes, 1.0, 0, 0);
3662 g.link(c, d, CausalEdgeType::Causes, 1.0, 0, 0);
3663
3664 let sparse_result = g.spectral_analysis(50);
3665 let dense_lambda2 = dense_spectral_lambda2(&g, 200);
3666
3667 assert!(
3668 (sparse_result.lambda_2 - dense_lambda2).abs() < 0.5,
3669 "Lanczos lambda_2={} vs dense lambda_2={} differ too much",
3670 sparse_result.lambda_2,
3671 dense_lambda2,
3672 );
3673 assert!(
3674 sparse_result.lambda_2 > 0.0,
3675 "path graph should be connected"
3676 );
3677 }
3678
3679 #[test]
3681 fn spectral_lanczos_matches_dense_k4() {
3682 let g = make_graph();
3684 let nodes: Vec<NodeId> = (0..4)
3685 .map(|i| g.add_node(format!("N{i}"), serde_json::json!({})))
3686 .collect();
3687 for i in 0..4 {
3688 for j in (i + 1)..4 {
3689 g.link(nodes[i], nodes[j], CausalEdgeType::Causes, 1.0, 0, 0);
3690 }
3691 }
3692
3693 let sparse_result = g.spectral_analysis(50);
3694 let dense_lambda2 = dense_spectral_lambda2(&g, 200);
3695
3696 assert!(
3697 (sparse_result.lambda_2 - dense_lambda2).abs() < 0.5,
3698 "K4: Lanczos lambda_2={} vs dense lambda_2={}",
3699 sparse_result.lambda_2,
3700 dense_lambda2,
3701 );
3702 }
3703
3704 #[test]
3706 fn spectral_lanczos_disconnected() {
3707 let g = make_graph();
3709 g.add_node("A".into(), serde_json::json!({}));
3710 g.add_node("B".into(), serde_json::json!({}));
3711
3712 let result = g.spectral_analysis(50);
3713 assert!(
3714 result.lambda_2 < 0.01,
3715 "disconnected graph should have lambda_2 ~ 0, got {}",
3716 result.lambda_2
3717 );
3718 }
3719
3720 #[test]
3726 fn trace_chain_simple_linear() {
3727 let g = make_graph();
3729 let a = g.add_node("A".into(), serde_json::json!({}));
3730 let b = g.add_node("B".into(), serde_json::json!({}));
3731 let c = g.add_node("C".into(), serde_json::json!({}));
3732 g.link(a, b, CausalEdgeType::Causes, 0.8, 0, 0);
3733 g.link(b, c, CausalEdgeType::Enables, 0.6, 0, 0);
3734
3735 let chain = g.trace_causal_chain(a, c, 10).expect("path should exist");
3736 assert_eq!(chain.path.len(), 2);
3737 assert_eq!(chain.path[0], (a, CausalEdgeType::Causes, b));
3738 assert_eq!(chain.path[1], (b, CausalEdgeType::Enables, c));
3739 assert!((chain.total_weight - 1.4).abs() < 1e-5);
3740 assert!(chain.explanation.contains("Causes"));
3741 assert!(chain.explanation.contains("Enables"));
3742 assert!(chain.explanation.contains("A"));
3743 assert!(chain.explanation.contains("C"));
3744 }
3745
3746 #[test]
3748 fn trace_chain_same_node() {
3749 let g = make_graph();
3750 let a = g.add_node("A".into(), serde_json::json!({}));
3751 let chain = g
3752 .trace_causal_chain(a, a, 10)
3753 .expect("same node => empty chain");
3754 assert!(chain.path.is_empty());
3755 assert_eq!(chain.total_weight, 0.0);
3756 assert_eq!(chain.explanation, "A");
3757 }
3758
3759 #[test]
3761 fn trace_chain_no_path() {
3762 let g = make_graph();
3763 let a = g.add_node("A".into(), serde_json::json!({}));
3764 let b = g.add_node("B".into(), serde_json::json!({}));
3765 assert!(g.trace_causal_chain(a, b, 10).is_none());
3767 }
3768
3769 #[test]
3771 fn trace_chain_nonexistent_node() {
3772 let g = make_graph();
3773 let a = g.add_node("A".into(), serde_json::json!({}));
3774 assert!(g.trace_causal_chain(a, 9999, 10).is_none());
3775 assert!(g.trace_causal_chain(9999, a, 10).is_none());
3776 }
3777
3778 #[test]
3780 fn trace_chain_depth_limit() {
3781 let g = make_graph();
3783 let a = g.add_node("A".into(), serde_json::json!({}));
3784 let b = g.add_node("B".into(), serde_json::json!({}));
3785 let c = g.add_node("C".into(), serde_json::json!({}));
3786 let d = g.add_node("D".into(), serde_json::json!({}));
3787 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
3788 g.link(b, c, CausalEdgeType::Causes, 1.0, 0, 0);
3789 g.link(c, d, CausalEdgeType::Causes, 1.0, 0, 0);
3790
3791 assert!(g.trace_causal_chain(a, d, 2).is_none());
3792 assert!(g.trace_causal_chain(a, d, 3).is_some());
3793 }
3794
3795 #[test]
3797 fn trace_chain_picks_shortest() {
3798 let g = make_graph();
3801 let a = g.add_node("A".into(), serde_json::json!({}));
3802 let b = g.add_node("B".into(), serde_json::json!({}));
3803 let c = g.add_node("C".into(), serde_json::json!({}));
3804 let x = g.add_node("X".into(), serde_json::json!({}));
3805 let d = g.add_node("D".into(), serde_json::json!({}));
3806 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
3807 g.link(b, d, CausalEdgeType::EvidenceFor, 1.0, 0, 0);
3808 g.link(a, c, CausalEdgeType::Causes, 1.0, 0, 0);
3809 g.link(c, x, CausalEdgeType::Causes, 1.0, 0, 0);
3810 g.link(x, d, CausalEdgeType::Causes, 1.0, 0, 0);
3811
3812 let chain = g.trace_causal_chain(a, d, 10).unwrap();
3813 assert_eq!(chain.path.len(), 2, "should pick the 2-hop path");
3814 }
3815
3816 #[test]
3818 fn trace_chain_mixed_edge_types() {
3819 let g = make_graph();
3820 let a = g.add_node("auth_service".into(), serde_json::json!({}));
3821 let b = g.add_node("config_service".into(), serde_json::json!({}));
3822 let c = g.add_node("cache_ttl".into(), serde_json::json!({}));
3823 g.link(a, b, CausalEdgeType::Causes, 0.9, 0, 0);
3824 g.link(b, c, CausalEdgeType::EvidenceFor, 0.7, 0, 0);
3825
3826 let chain = g.trace_causal_chain(a, c, 10).unwrap();
3827 assert!(chain.explanation.contains("auth_service"));
3828 assert!(chain.explanation.contains("--Causes-->"));
3829 assert!(chain.explanation.contains("--EvidenceFor-->"));
3830 assert!(chain.explanation.contains("cache_ttl"));
3831 }
3832
3833 #[test]
3839 fn rff_single_node() {
3840 let g = make_graph();
3841 g.add_node("A".into(), serde_json::json!({}));
3842 let result = g.spectral_analysis_rff(32, 200);
3843 assert!(result.lambda_2.abs() < 1e-6);
3844 }
3845
3846 #[test]
3848 fn rff_disconnected_graph() {
3849 let g = make_graph();
3850 g.add_node("A".into(), serde_json::json!({}));
3851 g.add_node("B".into(), serde_json::json!({}));
3852 let result = g.spectral_analysis_rff(32, 200);
3853 assert!(
3854 result.lambda_2 < 0.1,
3855 "disconnected graph RFF lambda_2 should be ~0, got {}",
3856 result.lambda_2
3857 );
3858 }
3859
3860 #[test]
3862 fn rff_path_graph_positive_lambda2() {
3863 let g = make_graph();
3865 let a = g.add_node("A".into(), serde_json::json!({}));
3866 let b = g.add_node("B".into(), serde_json::json!({}));
3867 let c = g.add_node("C".into(), serde_json::json!({}));
3868 let d = g.add_node("D".into(), serde_json::json!({}));
3869 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
3870 g.link(b, c, CausalEdgeType::Causes, 1.0, 0, 0);
3871 g.link(c, d, CausalEdgeType::Causes, 1.0, 0, 0);
3872
3873 let result = g.spectral_analysis_rff(64, 200);
3874 assert!(
3875 result.lambda_2 > 0.01,
3876 "connected path graph should have lambda_2 > 0, got {}",
3877 result.lambda_2
3878 );
3879 assert_eq!(result.fiedler_vector.len(), 4);
3880 }
3881
3882 #[test]
3884 fn rff_agrees_with_lanczos_k4() {
3885 let g = make_graph();
3887 let nodes: Vec<NodeId> = (0..4)
3888 .map(|i| g.add_node(format!("N{i}"), serde_json::json!({})))
3889 .collect();
3890 for i in 0..4 {
3891 for j in (i + 1)..4 {
3892 g.link(nodes[i], nodes[j], CausalEdgeType::Causes, 1.0, 0, 0);
3893 }
3894 }
3895
3896 let lanczos = g.spectral_analysis(50);
3897 let rff = g.spectral_analysis_rff(64, 200);
3898
3899 assert!(
3902 rff.lambda_2 > 0.5,
3903 "K4 RFF lambda_2 should be significantly positive, got {}",
3904 rff.lambda_2
3905 );
3906 let ratio = rff.lambda_2 / lanczos.lambda_2.max(1e-12);
3908 assert!(
3909 ratio > 0.3 && ratio < 3.0,
3910 "RFF/Lanczos ratio for K4 should be reasonable: RFF={}, Lanczos={}, ratio={}",
3911 rff.lambda_2,
3912 lanczos.lambda_2,
3913 ratio,
3914 );
3915 }
3916
3917 #[test]
3919 fn rff_returns_correct_node_count() {
3920 let g = make_graph();
3921 for i in 0..8 {
3922 g.add_node(format!("N{i}"), serde_json::json!({}));
3923 }
3924 let result = g.spectral_analysis_rff(16, 100);
3925 assert_eq!(result.node_ids.len(), 8);
3926 assert_eq!(result.fiedler_vector.len(), 8);
3927 }
3928
3929 #[test]
3931 fn rff_fiedler_vector_is_normalized() {
3932 let g = make_graph();
3933 let nodes: Vec<NodeId> = (0..6)
3934 .map(|i| g.add_node(format!("N{i}"), serde_json::json!({})))
3935 .collect();
3936 for i in 0..5 {
3937 g.link(nodes[i], nodes[i + 1], CausalEdgeType::Causes, 1.0, 0, 0);
3938 }
3939
3940 let result = g.spectral_analysis_rff(32, 200);
3941 let norm: f64 = result
3942 .fiedler_vector
3943 .iter()
3944 .map(|x| x * x)
3945 .sum::<f64>()
3946 .sqrt();
3947 assert!(
3948 (norm - 1.0).abs() < 0.05,
3949 "Fiedler vector should be approximately unit-normalized, got norm={}",
3950 norm
3951 );
3952 }
3953
3954 #[test]
3959 fn select_spectral_method_thresholds() {
3960 assert_eq!(select_spectral_method(0), SpectralMethod::Lanczos);
3961 assert_eq!(select_spectral_method(1), SpectralMethod::Lanczos);
3962 assert_eq!(select_spectral_method(999), SpectralMethod::Lanczos);
3963 assert_eq!(select_spectral_method(1_000), SpectralMethod::Lanczos);
3964 assert_eq!(
3965 select_spectral_method(SPECTRAL_RFF_MIN_NODES - 1),
3966 SpectralMethod::Lanczos
3967 );
3968 assert_eq!(
3969 select_spectral_method(SPECTRAL_RFF_MIN_NODES),
3970 SpectralMethod::Rff
3971 );
3972 assert_eq!(select_spectral_method(50_000), SpectralMethod::Rff);
3973 assert_eq!(
3974 select_spectral_method(SPECTRAL_EML_MIN_NODES - 1),
3975 SpectralMethod::Rff
3976 );
3977 assert_eq!(
3978 select_spectral_method(SPECTRAL_EML_MIN_NODES),
3979 SpectralMethod::Eml
3980 );
3981 assert_eq!(select_spectral_method(1_000_000), SpectralMethod::Eml);
3982 }
3983
3984 #[test]
3985 fn spectral_method_as_str_labels() {
3986 assert_eq!(SpectralMethod::Lanczos.as_str(), "lanczos");
3987 assert_eq!(SpectralMethod::Rff.as_str(), "rff");
3988 assert_eq!(SpectralMethod::Eml.as_str(), "eml");
3989 }
3990
3991 #[test]
3992 fn spectral_analysis_auto_small_graph_uses_lanczos() {
3993 let g = make_graph();
3995 let nodes: Vec<NodeId> = (0..8)
3996 .map(|i| g.add_node(format!("N{i}"), serde_json::json!({})))
3997 .collect();
3998 for i in 0..7 {
3999 g.link(nodes[i], nodes[i + 1], CausalEdgeType::Causes, 1.0, 0, 0);
4000 }
4001
4002 let (method, auto) = g.spectral_analysis_auto();
4003 assert_eq!(method, SpectralMethod::Lanczos);
4004 let direct = g.spectral_analysis(50);
4005 assert!(
4006 (auto.lambda_2 - direct.lambda_2).abs() < 1e-9,
4007 "auto Lanczos should match direct: auto={} direct={}",
4008 auto.lambda_2,
4009 direct.lambda_2
4010 );
4011 assert!(auto.lambda_2 > 0.0, "connected path should have lambda_2 > 0");
4012 }
4013
4014 #[test]
4015 fn spectral_analysis_with_method_rff_and_lanczos() {
4016 let g = make_graph();
4018 let nodes: Vec<NodeId> = (0..8)
4019 .map(|i| g.add_node(format!("N{i}"), serde_json::json!({})))
4020 .collect();
4021 for i in 0..7 {
4022 g.link(nodes[i], nodes[i + 1], CausalEdgeType::Causes, 1.0, 0, 0);
4023 }
4024
4025 let dense = dense_spectral_lambda2(&g, 50);
4026 let lanczos = g.spectral_analysis_with_method(SpectralMethod::Lanczos);
4027 let rff = g.spectral_analysis_with_method(SpectralMethod::Rff);
4028
4029 assert!(
4030 (lanczos.lambda_2 - dense).abs() < 0.05,
4031 "Lanczos should match dense ref: lanczos={} dense={}",
4032 lanczos.lambda_2,
4033 dense
4034 );
4035 assert!(
4036 rff.lambda_2 > 0.01,
4037 "RFF path should have positive lambda_2, got {}",
4038 rff.lambda_2
4039 );
4040 let ratio = rff.lambda_2 / lanczos.lambda_2.max(1e-12);
4041 assert!(
4042 ratio > 0.2 && ratio < 5.0,
4043 "RFF/Lanczos ratio out of range: RFF={} Lanczos={} ratio={}",
4044 rff.lambda_2,
4045 lanczos.lambda_2,
4046 ratio
4047 );
4048 }
4049
4050 #[test]
4051 fn spectral_analysis_auto_policy_matches_select() {
4052 assert_eq!(
4055 select_spectral_method(100),
4056 SpectralMethod::Lanczos
4057 );
4058 assert_eq!(
4059 select_spectral_method(SPECTRAL_RFF_MIN_NODES),
4060 SpectralMethod::Rff
4061 );
4062 assert_eq!(
4064 select_spectral_method(SPECTRAL_EML_MIN_NODES),
4065 SpectralMethod::Eml
4066 );
4067 }
4068
4069 #[test]
4072 fn shadowing_no_decay_gives_unit_weights() {
4073 let g = make_graph();
4074 let a = g.add_node("A".into(), serde_json::json!({}));
4075 let b = g.add_node("B".into(), serde_json::json!({}));
4076 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
4077
4078 let config = ShadowingConfig {
4079 decay_rate: 0.0,
4080 shadow_radius: 3,
4081 };
4082 let weights = compute_shadows(&g, &config, 100);
4083 assert_eq!(*weights.get(&a).unwrap(), 1.0);
4084 assert_eq!(*weights.get(&b).unwrap(), 1.0);
4085 }
4086
4087 #[test]
4088 fn shadowing_recent_nodes_have_higher_weight() {
4089 let g = make_graph();
4090 let old_id = g.add_node("Old".into(), serde_json::json!({}));
4092 let recent_id = g.add_node("Recent".into(), serde_json::json!({}));
4093
4094 if let Some(mut node) = g.nodes.get_mut(&old_id) {
4096 node.created_at = 0;
4097 }
4098 if let Some(mut node) = g.nodes.get_mut(&recent_id) {
4099 node.created_at = 90;
4100 }
4101 g.link(old_id, recent_id, CausalEdgeType::Causes, 1.0, 90, 0);
4102
4103 let config = ShadowingConfig {
4104 decay_rate: 0.05,
4105 shadow_radius: 2,
4106 };
4107 let weights = compute_shadows(&g, &config, 100);
4108
4109 let old_w = *weights.get(&old_id).unwrap();
4110 let recent_w = *weights.get(&recent_id).unwrap();
4111 assert!(
4112 recent_w > old_w,
4113 "Recent node (w={recent_w}) should have higher weight than old node (w={old_w})"
4114 );
4115 }
4116
4117 #[test]
4118 fn shadowing_correlates_decay_faster() {
4119 let g = make_graph();
4120 let a = g.add_node("A".into(), serde_json::json!({}));
4121 let b = g.add_node("B".into(), serde_json::json!({}));
4122 let c = g.add_node("C".into(), serde_json::json!({}));
4123
4124 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
4126 g.link(a, c, CausalEdgeType::Correlates, 1.0, 0, 0);
4127
4128 let config = ShadowingConfig {
4130 decay_rate: 0.05,
4131 shadow_radius: 0, };
4133 let weights = compute_shadows(&g, &config, 50);
4134
4135 let w_b = *weights.get(&b).unwrap();
4136 let w_c = *weights.get(&c).unwrap();
4137 assert!(
4139 w_b > w_c,
4140 "Causes-connected node (w={w_b}) should decay slower than Correlates-connected (w={w_c})"
4141 );
4142 }
4143
4144 #[test]
4145 fn shadowing_empty_graph() {
4146 let g = make_graph();
4147 let config = ShadowingConfig::default();
4148 let weights = compute_shadows(&g, &config, 100);
4149 assert!(weights.is_empty());
4150 }
4151
4152 #[test]
4153 fn shadowing_weights_in_range() {
4154 let g = make_graph();
4155 for i in 0..10 {
4156 let id = g.add_node(format!("N{i}"), serde_json::json!({}));
4157 if let Some(mut node) = g.nodes.get_mut(&id) {
4158 node.created_at = i * 10;
4159 }
4160 }
4161 let ids = g.node_ids();
4162 for i in 0..ids.len().saturating_sub(1) {
4163 g.link(ids[i], ids[i + 1], CausalEdgeType::Causes, 1.0, 0, 0);
4164 }
4165
4166 let config = ShadowingConfig {
4167 decay_rate: 0.1,
4168 shadow_radius: 2,
4169 };
4170 let weights = compute_shadows(&g, &config, 100);
4171 for &w in weights.values() {
4172 assert!((0.0..=1.0).contains(&w), "weight {w} out of range");
4173 }
4174 }
4175
4176 fn make_edge(source: NodeId, target: NodeId, kind: CausalEdgeType) -> CausalEdge {
4179 CausalEdge {
4180 source,
4181 target,
4182 edge_type: kind,
4183 weight: 1.0,
4184 timestamp: 0,
4185 chain_seq: 0,
4186 source_universal_id: [0u8; 32],
4187 target_universal_id: [0u8; 32],
4188 }
4189 }
4190
4191 fn naive_max_volatility(edges: &[CausalEdge]) -> f64 {
4192 edges
4193 .iter()
4194 .map(|e| edge_type_volatility(&e.edge_type))
4195 .fold(1.0_f64, f64::max)
4196 }
4197
4198 #[test]
4199 fn batched_max_volatility_empty_returns_one() {
4200 assert_eq!(max_volatility_batched(std::iter::empty()), 1.0);
4202 }
4203
4204 #[test]
4205 fn batched_max_volatility_uniform_kind_matches_naive() {
4206 let edges = vec![
4208 make_edge(1, 2, CausalEdgeType::Causes),
4209 make_edge(1, 3, CausalEdgeType::Causes),
4210 make_edge(1, 4, CausalEdgeType::Causes),
4211 ];
4212 let naive = naive_max_volatility(&edges);
4213 let batched = max_volatility_batched(edges.iter());
4214 assert_eq!(naive, batched);
4215 assert_eq!(batched, 1.0);
4217 }
4218
4219 #[test]
4220 fn batched_max_volatility_mixed_kinds_matches_naive() {
4221 let edges = vec![
4223 make_edge(1, 2, CausalEdgeType::Causes),
4224 make_edge(1, 3, CausalEdgeType::Correlates),
4225 make_edge(1, 4, CausalEdgeType::Follows),
4226 make_edge(1, 5, CausalEdgeType::Inhibits),
4227 ];
4228 let naive = naive_max_volatility(&edges);
4229 let batched = max_volatility_batched(edges.iter());
4230 assert_eq!(naive, batched);
4231 assert_eq!(batched, 2.0);
4233 }
4234
4235 #[test]
4236 fn batched_max_volatility_high_volatility_uniform() {
4237 let edges = [
4239 make_edge(1, 2, CausalEdgeType::Correlates),
4240 make_edge(1, 3, CausalEdgeType::Correlates),
4241 ];
4242 assert_eq!(max_volatility_batched(edges.iter()), 2.0);
4243 }
4244
4245 #[test]
4246 fn batched_max_volatility_dedups_repeated_kinds() {
4247 let edges = vec![
4250 make_edge(1, 2, CausalEdgeType::Causes),
4251 make_edge(1, 3, CausalEdgeType::Causes),
4252 make_edge(1, 4, CausalEdgeType::Correlates),
4253 make_edge(1, 5, CausalEdgeType::Correlates),
4254 make_edge(1, 6, CausalEdgeType::Follows),
4255 ];
4256 assert_eq!(
4257 max_volatility_batched(edges.iter()),
4258 naive_max_volatility(&edges)
4259 );
4260 }
4261
4262 #[test]
4263 fn compute_shadows_post_batching_matches_pre_batching_invariants() {
4264 let g = make_graph();
4269 let a = g.add_node("A".into(), serde_json::json!({}));
4270 let b = g.add_node("B".into(), serde_json::json!({}));
4271 let c = g.add_node("C".into(), serde_json::json!({}));
4272 g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
4273 g.link(a, c, CausalEdgeType::Correlates, 1.0, 0, 0);
4274
4275 let cfg = ShadowingConfig {
4276 decay_rate: 0.05,
4277 shadow_radius: 0,
4278 };
4279 let weights = compute_shadows(&g, &cfg, 50);
4280 let w_b = *weights.get(&b).unwrap();
4281 let w_c = *weights.get(&c).unwrap();
4282 assert!(
4283 w_b > w_c,
4284 "Causes ({w_b}) should decay slower than Correlates ({w_c})"
4285 );
4286 }
4287
4288 #[test]
4291 fn vq_codebook_new() {
4292 let cb = VqCodebook::new(4, 8);
4293 assert_eq!(cb.k(), 4);
4294 assert_eq!(cb.dim(), 8);
4295 assert_eq!(cb.centroids.len(), 4);
4296 assert!(cb.assignments.is_empty());
4297 }
4298
4299 #[test]
4300 fn vq_codebook_quantize_to_nearest() {
4301 let mut cb = VqCodebook::new(2, 3);
4302 cb.centroids[0] = vec![1.0, 0.0, 0.0];
4303 cb.centroids[1] = vec![0.0, 1.0, 0.0];
4304
4305 let (ci, dist) = cb.quantize(&[0.9, 0.1, 0.0]);
4306 assert_eq!(ci, 0, "should be nearest to centroid 0");
4307 assert!(dist < 0.1);
4308
4309 let (ci2, _) = cb.quantize(&[0.1, 0.9, 0.0]);
4310 assert_eq!(ci2, 1, "should be nearest to centroid 1");
4311 }
4312
4313 #[test]
4314 fn vq_codebook_train_and_lookup() {
4315 let mut cb = VqCodebook::new(2, 3);
4316 let embeddings = vec![
4317 ("Function".to_string(), vec![1.0, 0.0, 0.0]),
4318 ("Function".to_string(), vec![0.9, 0.1, 0.0]),
4319 ("Module".to_string(), vec![0.0, 1.0, 0.0]),
4320 ("Module".to_string(), vec![0.1, 0.9, 0.0]),
4321 ];
4322
4323 cb.train(&embeddings);
4324
4325 let func_vec = cb.lookup("Function");
4327 let mod_vec = cb.lookup("Module");
4328 assert!(func_vec.is_some());
4329 assert!(mod_vec.is_some());
4330
4331 let func_centroid = func_vec.unwrap();
4333 assert!(
4334 func_centroid[0] > 0.5,
4335 "Function centroid should be near [1,0,0]"
4336 );
4337
4338 let mod_centroid = mod_vec.unwrap();
4339 assert!(
4340 mod_centroid[1] > 0.5,
4341 "Module centroid should be near [0,1,0]"
4342 );
4343 }
4344
4345 #[test]
4346 fn vq_codebook_lookup_unknown_type() {
4347 let cb = VqCodebook::new(2, 3);
4348 assert!(cb.lookup("Unknown").is_none());
4349 }
4350
4351 #[test]
4352 fn vq_codebook_train_empty() {
4353 let mut cb = VqCodebook::new(3, 4);
4354 cb.train(&[]); assert!(cb.assignments.is_empty());
4356 }
4357
4358 #[test]
4359 fn vq_codebook_train_single_embedding() {
4360 let mut cb = VqCodebook::new(2, 3);
4361 let embeddings = vec![("Type".to_string(), vec![0.5, 0.5, 0.0])];
4362 cb.train(&embeddings);
4363 let result = cb.lookup("Type");
4364 assert!(result.is_some());
4365 }
4366
4367 #[test]
4368 fn vq_codebook_distance_sq_mismatched_dims() {
4369 let dist = VqCodebook::distance_sq(&[1.0, 2.0], &[1.0, 2.0, 3.0]);
4371 assert!(
4372 (dist - 9.0).abs() < 1e-6,
4373 "missing dim treated as 0, so diff=3 -> sq=9"
4374 );
4375 }
4376}