Skip to main content

clawft_kernel/
causal.rs

1//! Causal graph DAG with typed/weighted directed edges.
2//!
3//! Provides a concurrent, lock-free causal reasoning graph where nodes
4//! represent events or observations and edges encode causal relationships
5//! with weights and provenance metadata. Built on `DashMap` for safe
6//! concurrent access from multiple agent threads.
7
8// The later sections of this file host k-means / covariance / PCA math
9// where indexed `for i in 0..n { a[i][j] = ... }` loops are more
10// readable than iterator combinators. Scope the allow to the whole
11// file so the math reads like math.
12#![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// ---------------------------------------------------------------------------
25// ComponentIndex — incremental undirected connected components (WEFT-510)
26// ---------------------------------------------------------------------------
27
28/// Union-find index over causal nodes for O(1) component-count reads.
29///
30/// Unions on edge add are amortised nearly-constant. Edge/node removals
31/// rebuild the index from the adjacency lists (O(n+m)) so the count stays
32/// exact; the hot path (`GraphFeatures::from_causal_graph`) only reads
33/// [`ComponentIndex::count`].
34#[derive(Debug, Default)]
35struct ComponentIndex {
36    parent: HashMap<NodeId, NodeId>,
37    rank: HashMap<NodeId, u8>,
38    /// Number of distinct undirected components among registered nodes.
39    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    /// Register a new isolated node (a new component).
54    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        // Path compression.
68        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    /// Union two nodes. Returns `true` if two previously-distinct components
82    /// were merged (count decreased by one).
83    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
108/// Numeric identifier for causal graph nodes.
109///
110/// This is local to the causal module and distinct from
111/// [`crate::cluster::NodeId`] which is a `String`.
112pub type NodeId = u64;
113
114// ---------------------------------------------------------------------------
115// CausalEdgeType
116// ---------------------------------------------------------------------------
117
118/// The kind of causal relationship an edge represents.
119#[non_exhaustive]
120#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
121pub enum CausalEdgeType {
122    /// A directly causes B.
123    Causes,
124    /// A suppresses or prevents B.
125    Inhibits,
126    /// A and B are statistically correlated (non-directional semantics,
127    /// but stored in the directed graph for traversal purposes).
128    Correlates,
129    /// A is a precondition that enables B.
130    Enables,
131    /// A temporally follows B.
132    Follows,
133    /// A provides evidence against B.
134    Contradicts,
135    /// Edge was created by a ClawStage trigger.
136    TriggeredBy,
137    /// A provides supporting evidence for B.
138    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// ---------------------------------------------------------------------------
157// CausalEdge
158// ---------------------------------------------------------------------------
159
160/// A weighted, typed directed edge between two causal graph nodes.
161#[derive(Serialize, Deserialize, Debug, Clone)]
162pub struct CausalEdge {
163    /// Source node (tail of the arrow).
164    pub source: NodeId,
165    /// Target node (head of the arrow).
166    pub target: NodeId,
167    /// Semantic type of the relationship.
168    pub edge_type: CausalEdgeType,
169    /// Strength / confidence of the relationship (0.0 .. 1.0 typical).
170    pub weight: f32,
171    /// Hybrid logical clock timestamp at creation.
172    pub timestamp: u64,
173    /// ExoChain sequence number for provenance tracking.
174    pub chain_seq: u64,
175    /// Universal Node ID bytes for the source node.
176    pub source_universal_id: [u8; 32],
177    /// Universal Node ID bytes for the target node.
178    pub target_universal_id: [u8; 32],
179}
180
181// ---------------------------------------------------------------------------
182// CausalNode
183// ---------------------------------------------------------------------------
184
185/// A node in the causal graph representing an event, observation, or concept.
186#[derive(Serialize, Deserialize, Debug, Clone)]
187pub struct CausalNode {
188    /// Local numeric identifier.
189    pub id: NodeId,
190    /// Human-readable label.
191    pub label: String,
192    /// HLC timestamp at creation.
193    pub created_at: u64,
194    /// Arbitrary JSON metadata attached to this node.
195    pub metadata: serde_json::Value,
196}
197
198// ---------------------------------------------------------------------------
199// CausalGraph
200// ---------------------------------------------------------------------------
201
202/// A concurrent directed acyclic graph for causal reasoning.
203///
204/// Internally backed by [`DashMap`] for lock-free concurrent reads and
205/// fine-grained write locking. Edge lists are stored in both forward
206/// (outgoing) and reverse (incoming) adjacency maps for efficient
207/// bidirectional traversal.
208pub 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    /// Incremental undirected connected-component count (WEFT-510).
216    ///
217    /// Guarded by a mutex so concurrent `link`/`add_node` calls serialise
218    /// union-find updates; the graph's DashMap adjacency remains lock-free.
219    components: Mutex<ComponentIndex>,
220    /// Optional chain manager for ExoChain event logging.
221    #[cfg(feature = "exochain")]
222    chain_manager: Option<Arc<crate::chain::ChainManager>>,
223    /// Optional governance engine for gating destructive operations.
224    #[cfg(feature = "exochain")]
225    governance_engine: Option<Arc<crate::governance::GovernanceEngine>>,
226}
227
228impl CausalGraph {
229    /// Create an empty causal graph.
230    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    /// Set the chain manager for ExoChain event logging.
247    #[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    /// Set the governance engine for gating destructive operations.
253    #[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    /// Add a node with an auto-assigned ID.
259    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, // caller may set via metadata; HLC not available here
265            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    /// Retrieve a clone of the node with the given ID.
291    pub fn get_node(&self, id: NodeId) -> Option<CausalNode> {
292        self.nodes.get(&id).map(|r| r.value().clone())
293    }
294
295    /// Mirror a causal-model lifecycle state onto a node's `metadata.state`
296    /// (ADR-062 P0.2).
297    ///
298    /// The L2 session view ([`SessionView`](crate::context_graft::SessionView))
299    /// owns the authoritative per-turn [`NodeState`](crate::context_graft::NodeState);
300    /// this writes the same tag (`state.as_str()`) into the durable causal node so
301    /// the two substrates stay in sync. Returns `false` if the node is unknown.
302    ///
303    /// ## Chain event (ADR-067 P1-graph / WEFT-629)
304    ///
305    /// When a chain manager is attached (`exochain` feature) and the state
306    /// **changes**, emits [`EVENT_KIND_CAUSAL_NODE_STATE`](crate::chain::EVENT_KIND_CAUSAL_NODE_STATE)
307    /// with `{node_id, from, to}` so fold-replay can reconstruct lifecycle
308    /// history. No-op transitions (same state) do not append.
309    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 the DashMap write guard before appending so we never
324                // hold a node lock across the chain mutex.
325                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; // silence unused when exochain is off
341                let _ = changed;
342                true
343            }
344            None => false,
345        }
346    }
347
348    /// Metadata keys that are load-bearing node identity/lifecycle and may
349    /// never be overwritten by a [`Self::merge_node_metadata`] patch.
350    ///
351    /// `state` is owned by [`Self::set_node_state`]; `uid` / `chain_seq` /
352    /// `conv_id` are set once at commit and index the node in every scoped
353    /// projection; `text` is the durable turn content. A patch touching any
354    /// of these is a bug in the caller, so we drop it and warn rather than
355    /// silently corrupt the node.
356    const PROTECTED_METADATA_KEYS: &'static [&'static str] =
357        &["state", "uid", "chain_seq", "text", "conv_id"];
358
359    /// Merge the keys of `patch` into a node's metadata object, in place,
360    /// under the node's existing per-entry lock (Phase B enrichment path).
361    ///
362    /// This is the single mutable-metadata seam on the causal graph. Async
363    /// classification enrichment (design §D4) calls it to overwrite the
364    /// `classification` blob with an `llm`-tier refinement after the turn has
365    /// already committed. Protected identity/lifecycle keys
366    /// ([`Self::PROTECTED_METADATA_KEYS`]) are never clobbered: an attempt to
367    /// patch one is ignored and logged at WARN, not treated as an error.
368    ///
369    /// The merge is deterministic and holds a single lock for the whole patch
370    /// (the same `get_mut` entry lock `set_node_state` takes), so it
371    /// serialises cleanly against a concurrent `set_node_state`: each write
372    /// lands whole, neither can observe a half-applied patch, and both survive.
373    /// Under ADR-067 P1-graph replay this makes each patch one observable
374    /// state-at-T mutation a future snapshot/journal can capture atomically —
375    /// a "classification refined" event, not a torn write.
376    ///
377    /// Returns `false` if the node is unknown. A patch that is entirely
378    /// protected keys still returns `true` (the node exists); it simply
379    /// applies nothing.
380    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                // A non-object metadata value has no keys to merge into; seed a
388                // fresh object so the patch has a home (mirrors set_node_state).
389                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    /// Remove a node and all edges incident to it.
414    ///
415    /// Returns the removed node, or `None` if the ID was not found.
416    pub fn remove_node(&self, id: NodeId) -> Option<CausalNode> {
417        let (_, node) = self.nodes.remove(&id)?;
418
419        // Remove forward edges from this node and update reverse adjacency.
420        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        // Remove reverse edges to this node and update forward adjacency.
431        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        // Edge deletion can split components; rebuild for exact count (WEFT-510).
443        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    /// Create an edge from `source` to `target`.
461    ///
462    /// Returns `false` if either endpoint does not exist.
463    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        // Undirected union for connected-component count (WEFT-510).
496        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    /// Remove all edges between `source` and `target` (in that direction).
518    ///
519    /// Returns the number of edges removed.
520    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            // Unlink can split components; rebuild (WEFT-510).
536            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    /// O(1) undirected connected-component count (WEFT-510).
556    ///
557    /// Maintained incrementally on `add_node`/`link`; rebuilt after removals.
558    /// Prefer this over `connected_components().len()` in hot paths such as
559    /// EML coherence feature extraction.
560    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    /// Rebuild the union-find index from current adjacency (O(n+m)).
568    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            // Drop the lock over get_forward_edges? We hold the mutex already;
579            // DashMap reads don't conflict with our UF lock.
580            for edge in self.get_forward_edges(id) {
581                let _ = idx.union(edge.source, edge.target);
582            }
583        }
584    }
585
586    /// Return all edges originating from `id`.
587    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    /// Return all edges targeting `id`.
595    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    /// Return forward edges from `id` that match `edge_type`.
603    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    /// Number of nodes currently in the graph.
617    pub fn node_count(&self) -> u64 {
618        self.node_count.load(Ordering::SeqCst)
619    }
620
621    /// Number of edges currently in the graph.
622    pub fn edge_count(&self) -> u64 {
623        self.edge_count.load(Ordering::SeqCst)
624    }
625
626    /// Remove all nodes and edges (used during calibration cleanup).
627    ///
628    /// When the `exochain` feature is enabled and a governance engine is
629    /// configured, this operation is gated: it will return
630    /// [`KernelError::GovernanceDenied`] if the governance check fails.
631    pub fn clear(&self) -> Result<(), crate::error::KernelError> {
632        // Governance gate: destructive wipe requires approval.
633        #[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        // Note: next_node_id is intentionally NOT reset so IDs remain unique
665        // across clear cycles.
666
667        #[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    /// BFS traversal forward from `start` up to `depth` hops.
683    ///
684    /// Returns all discovered node IDs (excluding `start`).
685    pub fn traverse_forward(&self, start: NodeId, depth: usize) -> Vec<NodeId> {
686        self.bfs(start, depth, true)
687    }
688
689    /// BFS traversal backward (following reverse edges) from `start`
690    /// up to `depth` hops.
691    ///
692    /// Returns all discovered node IDs (excluding `start`).
693    pub fn traverse_reverse(&self, start: NodeId, depth: usize) -> Vec<NodeId> {
694        self.bfs(start, depth, false)
695    }
696
697    /// Find the shortest path from `from` to `to` using BFS, limited
698    /// to `max_depth` hops.
699    ///
700    /// Returns the node sequence including both endpoints, or `None`
701    /// if no path exists within the depth limit.
702    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        // BFS with parent tracking.
711        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                    // Reconstruct path.
733                    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    /// Trace a typed causal chain from `from` to `to`.
752    ///
753    /// Uses BFS over forward edges (like [`find_path`]) but records the
754    /// edge type at each step and builds a human-readable explanation
755    /// string (e.g., "A --Causes--> B --Enables--> C --EvidenceFor--> D").
756    ///
757    /// Returns `None` if no path exists within `max_depth` hops, or if
758    /// either endpoint is missing from the graph.
759    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        // BFS with parent tracking that records the edge used.
780        let mut visited: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
781        // parent[node] = (previous_node, edge_type, edge_weight)
782        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                    // Reconstruct the chain.
803                    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                    // Build explanation string.
815                    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    // -- private helpers --
844
845    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    /// List all node IDs currently in the graph.
877    pub fn node_ids(&self) -> Vec<NodeId> {
878        self.nodes.iter().map(|r| *r.key()).collect()
879    }
880
881    /// All nodes whose `metadata.conv_id` equals `conv_id`, cloned in full.
882    ///
883    /// The global graph is unbounded (nodes are never removed for the daemon's
884    /// lifetime), so any conversation-scoped projection — the `conversation.graph`
885    /// RPC in particular — MUST filter at the source rather than ship the whole
886    /// graph. Each returned [`CausalNode`] carries its `metadata` intact,
887    /// including the `state` tag written by [`Self::set_node_state`], so a caller
888    /// can read node lifecycle state without a second lookup.
889    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    /// All forward edges internal to a conversation — both endpoints carry
904    /// `metadata.conv_id == conv_id`.
905    ///
906    /// A conversation's lineage (`Follows` edges) is intra-conversation by
907    /// construction, so requiring both endpoints in the conversation drops any
908    /// stray cross-conversation causal edge rather than dangling it against a
909    /// node the scoped projection did not emit.
910    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    /// Degree of a node (in + out edges, treating graph as undirected).
931    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    /// In-degree (number of incoming edges).
938    pub fn in_degree(&self, id: NodeId) -> usize {
939        self.reverse_edges.get(&id).map_or(0, |e| e.len())
940    }
941
942    /// Out-degree (number of outgoing edges).
943    pub fn out_degree(&self, id: NodeId) -> usize {
944        self.forward_edges.get(&id).map_or(0, |e| e.len())
945    }
946
947    // -----------------------------------------------------------------------
948    // Connected Components (undirected)
949    // -----------------------------------------------------------------------
950
951    /// Find connected components treating the graph as undirected.
952    ///
953    /// Returns a vec of components, each component being a vec of node IDs.
954    /// Components are sorted largest-first.
955    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            // BFS over both directions (undirected).
965            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                // Forward neighbors.
973                for edge in self.get_forward_edges(current) {
974                    if visited.insert(edge.target) {
975                        queue.push_back(edge.target);
976                    }
977                }
978                // Reverse neighbors.
979                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    // -----------------------------------------------------------------------
994    // Community Detection (Label Propagation)
995    // -----------------------------------------------------------------------
996
997    /// Detect communities using label propagation on the undirected graph.
998    ///
999    /// Each node starts with its own label. In each iteration, every node
1000    /// adopts the most frequent label among its neighbors (weighted by edge
1001    /// weight). Converges when no labels change, or after `max_iterations`.
1002    ///
1003    /// Returns a map from community label (a NodeId) to the set of node IDs
1004    /// in that community.
1005    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        // Initialize: each node gets its own ID as label.
1012        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            // Process nodes in a deterministic order.
1022            let mut process_order = ids.clone();
1023            process_order.sort();
1024
1025            for &id in &process_order {
1026                // Gather neighbor labels weighted by edge weight.
1027                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; // isolated node keeps its label
1043                }
1044
1045                // Pick the label with the highest total weight.
1046                // On ties, pick the smallest label for determinism.
1047                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)) // smaller ID wins ties
1053                    })
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        // Group nodes by label.
1069        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    // -----------------------------------------------------------------------
1084    // Spectral Analysis
1085    // -----------------------------------------------------------------------
1086
1087    /// Compute the algebraic connectivity (lambda_2) of the graph.
1088    ///
1089    /// Lambda_2 is the second-smallest eigenvalue of the graph Laplacian.
1090    /// - lambda_2 = 0 means the graph is disconnected.
1091    /// - Higher values indicate stronger connectivity.
1092    ///
1093    /// Uses sparse Lanczos iteration at O(k*m) where m = number of edges
1094    /// and k = `max_iterations`. For typical ECC graphs with average degree
1095    /// ~10, this is ~200x faster than the dense O(k*n^2) approach.
1096    ///
1097    /// Returns `(lambda_2, fiedler_vector)` where the Fiedler vector can be
1098    /// used for spectral partitioning (sign of each component indicates which
1099    /// partition the node belongs to).
1100    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        // Build index map: NodeId -> matrix index.
1113        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        // Build sparse adjacency: adj[i] = Vec<(j, weight)> (symmetric).
1122        // Also accumulate degrees.  O(m) space instead of O(n^2).
1123        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            // Forward edges.
1130            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            // Reverse edges — only upper triangle to avoid double-counting.
1142            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        // Fix up degree: recompute from adjacency for correctness (handles
1157        // any double-adds from symmetric storage).
1158        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        // Sparse Laplacian mat-vec: result = L * x = D*x - A*x
1167        let laplacian_mul = |x: &[f64], out: &mut [f64]| {
1168            for i in 0..n {
1169                let mut sum = degree[i] * x[i]; // D*x
1170                for &(j, w) in &adj[i] {
1171                    sum -= w * x[j]; // -A*x
1172                }
1173                out[i] = sum;
1174            }
1175        };
1176
1177        // ── Lanczos iteration ──────────────────────────────────────────
1178        // Builds a k x k tridiagonal matrix T whose eigenvalues approximate
1179        // those of L restricted to the subspace orthogonal to the constant
1180        // (null-space) vector.  We then extract lambda_2 from T.
1181        //
1182        // The Fiedler vector is recovered by mapping the corresponding
1183        // eigenvector of T back through the Lanczos basis.
1184
1185        let inv_sqrt_n = 1.0 / (n as f64).sqrt();
1186
1187        // Initial vector: deterministic, orthogonal to the constant vector.
1188        let mut q: Vec<f64> = (0..n)
1189            .map(|i| (i as f64) - (n as f64 - 1.0) / 2.0)
1190            .collect();
1191
1192        // Project out the constant (null-space) direction.
1193        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); // can't exceed n-1 Lanczos steps
1200        let mut alpha: Vec<f64> = Vec::with_capacity(k); // diagonal of T
1201        let mut beta: Vec<f64> = Vec::with_capacity(k); // sub-diagonal of T
1202        let mut basis: Vec<Vec<f64>> = Vec::with_capacity(k); // Lanczos vectors
1203
1204        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            // w = L * q_j
1211            laplacian_mul(&q, &mut w_buf);
1212
1213            // alpha_j = q_j^T * w
1214            let aj: f64 = q.iter().zip(w_buf.iter()).map(|(a, b)| a * b).sum();
1215            alpha.push(aj);
1216
1217            // w = w - alpha_j * q_j - beta_{j-1} * q_{j-1}
1218            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            // Re-orthogonalize against all previous basis vectors AND the
1224            // constant vector (full reorth for numerical stability).
1225            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                // Invariant subspace found; stop early.
1241                break;
1242            }
1243
1244            // q_{j+1} = w / beta_j
1245            q_prev = q.clone();
1246            q = w_buf.iter().map(|&x| x / bj).collect();
1247        }
1248
1249        // ── Extract eigenvalues from the tridiagonal matrix T ──────────
1250        // Use the implicit-shift QR algorithm on the symmetric tridiagonal
1251        // matrix (alpha, beta).  We only need the smallest eigenvalue of T
1252        // (which approximates lambda_2 since we projected out the null space).
1253        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        // Find the smallest eigenvalue (approximation to lambda_2).
1261        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        // Recover the Fiedler vector: v = Q * s, where Q is the n x m Lanczos
1272        // basis and s is the eigenvector of T corresponding to lambda_2.
1273        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    /// Approximate spectral analysis using Random Fourier Features.
1293    ///
1294    /// O(m) per feature vector -- 3-6x faster than Lanczos on large graphs
1295    /// (>10K nodes) with approximately 5% accuracy loss. Uses random
1296    /// projections to estimate the graph Laplacian's eigenvalues without
1297    /// building an explicit Krylov subspace.
1298    ///
1299    /// # Algorithm
1300    ///
1301    /// 1. Generate `num_features` random Gaussian vectors.
1302    /// 2. For each random vector z, compute L*z (one sparse mat-vec, O(m)).
1303    /// 3. Build a `num_features x num_features` covariance matrix C where
1304    ///    C[i][j] = z_i^T * L * z_j.
1305    /// 4. Eigendecompose C (small dense matrix) to get approximate
1306    ///    eigenvalues of L.
1307    /// 5. Return the second-smallest eigenvalue as lambda_2.
1308    ///
1309    /// # Arguments
1310    ///
1311    /// - `num_features`: Number of random feature vectors (higher = more
1312    ///   accurate, but slower). 64-128 is typical.
1313    /// - `max_iter`: Maximum Jacobi iterations for the small dense eigensolve
1314    ///   (passed through to `tridiag_eigen`-style solver). Typically 200.
1315    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        // Build index map.
1328        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        // Build sparse adjacency + degrees (same as spectral_analysis).
1337        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        // Recompute degree from adjacency for correctness.
1367        for i in 0..n {
1368            degree[i] = adj[i].iter().map(|&(_, w)| w).sum();
1369        }
1370
1371        // Sparse Laplacian mat-vec.
1372        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        // Generate deterministic pseudo-random vectors using a simple LCG.
1386        // We avoid pulling in rand to keep dependencies minimal.
1387        let mut seed: u64 = 0xDEAD_BEEF_CAFE_BABEu64;
1388        let next_gaussian = |seed: &mut u64| -> f64 {
1389            // Box-Muller from two uniform values via LCG.
1390            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        // Step 1: Generate random vectors, project out constant vector, apply L.
1402        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            // Project out the constant (null-space) direction.
1408            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        // Step 2: Build the m x m covariance matrix C[i][j] = z_i^T * L * z_j.
1422        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        // Step 3: Eigendecompose the small m x m matrix.
1436        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        // Use the Jacobi solver on the full matrix (not just tridiagonal).
1444        let (evals, evecs) = dense_jacobi_eigen(&c_mat, m, max_iter);
1445
1446        // Step 4: Sort eigenvalues and find lambda_2 (second smallest).
1447        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        // The smallest eigenvalue should be ~0 (constant vector projected out).
1452        // lambda_2 is the second one.
1453        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        // Step 5: Recover approximate Fiedler vector.
1460        // v_approx = sum_j s[j] * z_j where s is the eigenvector of C for lambda_2.
1461        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    /// Automatic path selection for algebraic connectivity (KG-004 / WEFT-361).
1481    ///
1482    /// Dispatches between Lanczos and RFF using [`select_spectral_method`].
1483    /// When the policy prefers [`SpectralMethod::Eml`] (very large graphs),
1484    /// this still runs RFF for an *exact-path* Fiedler result — EML is an
1485    /// O(1) feature model in [`crate::eml_coherence`] and is not applied
1486    /// here. Callers that want pure EML should check
1487    /// `select_spectral_method` and invoke `EmlCoherenceModel` themselves.
1488    ///
1489    /// Returns `(chosen_method_executed, result)` where `chosen_method_executed`
1490    /// is always `Lanczos` or `Rff` (never `Eml`).
1491    ///
1492    /// # Defaults
1493    ///
1494    /// - Lanczos: `max_iterations = min(50, n-1)`
1495    /// - RFF: `num_features = 64`, Jacobi `max_iter = 50` (matches DEMOCRITUS)
1496    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            // Eml preference still needs a spectral Fiedler vector for
1505            // partition/cycle detectors; RFF is the large-n exact path.
1506            SpectralMethod::Rff | SpectralMethod::Eml => {
1507                (SpectralMethod::Rff, self.spectral_analysis_rff(64, 50))
1508            }
1509        }
1510    }
1511
1512    /// Run a specific spectral method (Lanczos or RFF).
1513    ///
1514    /// [`SpectralMethod::Eml`] is not executable here (needs
1515    /// `EmlCoherenceModel`); it falls back to RFF so callers can pass the
1516    /// policy enum without a second branch.
1517    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    /// Partition the graph into two halves using the Fiedler vector.
1529    ///
1530    /// Nodes with positive Fiedler vector components go to partition A,
1531    /// negative to partition B. This is spectral bisection — the
1532    /// minimum-cut balanced partition.
1533    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    // -----------------------------------------------------------------------
1549    // Predictive Analysis
1550    // -----------------------------------------------------------------------
1551
1552    /// Compute co-modification coupling between nodes based on temporal
1553    /// co-occurrence patterns.
1554    ///
1555    /// Given a list of "change events" (each event is a set of node IDs that
1556    /// changed together, plus a timestamp), computes a coupling score for
1557    /// every pair of nodes that have been modified together.
1558    ///
1559    /// The coupling score for nodes (A, B) is:
1560    ///   coupling = co_changes(A,B) / max(changes(A), changes(B))
1561    ///
1562    /// Returns pairs sorted by coupling score descending.
1563    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            // Count co-occurrences.
1578            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    /// Detect burst patterns in change events and predict which nodes are
1612    /// likely to change next.
1613    ///
1614    /// A "burst" is a period where a node has significantly more changes
1615    /// than its baseline rate. Nodes currently in a burst, or recently
1616    /// co-modified with nodes in a burst, are predicted to change next.
1617    ///
1618    /// `window_size` is the number of recent events to consider for the
1619    /// burst window. `baseline_factor` is the multiplier above which a
1620    /// node's activity is considered a burst (e.g., 2.0 = 2x baseline).
1621    ///
1622    /// Returns nodes sorted by prediction confidence (descending).
1623    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        // Sort events by timestamp.
1634        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        // Compute baseline rate (changes per event across all history).
1641        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        // Compute window rate.
1650        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        // Identify nodes in burst.
1661        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        // Compute coupling to identify co-modification partners.
1673        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        // Score all nodes.
1680        let mut predictions: std::collections::HashMap<NodeId, f64> =
1681            std::collections::HashMap::new();
1682
1683        // Burst nodes get high base confidence.
1684        for &(id, burst_ratio) in &burst_nodes {
1685            *predictions.entry(id).or_insert(0.0) += burst_ratio * 0.6;
1686        }
1687
1688        // Coupled partners of burst nodes get transitive confidence.
1689        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        // Recent activity boost: nodes that appeared in the last few events.
1706        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// ---------------------------------------------------------------------------
1742// Supporting types
1743// ---------------------------------------------------------------------------
1744
1745/// A traced causal chain from source to target through the graph.
1746///
1747/// Each step records the source node, the typed relationship, and the
1748/// target node.  The `explanation` field provides a human-readable
1749/// narrative like "A --Causes--> B --Enables--> C".
1750#[derive(Debug, Clone)]
1751pub struct CausalChain {
1752    /// Sequence of (source, edge_type, target) steps in the chain.
1753    pub path: Vec<(NodeId, CausalEdgeType, NodeId)>,
1754    /// Human-readable narrative of the chain.
1755    pub explanation: String,
1756    /// Sum of edge weights along the chain.
1757    pub total_weight: f32,
1758}
1759
1760/// Result of spectral analysis on the causal graph.
1761#[derive(Debug, Clone)]
1762pub struct SpectralResult {
1763    /// Algebraic connectivity (second-smallest Laplacian eigenvalue).
1764    /// 0.0 means disconnected. Higher = more connected.
1765    pub lambda_2: f64,
1766    /// Fiedler vector — sign indicates spectral partition membership.
1767    pub fiedler_vector: Vec<f64>,
1768    /// Node IDs in the same order as the Fiedler vector.
1769    pub node_ids: Vec<NodeId>,
1770}
1771
1772// ---------------------------------------------------------------------------
1773// KG-004 / WEFT-361: spectral path selection (Lanczos vs RFF vs EML)
1774// ---------------------------------------------------------------------------
1775
1776/// Algorithm used to estimate algebraic connectivity (lambda₂).
1777///
1778/// # Decision rule (default policy)
1779///
1780/// | Node count `n` | Path | Rationale |
1781/// |----------------|------|-----------|
1782/// | `n < 10_000` | [`SpectralMethod::Lanczos`] | Sparse O(k·m) is accurate and cheap enough; preferred when Fiedler quality matters |
1783/// | `10_000 ≤ n < 100_000` | [`SpectralMethod::Rff`] | O(m) random Fourier features; ~3–6× faster than Lanczos with ~5% accuracy loss |
1784/// | `n ≥ 100_000` | [`SpectralMethod::Eml`] | O(1) feature model for tick-rate budgets; re-train from occasional RFF/Lanczos |
1785///
1786/// Thresholds are [`SPECTRAL_RFF_MIN_NODES`] and [`SPECTRAL_EML_MIN_NODES`].
1787/// Override via explicit `spectral_analysis` / `spectral_analysis_rff` /
1788/// `EmlCoherenceModel::predict` when a caller knows better.
1789///
1790/// See `docs/benchmarks/results.md` (WEFT-361) and the
1791/// `spectral_lambda2_bench` harness for measured latency/accuracy.
1792#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1793pub enum SpectralMethod {
1794    /// Sparse Lanczos on the graph Laplacian (reference / small-n path).
1795    Lanczos,
1796    /// Random Fourier Feature Laplacian estimator (large-n exact path).
1797    Rff,
1798    /// O(1) EML coherence model from graph statistics (fast path).
1799    Eml,
1800}
1801
1802impl SpectralMethod {
1803    /// Stable string label for logs and JSON bench output.
1804    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
1813/// Prefer RFF over Lanczos once the graph has this many nodes.
1814///
1815/// Below this threshold Lanczos is the default exact path (Fiedler quality
1816/// and agreement with dense QR). At/above it, RFF is preferred for latency.
1817pub const SPECTRAL_RFF_MIN_NODES: usize = 10_000;
1818
1819/// Prefer pure EML (O(1)) once the graph has this many nodes.
1820///
1821/// At/above this scale even RFF matvecs dominate a sub-ms tick budget;
1822/// DEMOCRITUS-style loops should use EML every tick and only occasionally
1823/// re-anchor with RFF. [`CausalGraph::spectral_analysis_auto`] still falls
1824/// back to RFF when an EML model is not supplied (Fiedler vector required).
1825pub const SPECTRAL_EML_MIN_NODES: usize = 100_000;
1826
1827/// Select the spectral path for a graph of `node_count` nodes.
1828///
1829/// Implements the size-threshold decision rule documented on
1830/// [`SpectralMethod`]. Pure function of `n` — no graph I/O.
1831pub 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/// A temporal change event: a set of nodes that changed together.
1842#[derive(Debug, Clone)]
1843pub struct ChangeEvent {
1844    /// Nodes that changed in this event (e.g., modules modified in a commit).
1845    pub node_ids: Vec<NodeId>,
1846    /// Timestamp of the event.
1847    pub timestamp: u64,
1848}
1849
1850/// Coupling between two nodes based on co-modification frequency.
1851#[derive(Debug, Clone)]
1852pub struct CouplingPair {
1853    /// First node.
1854    pub node_a: NodeId,
1855    /// Second node.
1856    pub node_b: NodeId,
1857    /// Number of times both changed in the same event.
1858    pub co_changes: usize,
1859    /// Coupling score: co_changes / max(changes_a, changes_b).
1860    pub coupling_score: f64,
1861}
1862
1863/// A prediction that a node will change soon.
1864#[derive(Debug, Clone)]
1865pub struct ChangePrediction {
1866    /// Node ID.
1867    pub node_id: NodeId,
1868    /// Human-readable label.
1869    pub label: String,
1870    /// Prediction confidence (0.0 .. 1.0).
1871    pub confidence: f64,
1872    /// Whether this node is currently in a burst pattern.
1873    pub in_burst: bool,
1874    /// Number of changes in the recent window.
1875    pub recent_changes: usize,
1876}
1877
1878/// L2-normalize a vector in place.
1879fn 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
1886/// Compute all eigenvalues and eigenvectors of a symmetric tridiagonal matrix.
1887///
1888/// * `diag` — main diagonal (length m).
1889/// * `off`  — sub-diagonal (length m-1).
1890///
1891/// Returns `(eigenvalues, eigenvectors)` where `eigenvectors[i]` is the
1892/// eigenvector for `eigenvalues[i]`, each of length `m`.
1893///
1894/// Uses the Jacobi eigenvalue algorithm on the full m x m symmetric matrix
1895/// built from the tridiagonal.  Since m is small (Lanczos iteration count,
1896/// typically 20-50), the O(m^3) cost is negligible compared to the O(k*m)
1897/// sparse mat-vecs.
1898fn 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    // Build full symmetric matrix from the tridiagonal.
1907    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    // Eigenvector matrix V (columns are eigenvectors), starts as identity.
1918    let mut v = vec![vec![0.0f64; m]; m];
1919    for i in 0..m {
1920        v[i][i] = 1.0;
1921    }
1922
1923    // Jacobi cyclic sweeps.
1924    for _ in 0..100 * m {
1925        // Find the largest off-diagonal element.
1926        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        // Compute Jacobi rotation angle to zero out a[p][q].
1944        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        // Apply similarity rotation to A.
1950        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        // Accumulate rotation into eigenvector matrix.
1970        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    // Eigenvalues are the diagonal of A.
1979    let eigenvalues: Vec<f64> = (0..m).map(|i| a[i][i]).collect();
1980
1981    // Eigenvectors: column j of V is the eigenvector for eigenvalue j.
1982    // Return as eigenvectors[j] = column j.
1983    let eigenvectors: Vec<Vec<f64>> = (0..m).map(|j| (0..m).map(|i| v[i][j]).collect()).collect();
1984
1985    (eigenvalues, eigenvectors)
1986}
1987
1988/// Jacobi eigenvalue decomposition for a dense symmetric matrix.
1989///
1990/// Like `tridiag_eigen` but accepts an arbitrary symmetric matrix (not
1991/// just tridiagonal). Used by [`CausalGraph::spectral_analysis_rff`] for
1992/// the small `m x m` covariance matrix.
1993///
1994/// Returns `(eigenvalues, eigenvectors)` where `eigenvectors[i]` is the
1995/// eigenvector for `eigenvalues[i]`.
1996fn 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    // Eigenvector matrix V, starts as identity.
2007    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        // Find largest off-diagonal element.
2014        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// ---------------------------------------------------------------------------
2070// Persistence
2071// ---------------------------------------------------------------------------
2072
2073/// Serializable snapshot of a [`CausalGraph`] for JSON persistence.
2074#[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    /// Serialize the entire graph to a JSON writer.
2083    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    /// Deserialize a graph from a JSON reader.
2090    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    /// Save the graph to a file path.
2097    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    /// Load a graph from a file path.
2107    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        // Restore nodes.
2144        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        // Restore edges from forward_edges map.
2154        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// ===========================================================================
2189// KG-009: Geometric Shadowing for Memory Decay (RoMem)
2190// ===========================================================================
2191
2192/// Configuration for geometric shadowing (memory decay).
2193#[derive(Debug, Clone, Serialize, Deserialize)]
2194pub struct ShadowingConfig {
2195    /// Decay rate per epoch (0.0 = no decay, 1.0 = instant decay).
2196    pub decay_rate: f64,
2197    /// How many hops a node's shadow extends.
2198    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
2210/// Per-edge-type volatility: some edge types decay faster than others.
2211/// EML-learned rates; these defaults encode domain knowledge.
2212fn edge_type_volatility(edge_type: &CausalEdgeType) -> f64 {
2213    match edge_type {
2214        // Correlates are ephemeral -- decay fast.
2215        CausalEdgeType::Correlates => 2.0,
2216        // Follows is temporal context -- moderately volatile.
2217        CausalEdgeType::Follows => 1.5,
2218        // EvidenceFor is mid-range.
2219        CausalEdgeType::EvidenceFor => 1.2,
2220        // TriggeredBy is event-driven, somewhat transient.
2221        CausalEdgeType::TriggeredBy => 1.3,
2222        // Contradicts: important to keep visible.
2223        CausalEdgeType::Contradicts => 0.5,
2224        // Causes and Enables are structural -- decay slowly.
2225        CausalEdgeType::Causes => 0.3,
2226        CausalEdgeType::Enables => 0.4,
2227        // Inhibits is a strong structural relationship.
2228        CausalEdgeType::Inhibits => 0.4,
2229    }
2230}
2231
2232/// Maximum edge-type volatility across an iterator of edges, using a
2233/// [`clawft_treecalc::triage`] dispatch on the edge-kind stream so a
2234/// uniform-kind incident set collapses to a single `match` branch
2235/// (Sequence form) instead of one per edge.
2236///
2237/// Behaviour-equivalent to `edges.map(volatility).fold(1.0, max)` —
2238/// the hardcoded fallback semantics — but faster on the common case
2239/// where a node's incident edges are all the same kind (e.g. a
2240/// node touched only by `Causes` edges).
2241///
2242/// NOTE(treecalc-swap): wired — Finding #9 (CausalEdgeType decay
2243/// dispatch). The treecalc batching is the structural change; a
2244/// per-kind learned `DecayScheduleModel` is a follow-up.
2245pub fn max_volatility_batched<'a, I>(edges: I) -> f64
2246where
2247    I: IntoIterator<Item = &'a CausalEdge>,
2248{
2249    // Snapshot the edge-kind stream so we can triage and re-iterate.
2250    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        // All edges share one kind — single match, no per-edge branching.
2257        clawft_treecalc::Form::Sequence => {
2258            // Safe to unwrap: kinds is non-empty by the guard above.
2259            edge_type_volatility(kinds[0]).max(1.0)
2260        }
2261        // Atom is unreachable (we guard non-empty), but treat as the
2262        // identity to keep behaviour aligned with the fallback.
2263        clawft_treecalc::Form::Atom => 1.0,
2264        // Mixed kinds — fold over deduped representatives so each
2265        // kind costs one match branch instead of one per occurrence.
2266        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
2280/// Compute shadow-adjusted relevance for each node in the graph.
2281///
2282/// Recent nodes cast "shadows" that suppress older redundant nodes.
2283/// A node's shadow weight is `decay_rate^(age * volatility)` where age
2284/// is `current_epoch - node.created_at` and volatility is the maximum
2285/// edge-type volatility among the node's incident edges.
2286///
2287/// Nodes within `shadow_radius` hops of a recent (high-weight) node
2288/// have their weight further reduced proportionally, simulating the
2289/// effect of newer evidence making older nearby evidence redundant.
2290///
2291/// Returns a map from NodeId to shadow-adjusted weight in [0.0, 1.0].
2292/// A weight of 0.0 means the node is fully shadowed and can be pruned.
2293pub 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        // No decay or invalid config: all nodes get weight 1.0.
2303        for &id in &node_ids {
2304            weights.insert(id, 1.0);
2305        }
2306        return weights;
2307    }
2308
2309    // Phase 1: Compute base weight from age and edge-type volatility.
2310    //
2311    // NOTE(treecalc-swap): wired — Finding #9. Per-node incident-edge
2312    // dispatch goes through `max_volatility_batched`, which uses
2313    // `clawft_treecalc::triage` so a uniform-kind incident set
2314    // collapses to one match branch. Behaviour matches the previous
2315    // `map(volatility).fold(1.0, max)` formulation exactly.
2316    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        // Geometric decay: weight = (1 - decay_rate)^(age * volatility).
2329        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    // Phase 2: Shadow suppression -- recent (high-weight) nodes suppress
2334    // nearby older nodes within shadow_radius hops.
2335    if config.shadow_radius > 0 {
2336        // Sort nodes by weight descending to process recent nodes first.
2337        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        // For each high-weight node, reduce weights of nearby older nodes.
2341        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; // Only recent nodes cast meaningful shadows.
2346                }
2347
2348                // BFS from caster up to shadow_radius hops.
2349                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                    // Only suppress nodes that are older (lower weight).
2355                    if neighbor_weight < caster_weight {
2356                        // Shadow strength decays with hop distance.
2357                        // Approximate: since traverse returns all within radius,
2358                        // apply a flat suppression factor.
2359                        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// ===========================================================================
2380// KG-014: Codebook Cold-Start (TransFIR)
2381// ===========================================================================
2382
2383/// Vector quantization codebook for bootstrapping new entity embeddings.
2384///
2385/// When a new entity type has no training data for embeddings, the codebook
2386/// provides a reasonable starting vector by mapping entity types to their
2387/// nearest centroid from previously seen embeddings.
2388#[derive(Debug, Clone, Serialize, Deserialize)]
2389pub struct VqCodebook {
2390    /// Centroid vectors (k centroids, each of dimension `dim`).
2391    centroids: Vec<Vec<f32>>,
2392    /// Maps entity type strings to their assigned centroid index.
2393    assignments: std::collections::HashMap<String, usize>,
2394    /// Dimensionality of vectors.
2395    dim: usize,
2396}
2397
2398impl VqCodebook {
2399    /// Create a new codebook with `k` centroids of dimension `dim`.
2400    ///
2401    /// Centroids are initialized to zero and must be trained before use.
2402    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    /// Number of centroids in the codebook.
2411    pub fn k(&self) -> usize {
2412        self.centroids.len()
2413    }
2414
2415    /// Dimensionality of the embedding space.
2416    pub fn dim(&self) -> usize {
2417        self.dim
2418    }
2419
2420    /// Train the codebook from existing embeddings using Lloyd's algorithm.
2421    ///
2422    /// Each embedding is a `(entity_type, vector)` pair. After training,
2423    /// each entity type is assigned to its nearest centroid.
2424    ///
2425    /// Uses K-means++ initialization for better convergence.
2426    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        // K-means++ initialization: pick first centroid randomly (use first
2435        // point), then pick subsequent centroids proportional to distance^2.
2436        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                // More centroids than points: duplicate last used embedding.
2442                self.centroids[ci] = self.centroids[ci - 1].clone();
2443                continue;
2444            }
2445
2446            // Find the embedding farthest from all existing centroids.
2447            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        // Lloyd's algorithm: iterate assignment + update.
2464        let max_lloyd_iterations = 50;
2465        for _iter in 0..max_lloyd_iterations {
2466            // Assignment step: assign each embedding to nearest centroid.
2467            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            // Update step: recompute centroids.
2481            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        // Store final assignments: each entity type -> nearest centroid.
2502        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    /// Look up the codebook vector for an entity type.
2510    ///
2511    /// Returns `None` if the entity type has not been seen during training.
2512    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    /// Quantize a vector to its nearest centroid.
2519    ///
2520    /// Returns `(centroid_index, squared_distance)`.
2521    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    /// Squared Euclidean distance between two vectors.
2535    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        // Treat missing dimensions as 0.
2543        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// ===========================================================================
2554// Tests
2555// ===========================================================================
2556
2557#[cfg(test)]
2558mod tests {
2559    use super::*;
2560
2561    fn make_graph() -> CausalGraph {
2562        CausalGraph::new()
2563    }
2564
2565    // 1
2566    #[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    // 2
2574    #[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    // 3
2584    #[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    // 3b — ADR-062 P0.2: mirror NodeState onto node metadata.
2594    #[test]
2595    fn set_node_state_mirrors_into_metadata() {
2596        let g = make_graph();
2597        // Object metadata: state is inserted alongside existing keys.
2598        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        // Non-object metadata is replaced by a fresh state object.
2607        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        // Unknown node → false.
2612        assert!(!g.set_node_state(99_999, "frontier"));
2613    }
2614
2615    // 3c — Phase B: merge_node_metadata merges patch keys, preserves the rest.
2616    #[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        // Patched key is overwritten wholesale with the refined blob.
2633        assert_eq!(node.metadata["classification"]["tier"], "llm");
2634        assert_eq!(node.metadata["classification"]["intent"], "question");
2635        // Untouched keys survive.
2636        assert_eq!(node.metadata["chain_seq"], 7);
2637
2638        // Unknown node → false.
2639        assert!(!g.merge_node_metadata(99_999, &patch));
2640    }
2641
2642    // 3d — Phase B: protected keys are never clobbered by a patch.
2643    #[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        // A non-protected key rides along in the same patch and must land.
2662        patch.insert("classification".into(), serde_json::json!({ "tier": "llm" }));
2663
2664        // Node exists → true even though every protected key was dropped.
2665        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    // 3e — Phase B: a non-object metadata value is seeded before merge.
2677    #[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    // 3f — Phase B race invariant: concurrent merge_node_metadata +
2691    // set_node_state interleavings preserve BOTH writes. The two share the
2692    // node's per-entry DashMap lock, so `state` (owned by set_node_state) and
2693    // `classification` (owned by the merge) never race to a torn value.
2694    #[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            // Both writers' final effects are present — neither clobbered the
2724            // other, and chain_seq (touched by neither) survived intact.
2725            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    // 4
2733    #[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    // 5
2745    #[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    // 6
2755    #[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    // 7
2764    #[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    // 8
2773    #[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    // 9
2788    #[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    // 10
2800    #[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    // 11
2814    #[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    // 12
2830    #[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    // 13
2843    #[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    // 14
2855    #[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    // 15 — A -> B, traverse 1 hop forward from A
2867    #[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    // 16 — A -> B -> C, traverse 2 hops from A
2878    #[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    // 17 — A -> B -> C, traverse only 1 hop from A (should NOT reach C)
2893    #[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    // 18 — A -> B -> C, traverse reverse from C
2907    #[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    // 19
2921    #[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    // 20
2934    #[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        // No edge between them.
2940        assert!(g.find_path(a, b, 5).is_none());
2941    }
2942
2943    // 21
2944    #[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    // 22
2969    #[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    // =====================================================================
2982    // Degree tests
2983    // =====================================================================
2984
2985    // 23
2986    #[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); // 1 in + 1 out
3002    }
3003
3004    // =====================================================================
3005    // Connected Components tests
3006    // =====================================================================
3007
3008    // 24
3009    #[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    // 25
3021    #[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    /// WEFT-510: incremental count matches BFS components through add/link/unlink.
3037    #[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        // Second edge between same pair must not change count.
3056        g.link(a, b, CausalEdgeType::Enables, 0.5, 0, 0);
3057        assert_eq!(g.component_count_fast(), 1);
3058
3059        // Unlink all a→b edges; a-b-c still connected via a-b? Wait, still one
3060        // edge a→b left if we only unlink once... unlink removes all a→b.
3061        g.unlink(a, b);
3062        // Still connected: a—b? no edges left a-b; path is a? only b-c remains
3063        // a is isolated, b-c connected → 2 components.
3064        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    // 26
3076    #[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); // largest first
3086        assert_eq!(cc[1].len(), 1); // isolated
3087    }
3088
3089    // =====================================================================
3090    // Community Detection tests
3091    // =====================================================================
3092
3093    // 27
3094    #[test]
3095    fn community_detection_two_clusters() {
3096        let g = make_graph();
3097        // Cluster 1: A-B-C strongly connected.
3098        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        // Cluster 2: D-E-F strongly connected.
3106        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        // Weak bridge between clusters.
3114        g.link(c, d, CausalEdgeType::Correlates, 0.1, 0, 0);
3115
3116        let communities = g.detect_communities(20);
3117        // Should find 2 communities (clusters) even with the weak bridge.
3118        // Label propagation may merge them due to the bridge, but the strong
3119        // internal edges should dominate.
3120        assert!(!communities.is_empty());
3121        // At minimum, isolated nodes shouldn't be their own community.
3122        assert!(communities.len() <= 3);
3123    }
3124
3125    // 28
3126    #[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        // Each isolated node stays in its own community.
3133        assert_eq!(communities.len(), 2);
3134    }
3135
3136    // 29
3137    #[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    // =====================================================================
3145    // Spectral Analysis tests
3146    // =====================================================================
3147
3148    // 30
3149    #[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    // 31
3170    #[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        // No edges — disconnected.
3176        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    // 32
3186    #[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        // Two clusters with weak bridge.
3194        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    // 33
3205    #[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    // =====================================================================
3214    // Coupling / Predictive Analysis tests
3215    // =====================================================================
3216
3217    // 34
3218    #[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        // A-B co-changed 2 times out of max(3,3)=3 → 0.67
3248        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    // 35
3258    #[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    // 36
3266    #[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        // History: 50 events. Module A changes rarely (every 10th event).
3274        // Module C fills the rest so there are plenty of events.
3275        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        // Burst window: module A + B change together in every recent event.
3289        for i in 50..60 {
3290            events.push(ChangeEvent {
3291                node_ids: vec![a, b],
3292                timestamp: i,
3293            });
3294        }
3295        // Module A baseline: ~5 changes in ~55 events before window (rate ~0.09).
3296        // Module A window: 10 changes in 10 events (rate 1.0).
3297        // Burst ratio: ~11x — well above 1.5 threshold.
3298
3299        let predictions = g.predict_changes(&events, 10, 1.5);
3300        assert!(!predictions.is_empty());
3301
3302        // Module A should be predicted (in burst).
3303        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        // Module B should be predicted (co-modified with A during burst).
3308        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    // 37
3316    #[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    // 38
3324    #[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    // 39
3336    #[test]
3337    fn spectral_strongly_connected_high_lambda2() {
3338        // A complete graph of 4 nodes should have high lambda_2.
3339        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        // For K4, lambda_2 should be 4.0 (all eigenvalues of Laplacian of K4 are 0,4,4,4).
3352        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    // ── Persistence tests ────────────────────────────────────────────
3360
3361    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    // 40
3372    #[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    // 41
3384    #[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        // Verify node data.
3402        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        // Verify edges.
3410        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    // 42
3427    #[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    // 43
3450    #[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    // 44
3470    #[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        // next_node_id should be 4 now (started at 1, added 3 nodes).
3477
3478        let path = tmp_path("next_id");
3479        g.save_to_file(&path).unwrap();
3480        let loaded = CausalGraph::load_from_file(&path).unwrap();
3481
3482        // Adding a new node should get id >= 4, not collide with existing.
3483        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    // 45
3491    #[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    // 46
3509    #[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        // Reverse edges should be rebuilt from forward edges.
3521        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    // =====================================================================
3528    // Sparse Lanczos vs dense reference comparison
3529    // =====================================================================
3530
3531    /// Dense reference: compute lambda_2 via Jacobi eigendecomposition of the
3532    /// full Laplacian.  O(n^3) — only used in tests for correctness checking.
3533    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        // Jacobi eigendecomposition of the full Laplacian.
3577        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        // Collect eigenvalues, sort, return second-smallest.
3618        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    // 47
3624    #[test]
3625    fn spectral_lanczos_matches_dense_triangle() {
3626        // Triangle graph (K3): known lambda_2 = 3.0 for unit weights.
3627        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        // Both should be close to 3.0 for K3 with symmetric unit edges.
3645        assert!(
3646            sparse_result.lambda_2 > 1.0,
3647            "lambda_2 should be > 1 for K3"
3648        );
3649    }
3650
3651    // 48
3652    #[test]
3653    fn spectral_lanczos_matches_dense_path() {
3654        // Path graph: A - B - C - D (lambda_2 ~ 0.586 for unit weights).
3655        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    // 49
3680    #[test]
3681    fn spectral_lanczos_matches_dense_k4() {
3682        // K4: lambda_2 = 4.0 for unit-weight complete graph on 4 nodes.
3683        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    // 50
3705    #[test]
3706    fn spectral_lanczos_disconnected() {
3707        // Two isolated nodes — lambda_2 should be 0.
3708        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    // ===================================================================
3721    // KG-003: Causal Chain Tracing
3722    // ===================================================================
3723
3724    // 51
3725    #[test]
3726    fn trace_chain_simple_linear() {
3727        // A --Causes--> B --Enables--> C
3728        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    // 52
3747    #[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    // 53
3760    #[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        // No edge between A and B.
3766        assert!(g.trace_causal_chain(a, b, 10).is_none());
3767    }
3768
3769    // 54
3770    #[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    // 55
3779    #[test]
3780    fn trace_chain_depth_limit() {
3781        // A -> B -> C -> D, but max_depth=2 cannot reach D from A.
3782        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    // 56
3796    #[test]
3797    fn trace_chain_picks_shortest() {
3798        // A -> B -> D (len 2) and A -> C -> X -> D (len 3).
3799        // BFS should find the shorter path.
3800        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    // 57
3817    #[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    // ===================================================================
3834    // KG-004: Random Fourier Feature Spectral Analysis
3835    // ===================================================================
3836
3837    // 58
3838    #[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    // 59
3847    #[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    // 60
3861    #[test]
3862    fn rff_path_graph_positive_lambda2() {
3863        // Path: A -- B -- C -- D. lambda_2 should be positive.
3864        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    // 61
3883    #[test]
3884    fn rff_agrees_with_lanczos_k4() {
3885        // K4: lambda_2 = 4.0 for unit-weight complete graph on 4 nodes.
3886        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        // RFF should be within ~50% of Lanczos for a small graph.
3900        // (RFF is designed for large graphs; small graphs have higher variance.)
3901        assert!(
3902            rff.lambda_2 > 0.5,
3903            "K4 RFF lambda_2 should be significantly positive, got {}",
3904            rff.lambda_2
3905        );
3906        // Both should be in the same ballpark.
3907        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    // 62
3918    #[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    // 63
3930    #[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    // ===================================================================
3955    // WEFT-361 / KG-004: spectral path dispatcher (CI-safe, small-n)
3956    // ===================================================================
3957
3958    #[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        // Path of 8 nodes → well below SPECTRAL_RFF_MIN_NODES.
3994        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        // Path of 8 nodes — dense reference available via dense_spectral_lambda2.
4017        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        // We cannot build a 10K graph in unit tests (CI time), but the
4053        // policy function is pure — auto uses the same thresholds.
4054        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        // Auto maps Eml policy → Rff execution for Fiedler recovery.
4063        assert_eq!(
4064            select_spectral_method(SPECTRAL_EML_MIN_NODES),
4065            SpectralMethod::Eml
4066        );
4067    }
4068
4069    // -- KG-009: Geometric Shadowing tests ---------------------------------
4070
4071    #[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        // Node with created_at=0 (old) and created_at=90 (recent).
4091        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        // Manually set created_at via metadata workaround: directly modify nodes.
4095        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        // a->b via Causes (low volatility), a->c via Correlates (high volatility).
4125        g.link(a, b, CausalEdgeType::Causes, 1.0, 0, 0);
4126        g.link(a, c, CausalEdgeType::Correlates, 1.0, 0, 0);
4127
4128        // Both b and c have same age (created_at=0).
4129        let config = ShadowingConfig {
4130            decay_rate: 0.05,
4131            shadow_radius: 0, // no shadow suppression, just age decay
4132        };
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        // Node connected via Correlates should decay faster.
4138        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    // -- Finding #9: treecalc-batched edge volatility ----------------------
4177
4178    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        // Mirrors the original `fold(1.0, max)` identity.
4201        assert_eq!(max_volatility_batched(std::iter::empty()), 1.0);
4202    }
4203
4204    #[test]
4205    fn batched_max_volatility_uniform_kind_matches_naive() {
4206        // Sequence form — all edges share one kind.
4207        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        // Causes has volatility 0.3, but the fold floor is 1.0.
4216        assert_eq!(batched, 1.0);
4217    }
4218
4219    #[test]
4220    fn batched_max_volatility_mixed_kinds_matches_naive() {
4221        // Branch form — heterogeneous kinds.
4222        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        // Correlates volatility (2.0) wins.
4232        assert_eq!(batched, 2.0);
4233    }
4234
4235    #[test]
4236    fn batched_max_volatility_high_volatility_uniform() {
4237        // Sequence form for a kind whose volatility > 1.0.
4238        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        // Branch form with repeats — should still match the naive
4248        // fold because we max over the unique-kind volatilities.
4249        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        // High-level: compute_shadows with mixed-kind edges still
4265        // produces in-range weights and Correlates edges decay faster
4266        // than Causes — the original `shadowing_correlates_decay_faster`
4267        // invariant remains intact under the treecalc dispatch.
4268        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    // -- KG-014: VQ Codebook Cold-Start tests ------------------------------
4289
4290    #[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        // After training, Function and Module should map to different centroids.
4326        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        // Centroids should be near the cluster means.
4332        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(&[]); // should not panic
4355        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        // Shorter vector should be padded with zeros conceptually.
4370        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}