Skip to main content

ipfrs_tensorlogic/
causal_chain_tracer.rs

1//! Causal Chain Tracer — production-quality causal chain tracing for event sequences.
2//!
3//! This module provides:
4//! - Directed causal graph with configurable relation types
5//! - BFS/DFS chain tracing with depth, strength, time-window, and relation filters
6//! - Shortest-path (hop count) and strongest-path (max product of edge strengths)
7//! - Root-cause identification and downstream-effect enumeration
8//! - DFS-based cycle detection on edge insertion
9
10use std::collections::{HashMap, HashSet, VecDeque};
11
12// ---------------------------------------------------------------------------
13// Error type
14// ---------------------------------------------------------------------------
15
16/// Errors produced by [`CausalChainTracer`].
17#[derive(Debug, Clone, PartialEq)]
18pub enum TracerError {
19    /// A node ID was referenced but does not exist in the graph.
20    NodeNotFound(String),
21    /// Adding an edge would create a cycle.
22    CycleDetected {
23        /// Ordered sequence of node IDs that forms the cycle.
24        path: Vec<String>,
25    },
26    /// The query would require exploring more nodes than allowed.
27    QueryTooExpensive(usize),
28    /// An edge strength value was outside `[0.0, 1.0]`.
29    InvalidStrength(f64),
30    /// A traversal exceeded the configured maximum depth.
31    MaxDepthExceeded,
32}
33
34impl std::fmt::Display for TracerError {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        match self {
37            Self::NodeNotFound(id) => write!(f, "node not found: {id}"),
38            Self::CycleDetected { path } => write!(f, "cycle detected: {}", path.join(" -> ")),
39            Self::QueryTooExpensive(n) => write!(f, "query too expensive: {n} nodes"),
40            Self::InvalidStrength(s) => write!(f, "invalid strength {s}: must be in [0.0, 1.0]"),
41            Self::MaxDepthExceeded => write!(f, "max depth exceeded"),
42        }
43    }
44}
45
46impl std::error::Error for TracerError {}
47
48// ---------------------------------------------------------------------------
49// CausalRelation
50// ---------------------------------------------------------------------------
51
52/// Semantic relationship carried by a [`CausalEdge`].
53#[derive(Debug, Clone, PartialEq, Eq, Hash)]
54pub enum CausalRelation {
55    /// A directly causes B.
56    DirectCause,
57    /// A indirectly causes B (via one or more intermediaries).
58    IndirectCause,
59    /// A enables B to occur.
60    Enables,
61    /// A inhibits B from occurring.
62    Inhibits,
63    /// A and B are correlated but neither directly causes the other.
64    Correlates,
65    /// A temporally precedes B without a strict causal link.
66    Precedes,
67}
68
69// ---------------------------------------------------------------------------
70// Core node/edge types
71// ---------------------------------------------------------------------------
72
73/// A node in the causal graph, representing a single event.
74#[derive(Debug, Clone)]
75pub struct CausalNode {
76    /// Unique identifier for this event.
77    pub id: String,
78    /// Category/type label for this event.
79    pub event_type: String,
80    /// Microsecond timestamp at which this event occurred.
81    pub timestamp: u64,
82    /// Arbitrary key-value metadata.
83    pub attributes: Vec<(String, String)>,
84    /// Confidence that this event occurred as described, in `[0.0, 1.0]`.
85    pub confidence: f64,
86}
87
88impl CausalNode {
89    /// Convenience constructor.
90    pub fn new(
91        id: impl Into<String>,
92        event_type: impl Into<String>,
93        timestamp: u64,
94        confidence: f64,
95    ) -> Self {
96        Self {
97            id: id.into(),
98            event_type: event_type.into(),
99            timestamp,
100            attributes: Vec::new(),
101            confidence,
102        }
103    }
104
105    /// Builder: attach a key-value attribute.
106    pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
107        self.attributes.push((key.into(), value.into()));
108        self
109    }
110}
111
112/// A directed edge between two nodes in the causal graph.
113#[derive(Debug, Clone)]
114pub struct CausalEdge {
115    /// Source node ID.
116    pub from_id: String,
117    /// Destination node ID.
118    pub to_id: String,
119    /// Semantic relationship.
120    pub relation: CausalRelation,
121    /// Strength of this causal link, in `[0.0, 1.0]`.
122    pub strength: f64,
123    /// Typical delay in microseconds between cause and effect.
124    pub delay_us: u64,
125}
126
127impl CausalEdge {
128    /// Convenience constructor.
129    pub fn new(
130        from_id: impl Into<String>,
131        to_id: impl Into<String>,
132        relation: CausalRelation,
133        strength: f64,
134        delay_us: u64,
135    ) -> Self {
136        Self {
137            from_id: from_id.into(),
138            to_id: to_id.into(),
139            relation,
140            strength,
141            delay_us,
142        }
143    }
144}
145
146// ---------------------------------------------------------------------------
147// CausalChain — a complete traced path
148// ---------------------------------------------------------------------------
149
150/// A traced causal chain from a root event to one or more leaf events.
151#[derive(Debug, Clone)]
152pub struct CausalChain {
153    /// All nodes participating in the chain (ordered by traversal).
154    pub nodes: Vec<CausalNode>,
155    /// All edges in traversal order.
156    pub edges: Vec<CausalEdge>,
157    /// The starting node ID.
158    pub root_id: String,
159    /// Terminal node IDs (no outgoing edges within the chain).
160    pub leaf_ids: Vec<String>,
161    /// Product of all edge strengths along the chain; 1.0 for a single node.
162    pub chain_confidence: f64,
163    /// Maximum hop depth of the chain.
164    pub depth: usize,
165}
166
167// ---------------------------------------------------------------------------
168// TraceQuery
169// ---------------------------------------------------------------------------
170
171/// Query parameters passed to [`CausalChainTracer::trace`].
172#[derive(Debug, Clone)]
173pub struct TraceQuery {
174    /// If `Some`, start traversal from this node; otherwise start from all roots.
175    pub root_event_id: Option<String>,
176    /// If non-empty, only include nodes whose `event_type` is in this list.
177    pub event_types: Vec<String>,
178    /// Maximum traversal depth (0 = root only).
179    pub max_depth: usize,
180    /// Minimum edge strength to follow.
181    pub min_strength: f64,
182    /// Optional `[start_us, end_us]` time window (inclusive).
183    pub time_window_us: Option<(u64, u64)>,
184    /// If non-empty, only traverse edges whose relation is in this list.
185    pub include_relations: Vec<CausalRelation>,
186}
187
188impl Default for TraceQuery {
189    fn default() -> Self {
190        Self {
191            root_event_id: None,
192            event_types: Vec::new(),
193            max_depth: 16,
194            min_strength: 0.0,
195            time_window_us: None,
196            include_relations: Vec::new(),
197        }
198    }
199}
200
201// ---------------------------------------------------------------------------
202// TracerConfig
203// ---------------------------------------------------------------------------
204
205/// Configuration for a [`CausalChainTracer`] instance.
206#[derive(Debug, Clone)]
207pub struct TracerConfig {
208    /// Hard limit on chain depth during tracing.
209    pub max_chain_depth: usize,
210    /// Minimum edge strength to index.
211    pub min_edge_strength: f64,
212    /// Maximum number of nodes the graph may hold.
213    pub max_nodes: usize,
214    /// When `true`, adding an edge triggers cycle detection.
215    pub enable_cycle_detection: bool,
216    /// Only return chains whose `chain_confidence` meets this threshold.
217    pub confidence_threshold: f64,
218}
219
220impl Default for TracerConfig {
221    fn default() -> Self {
222        Self {
223            max_chain_depth: 32,
224            min_edge_strength: 0.0,
225            max_nodes: 100_000,
226            enable_cycle_detection: true,
227            confidence_threshold: 0.0,
228        }
229    }
230}
231
232// ---------------------------------------------------------------------------
233// TracerStats
234// ---------------------------------------------------------------------------
235
236/// Aggregate statistics for a [`CausalChainTracer`].
237#[derive(Debug, Clone, Default)]
238pub struct TracerStats {
239    /// Number of nodes currently tracked.
240    pub nodes_tracked: usize,
241    /// Number of edges currently tracked.
242    pub edges_tracked: usize,
243    /// Total number of `trace()` calls completed.
244    pub chains_traced: usize,
245    /// Running average of chain depth across all traced chains.
246    pub avg_chain_depth: f64,
247    /// Number of cycle-detection rejections since creation.
248    pub cycles_detected: usize,
249}
250
251// ---------------------------------------------------------------------------
252// Internal adjacency helpers
253// ---------------------------------------------------------------------------
254
255/// Lightweight edge reference stored in the adjacency list.
256#[derive(Debug, Clone)]
257struct EdgeRef {
258    to: String,
259    edge_index: usize,
260}
261
262// ---------------------------------------------------------------------------
263// CausalChainTracer
264// ---------------------------------------------------------------------------
265
266/// Production-quality causal chain tracer for event sequences.
267///
268/// Maintains a directed graph of [`CausalNode`]s connected by [`CausalEdge`]s and
269/// exposes high-level methods for chain tracing, path finding, root-cause
270/// identification, and downstream-effect enumeration.
271pub struct CausalChainTracer {
272    config: TracerConfig,
273    nodes: HashMap<String, CausalNode>,
274    edges: Vec<CausalEdge>,
275    /// Forward adjacency: node_id -> list of outgoing edge refs.
276    adj_out: HashMap<String, Vec<EdgeRef>>,
277    /// Backward adjacency: node_id -> list of incoming node IDs.
278    adj_in: HashMap<String, Vec<String>>,
279    stats: TracerStats,
280}
281
282impl CausalChainTracer {
283    // ------------------------------------------------------------------
284    // Construction
285    // ------------------------------------------------------------------
286
287    /// Create a new tracer with the given configuration.
288    pub fn new(config: TracerConfig) -> Self {
289        Self {
290            config,
291            nodes: HashMap::new(),
292            edges: Vec::new(),
293            adj_out: HashMap::new(),
294            adj_in: HashMap::new(),
295            stats: TracerStats::default(),
296        }
297    }
298
299    /// Create a tracer with default configuration.
300    pub fn with_defaults() -> Self {
301        Self::new(TracerConfig::default())
302    }
303
304    // ------------------------------------------------------------------
305    // Mutation
306    // ------------------------------------------------------------------
307
308    /// Insert a node into the graph.
309    ///
310    /// Returns [`TracerError::QueryTooExpensive`] when `max_nodes` would be exceeded.
311    pub fn add_node(&mut self, node: CausalNode) -> Result<(), TracerError> {
312        if self.nodes.len() >= self.config.max_nodes && !self.nodes.contains_key(&node.id) {
313            return Err(TracerError::QueryTooExpensive(self.nodes.len()));
314        }
315        // Ensure adjacency lists exist even if there are no edges yet.
316        self.adj_out.entry(node.id.clone()).or_default();
317        self.adj_in.entry(node.id.clone()).or_default();
318        self.nodes.insert(node.id.clone(), node);
319        self.stats.nodes_tracked = self.nodes.len();
320        Ok(())
321    }
322
323    /// Insert an edge into the graph.
324    ///
325    /// Validates:
326    /// - Both endpoints must exist.
327    /// - `strength` must be in `[0.0, 1.0]`.
328    /// - When `enable_cycle_detection` is set, the edge must not introduce a cycle.
329    pub fn add_edge(&mut self, edge: CausalEdge) -> Result<(), TracerError> {
330        if !self.nodes.contains_key(&edge.from_id) {
331            return Err(TracerError::NodeNotFound(edge.from_id.clone()));
332        }
333        if !self.nodes.contains_key(&edge.to_id) {
334            return Err(TracerError::NodeNotFound(edge.to_id.clone()));
335        }
336        if edge.strength < 0.0 || edge.strength > 1.0 {
337            return Err(TracerError::InvalidStrength(edge.strength));
338        }
339
340        if self.config.enable_cycle_detection {
341            // After adding from->to, a cycle exists iff `from` is reachable from `to`.
342            if let Some(cycle_path) = self.find_cycle_path(&edge.to_id, &edge.from_id) {
343                self.stats.cycles_detected += 1;
344                // Prepend from_id so the path reads: from -> to -> ... -> from
345                let mut full_path = vec![edge.from_id.clone()];
346                full_path.extend(cycle_path);
347                return Err(TracerError::CycleDetected { path: full_path });
348            }
349        }
350
351        let edge_index = self.edges.len();
352        let from = edge.from_id.clone();
353        let to = edge.to_id.clone();
354
355        self.adj_out.entry(from.clone()).or_default().push(EdgeRef {
356            to: to.clone(),
357            edge_index,
358        });
359
360        self.adj_in.entry(to).or_default().push(from);
361
362        self.edges.push(edge);
363        self.stats.edges_tracked = self.edges.len();
364        Ok(())
365    }
366
367    /// Remove a node and all edges that touch it.
368    pub fn remove_node(&mut self, id: &str) -> Result<(), TracerError> {
369        if !self.nodes.contains_key(id) {
370            return Err(TracerError::NodeNotFound(id.to_string()));
371        }
372
373        // Collect edge indices to remove.
374        let edge_indices_to_remove: HashSet<usize> = self
375            .edges
376            .iter()
377            .enumerate()
378            .filter(|(_, e)| e.from_id == id || e.to_id == id)
379            .map(|(i, _)| i)
380            .collect();
381
382        // Rebuild edge list, shifting adjacency indices.
383        let mut new_edges: Vec<CausalEdge> = Vec::new();
384        let mut old_to_new: HashMap<usize, usize> = HashMap::new();
385        for (old_idx, edge) in self.edges.drain(..).enumerate() {
386            if !edge_indices_to_remove.contains(&old_idx) {
387                old_to_new.insert(old_idx, new_edges.len());
388                new_edges.push(edge);
389            }
390        }
391        self.edges = new_edges;
392
393        // Rebuild adjacency maps entirely.
394        self.adj_out.clear();
395        self.adj_in.clear();
396        self.nodes.remove(id);
397
398        for nid in self.nodes.keys() {
399            self.adj_out.entry(nid.clone()).or_default();
400            self.adj_in.entry(nid.clone()).or_default();
401        }
402        for (i, edge) in self.edges.iter().enumerate() {
403            self.adj_out
404                .entry(edge.from_id.clone())
405                .or_default()
406                .push(EdgeRef {
407                    to: edge.to_id.clone(),
408                    edge_index: i,
409                });
410            self.adj_in
411                .entry(edge.to_id.clone())
412                .or_default()
413                .push(edge.from_id.clone());
414        }
415
416        self.stats.nodes_tracked = self.nodes.len();
417        self.stats.edges_tracked = self.edges.len();
418        Ok(())
419    }
420
421    // ------------------------------------------------------------------
422    // Tracing
423    // ------------------------------------------------------------------
424
425    /// Trace causal chains according to `query`.
426    ///
427    /// Each returned [`CausalChain`] is a root-to-leaf path in the graph that
428    /// satisfies all query constraints and whose `chain_confidence` meets the
429    /// configured `confidence_threshold`.
430    pub fn trace(&mut self, query: &TraceQuery) -> Result<Vec<CausalChain>, TracerError> {
431        let roots = self.resolve_roots(query)?;
432        let max_depth = query.max_depth.min(self.config.max_chain_depth);
433
434        let mut all_chains: Vec<CausalChain> = Vec::new();
435
436        for root_id in &roots {
437            // Only consider the root if it passes the event_type / time_window filters.
438            let root_node = self
439                .nodes
440                .get(root_id)
441                .ok_or_else(|| TracerError::NodeNotFound(root_id.clone()))?;
442
443            if !self.node_passes_query(root_node, query) {
444                continue;
445            }
446
447            // DFS stack: (current_id, path_of_node_ids, path_of_edge_indices, confidence)
448            let mut stack: Vec<(String, Vec<String>, Vec<usize>, f64)> =
449                vec![(root_id.clone(), vec![root_id.clone()], Vec::new(), 1.0_f64)];
450
451            while let Some((current_id, node_path, edge_path, confidence)) = stack.pop() {
452                let depth = node_path.len() - 1;
453                let out_edges = self.adj_out.get(&current_id).cloned().unwrap_or_default();
454
455                // Collect edges that pass filters.
456                let valid_next: Vec<(String, usize, f64)> = out_edges
457                    .iter()
458                    .filter_map(|eref| {
459                        let edge = &self.edges[eref.edge_index];
460                        // Strength filter.
461                        if edge.strength < query.min_strength {
462                            return None;
463                        }
464                        // Relation filter.
465                        if !query.include_relations.is_empty()
466                            && !query.include_relations.contains(&edge.relation)
467                        {
468                            return None;
469                        }
470                        // Target node filters.
471                        let target = self.nodes.get(&eref.to)?;
472                        if !self.node_passes_query(target, query) {
473                            return None;
474                        }
475                        // Avoid revisiting nodes already in the current path (simple path).
476                        if node_path.contains(&eref.to) {
477                            return None;
478                        }
479                        Some((eref.to.clone(), eref.edge_index, edge.strength))
480                    })
481                    .collect();
482
483                let is_leaf = valid_next.is_empty() || depth >= max_depth;
484
485                if is_leaf {
486                    // Emit this chain if confidence threshold is met.
487                    if confidence >= self.config.confidence_threshold {
488                        let chain = self.build_chain(&node_path, &edge_path, confidence)?;
489                        all_chains.push(chain);
490                    }
491                } else {
492                    for (next_id, edge_idx, strength) in valid_next {
493                        let mut new_node_path = node_path.clone();
494                        new_node_path.push(next_id.clone());
495                        let mut new_edge_path = edge_path.clone();
496                        new_edge_path.push(edge_idx);
497                        let new_confidence = confidence * strength;
498                        stack.push((next_id, new_node_path, new_edge_path, new_confidence));
499                    }
500                }
501            }
502        }
503
504        // Update stats.
505        self.stats.chains_traced += all_chains.len();
506        if !all_chains.is_empty() {
507            let total_depth: usize = all_chains.iter().map(|c| c.depth).sum();
508            self.stats.avg_chain_depth = total_depth as f64 / all_chains.len() as f64;
509        }
510
511        Ok(all_chains)
512    }
513
514    // ------------------------------------------------------------------
515    // Path finding
516    // ------------------------------------------------------------------
517
518    /// Return the shortest path (fewest hops) from `from` to `to`, or `None`.
519    pub fn shortest_path(&self, from: &str, to: &str) -> Result<Option<Vec<String>>, TracerError> {
520        if !self.nodes.contains_key(from) {
521            return Err(TracerError::NodeNotFound(from.to_string()));
522        }
523        if !self.nodes.contains_key(to) {
524            return Err(TracerError::NodeNotFound(to.to_string()));
525        }
526        if from == to {
527            return Ok(Some(vec![from.to_string()]));
528        }
529
530        // BFS.
531        let mut visited: HashSet<String> = HashSet::new();
532        // Queue entries: (current_id, path_so_far)
533        let mut queue: VecDeque<(String, Vec<String>)> = VecDeque::new();
534        queue.push_back((from.to_string(), vec![from.to_string()]));
535        visited.insert(from.to_string());
536
537        while let Some((current, path)) = queue.pop_front() {
538            let out_edges = match self.adj_out.get(&current) {
539                Some(v) => v.clone(),
540                None => continue,
541            };
542            for eref in &out_edges {
543                if visited.contains(&eref.to) {
544                    continue;
545                }
546                let mut new_path = path.clone();
547                new_path.push(eref.to.clone());
548                if eref.to == to {
549                    return Ok(Some(new_path));
550                }
551                visited.insert(eref.to.clone());
552                queue.push_back((eref.to.clone(), new_path));
553            }
554        }
555
556        Ok(None)
557    }
558
559    /// Return the path from `from` to `to` that maximises the product of edge
560    /// strengths (i.e., the "strongest" causal chain).
561    ///
562    /// Uses Dijkstra's algorithm on the negated log strengths so that path
563    /// score = sum(-ln(strength_i)), and we minimise it.  A strength of 0 is
564    /// treated as –∞ (the path is ignored).
565    pub fn strongest_path(&self, from: &str, to: &str) -> Result<Option<CausalChain>, TracerError> {
566        if !self.nodes.contains_key(from) {
567            return Err(TracerError::NodeNotFound(from.to_string()));
568        }
569        if !self.nodes.contains_key(to) {
570            return Err(TracerError::NodeNotFound(to.to_string()));
571        }
572
573        if from == to {
574            let node = self
575                .nodes
576                .get(from)
577                .ok_or_else(|| TracerError::NodeNotFound(from.to_string()))?
578                .clone();
579            return Ok(Some(CausalChain {
580                nodes: vec![node],
581                edges: Vec::new(),
582                root_id: from.to_string(),
583                leaf_ids: vec![from.to_string()],
584                chain_confidence: 1.0,
585                depth: 0,
586            }));
587        }
588
589        // --- Dijkstra on -ln(strength) ---
590        // dist[id] = (neg_log_product, predecessor, edge_index_used)
591        let mut dist: HashMap<String, (f64, Option<String>, Option<usize>)> = HashMap::new();
592        // Use a simple priority queue via BinaryHeap with ordered f64.
593        use std::cmp::Reverse;
594        use std::collections::BinaryHeap;
595
596        // Wrap f64 in a Newtype that implements Ord.
597        #[derive(PartialEq)]
598        struct OrdF64(f64);
599        impl Eq for OrdF64 {}
600        impl PartialOrd for OrdF64 {
601            fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
602                Some(self.cmp(other))
603            }
604        }
605        impl Ord for OrdF64 {
606            fn cmp(&self, other: &Self) -> std::cmp::Ordering {
607                self.0
608                    .partial_cmp(&other.0)
609                    .unwrap_or(std::cmp::Ordering::Equal)
610            }
611        }
612
613        // Heap entries: (Reverse(cost), node_id)
614        let mut heap: BinaryHeap<(Reverse<OrdF64>, String)> = BinaryHeap::new();
615
616        dist.insert(from.to_string(), (0.0, None, None));
617        heap.push((Reverse(OrdF64(0.0)), from.to_string()));
618
619        while let Some((Reverse(OrdF64(cost)), current)) = heap.pop() {
620            // Skip stale entries.
621            let recorded_cost = dist.get(&current).map(|e| e.0).unwrap_or(f64::INFINITY);
622            if cost > recorded_cost + f64::EPSILON {
623                continue;
624            }
625
626            if current == to {
627                break;
628            }
629
630            let out_edges = match self.adj_out.get(&current) {
631                Some(v) => v.clone(),
632                None => continue,
633            };
634
635            for eref in &out_edges {
636                let edge = &self.edges[eref.edge_index];
637                if edge.strength <= 0.0 {
638                    // Zero-strength edges block the path.
639                    continue;
640                }
641                let new_cost = cost + (-edge.strength.ln());
642                let existing = dist.get(&eref.to).map(|e| e.0).unwrap_or(f64::INFINITY);
643                if new_cost < existing - f64::EPSILON {
644                    dist.insert(
645                        eref.to.clone(),
646                        (new_cost, Some(current.clone()), Some(eref.edge_index)),
647                    );
648                    heap.push((Reverse(OrdF64(new_cost)), eref.to.clone()));
649                }
650            }
651        }
652
653        // Reconstruct path.
654        if !dist.contains_key(to) || dist[to].1.is_none() && to != from {
655            return Ok(None);
656        }
657
658        let mut node_ids: Vec<String> = Vec::new();
659        let mut edge_indices: Vec<usize> = Vec::new();
660        let mut cursor = to.to_string();
661
662        loop {
663            node_ids.push(cursor.clone());
664            let entry = match dist.get(&cursor) {
665                Some(e) => e.clone(),
666                None => break,
667            };
668            if let Some(ei) = entry.2 {
669                edge_indices.push(ei);
670            }
671            match entry.1 {
672                Some(pred) => cursor = pred,
673                None => break,
674            }
675        }
676
677        node_ids.reverse();
678        edge_indices.reverse();
679
680        let chain = self.build_chain(
681            &node_ids,
682            &edge_indices,
683            self.product_of_edges(&edge_indices),
684        )?;
685        Ok(Some(chain))
686    }
687
688    // ------------------------------------------------------------------
689    // Analysis
690    // ------------------------------------------------------------------
691
692    /// Return all nodes with no incoming edges that can reach `event_id`.
693    pub fn root_causes(&self, event_id: &str) -> Result<Vec<CausalNode>, TracerError> {
694        if !self.nodes.contains_key(event_id) {
695            return Err(TracerError::NodeNotFound(event_id.to_string()));
696        }
697
698        // BFS backwards from event_id.
699        let mut visited: HashSet<String> = HashSet::new();
700        let mut queue: VecDeque<String> = VecDeque::new();
701        queue.push_back(event_id.to_string());
702        visited.insert(event_id.to_string());
703
704        while let Some(current) = queue.pop_front() {
705            let parents = self.adj_in.get(&current).cloned().unwrap_or_default();
706            for parent in parents {
707                if !visited.contains(&parent) {
708                    visited.insert(parent.clone());
709                    queue.push_back(parent);
710                }
711            }
712        }
713
714        // Filter to nodes with no incoming edges among those reachable.
715        let mut roots: Vec<CausalNode> = Vec::new();
716        for node_id in &visited {
717            if node_id == event_id {
718                continue;
719            }
720            let has_incoming = self
721                .adj_in
722                .get(node_id)
723                .map(|v| !v.is_empty())
724                .unwrap_or(false);
725            if !has_incoming {
726                if let Some(n) = self.nodes.get(node_id) {
727                    roots.push(n.clone());
728                }
729            }
730        }
731        Ok(roots)
732    }
733
734    /// Return all nodes reachable from `event_id` via BFS up to `depth` hops.
735    pub fn downstream_effects(
736        &self,
737        event_id: &str,
738        depth: usize,
739    ) -> Result<Vec<CausalNode>, TracerError> {
740        if !self.nodes.contains_key(event_id) {
741            return Err(TracerError::NodeNotFound(event_id.to_string()));
742        }
743
744        let mut visited: HashSet<String> = HashSet::new();
745        let mut queue: VecDeque<(String, usize)> = VecDeque::new();
746        queue.push_back((event_id.to_string(), 0));
747        visited.insert(event_id.to_string());
748
749        while let Some((current, d)) = queue.pop_front() {
750            if d >= depth {
751                continue;
752            }
753            let out_edges = self.adj_out.get(&current).cloned().unwrap_or_default();
754            for eref in &out_edges {
755                if !visited.contains(&eref.to) {
756                    visited.insert(eref.to.clone());
757                    queue.push_back((eref.to.clone(), d + 1));
758                }
759            }
760        }
761
762        // Return all visited nodes except the starting node.
763        let mut effects: Vec<CausalNode> = visited
764            .iter()
765            .filter(|id| id.as_str() != event_id)
766            .filter_map(|id| self.nodes.get(id).cloned())
767            .collect();
768        effects.sort_by_key(|a| a.timestamp);
769        Ok(effects)
770    }
771
772    /// Return a snapshot of aggregate statistics.
773    pub fn stats(&self) -> TracerStats {
774        self.stats.clone()
775    }
776
777    // ------------------------------------------------------------------
778    // Internal helpers
779    // ------------------------------------------------------------------
780
781    /// DFS to determine if there is a path from `start` to `target`.
782    /// Returns the path if found (including `target`).
783    fn find_cycle_path(&self, start: &str, target: &str) -> Option<Vec<String>> {
784        if start == target {
785            return Some(vec![start.to_string()]);
786        }
787
788        let mut visited: HashSet<String> = HashSet::new();
789        let mut path: Vec<String> = Vec::new();
790        self.dfs_find(start, target, &mut visited, &mut path)
791    }
792
793    /// Recursive DFS helper used by `find_cycle_path`.
794    fn dfs_find(
795        &self,
796        current: &str,
797        target: &str,
798        visited: &mut HashSet<String>,
799        path: &mut Vec<String>,
800    ) -> Option<Vec<String>> {
801        if visited.contains(current) {
802            return None;
803        }
804        visited.insert(current.to_string());
805        path.push(current.to_string());
806
807        if current == target {
808            return Some(path.clone());
809        }
810
811        let out_edges = match self.adj_out.get(current) {
812            Some(v) => v.clone(),
813            None => {
814                path.pop();
815                return None;
816            }
817        };
818
819        for eref in &out_edges {
820            if let Some(found) = self.dfs_find(&eref.to, target, visited, path) {
821                return Some(found);
822            }
823        }
824
825        path.pop();
826        None
827    }
828
829    /// Determine the root nodes for a trace query.
830    fn resolve_roots(&self, query: &TraceQuery) -> Result<Vec<String>, TracerError> {
831        match &query.root_event_id {
832            Some(id) => {
833                if !self.nodes.contains_key(id) {
834                    return Err(TracerError::NodeNotFound(id.clone()));
835                }
836                Ok(vec![id.clone()])
837            }
838            None => {
839                // All nodes with no incoming edges.
840                let roots: Vec<String> = self
841                    .nodes
842                    .keys()
843                    .filter(|id| self.adj_in.get(*id).map(|v| v.is_empty()).unwrap_or(true))
844                    .cloned()
845                    .collect();
846                Ok(roots)
847            }
848        }
849    }
850
851    /// Check whether a node passes the event_type and time_window filters.
852    fn node_passes_query(&self, node: &CausalNode, query: &TraceQuery) -> bool {
853        if !query.event_types.is_empty() && !query.event_types.contains(&node.event_type) {
854            return false;
855        }
856        if let Some((start, end)) = query.time_window_us {
857            if node.timestamp < start || node.timestamp > end {
858                return false;
859            }
860        }
861        true
862    }
863
864    /// Compute the product of strengths for a slice of edge indices.
865    fn product_of_edges(&self, edge_indices: &[usize]) -> f64 {
866        edge_indices
867            .iter()
868            .fold(1.0_f64, |acc, &i| acc * self.edges[i].strength)
869    }
870
871    /// Construct a [`CausalChain`] from parallel node-ID and edge-index slices.
872    fn build_chain(
873        &self,
874        node_ids: &[String],
875        edge_indices: &[usize],
876        confidence: f64,
877    ) -> Result<CausalChain, TracerError> {
878        let mut nodes: Vec<CausalNode> = Vec::with_capacity(node_ids.len());
879        for id in node_ids {
880            let node = self
881                .nodes
882                .get(id)
883                .ok_or_else(|| TracerError::NodeNotFound(id.clone()))?
884                .clone();
885            nodes.push(node);
886        }
887
888        let mut edges: Vec<CausalEdge> = Vec::with_capacity(edge_indices.len());
889        for &i in edge_indices {
890            edges.push(self.edges[i].clone());
891        }
892
893        let root_id = node_ids.first().cloned().unwrap_or_default();
894        let leaf_ids = vec![node_ids.last().cloned().unwrap_or_default()];
895        let depth = node_ids.len().saturating_sub(1);
896
897        Ok(CausalChain {
898            nodes,
899            edges,
900            root_id,
901            leaf_ids,
902            chain_confidence: confidence,
903            depth,
904        })
905    }
906}
907
908// ---------------------------------------------------------------------------
909// PRNG (used in tests — no `rand` dependency)
910// ---------------------------------------------------------------------------
911
912/// XorShift64 PRNG; deterministic, dependency-free.
913pub fn xorshift64(state: &mut u64) -> u64 {
914    let mut x = *state;
915    x ^= x << 13;
916    x ^= x >> 7;
917    x ^= x << 17;
918    *state = x;
919    x
920}
921
922// ---------------------------------------------------------------------------
923// Tests
924// ---------------------------------------------------------------------------
925
926#[cfg(test)]
927mod tests {
928    use super::*;
929
930    // -----------------------------------------------------------------------
931    // Helpers
932    // -----------------------------------------------------------------------
933
934    fn make_tracer() -> CausalChainTracer {
935        CausalChainTracer::new(TracerConfig {
936            max_chain_depth: 16,
937            min_edge_strength: 0.0,
938            max_nodes: 10_000,
939            enable_cycle_detection: true,
940            confidence_threshold: 0.0,
941        })
942    }
943
944    fn node(id: &str, etype: &str, ts: u64) -> CausalNode {
945        CausalNode::new(id, etype, ts, 1.0)
946    }
947
948    fn edge(from: &str, to: &str, rel: CausalRelation, strength: f64) -> CausalEdge {
949        CausalEdge::new(from, to, rel, strength, 0)
950    }
951
952    // -----------------------------------------------------------------------
953    // 1. add_node basic
954    // -----------------------------------------------------------------------
955    #[test]
956    fn test_add_node_basic() {
957        let mut t = make_tracer();
958        let n = node("A", "login", 1000);
959        assert!(t.add_node(n).is_ok());
960        assert_eq!(t.stats().nodes_tracked, 1);
961    }
962
963    // -----------------------------------------------------------------------
964    // 2. add_node duplicate (overwrite)
965    // -----------------------------------------------------------------------
966    #[test]
967    fn test_add_node_duplicate_overwrite() {
968        let mut t = make_tracer();
969        t.add_node(node("A", "login", 100))
970            .expect("test setup: add_node failed");
971        t.add_node(node("A", "logout", 200))
972            .expect("test setup: add_node failed");
973        // Still 1 node, updated value.
974        assert_eq!(t.stats().nodes_tracked, 1);
975    }
976
977    // -----------------------------------------------------------------------
978    // 3. add_node max_nodes limit
979    // -----------------------------------------------------------------------
980    #[test]
981    fn test_add_node_max_nodes() {
982        let mut t = CausalChainTracer::new(TracerConfig {
983            max_nodes: 2,
984            ..Default::default()
985        });
986        t.add_node(node("A", "x", 0))
987            .expect("test setup: add_node failed");
988        t.add_node(node("B", "x", 0))
989            .expect("test setup: add_node failed");
990        let err = t.add_node(node("C", "x", 0)).unwrap_err();
991        assert!(matches!(err, TracerError::QueryTooExpensive(_)));
992    }
993
994    // -----------------------------------------------------------------------
995    // 4. add_edge basic
996    // -----------------------------------------------------------------------
997    #[test]
998    fn test_add_edge_basic() {
999        let mut t = make_tracer();
1000        t.add_node(node("A", "e", 0))
1001            .expect("test setup: add_node failed");
1002        t.add_node(node("B", "e", 1))
1003            .expect("test setup: add_node failed");
1004        assert!(t
1005            .add_edge(edge("A", "B", CausalRelation::DirectCause, 0.8))
1006            .is_ok());
1007        assert_eq!(t.stats().edges_tracked, 1);
1008    }
1009
1010    // -----------------------------------------------------------------------
1011    // 5. add_edge missing from-node
1012    // -----------------------------------------------------------------------
1013    #[test]
1014    fn test_add_edge_missing_from() {
1015        let mut t = make_tracer();
1016        t.add_node(node("B", "e", 0))
1017            .expect("test setup: add_node failed");
1018        let err = t
1019            .add_edge(edge("X", "B", CausalRelation::Enables, 0.5))
1020            .unwrap_err();
1021        assert!(matches!(err, TracerError::NodeNotFound(_)));
1022    }
1023
1024    // -----------------------------------------------------------------------
1025    // 6. add_edge missing to-node
1026    // -----------------------------------------------------------------------
1027    #[test]
1028    fn test_add_edge_missing_to() {
1029        let mut t = make_tracer();
1030        t.add_node(node("A", "e", 0))
1031            .expect("test setup: add_node failed");
1032        let err = t
1033            .add_edge(edge("A", "Y", CausalRelation::Enables, 0.5))
1034            .unwrap_err();
1035        assert!(matches!(err, TracerError::NodeNotFound(_)));
1036    }
1037
1038    // -----------------------------------------------------------------------
1039    // 7. add_edge invalid strength > 1
1040    // -----------------------------------------------------------------------
1041    #[test]
1042    fn test_add_edge_strength_too_high() {
1043        let mut t = make_tracer();
1044        t.add_node(node("A", "e", 0))
1045            .expect("test setup: add_node failed");
1046        t.add_node(node("B", "e", 1))
1047            .expect("test setup: add_node failed");
1048        let err = t
1049            .add_edge(edge("A", "B", CausalRelation::Precedes, 1.5))
1050            .unwrap_err();
1051        assert!(matches!(err, TracerError::InvalidStrength(_)));
1052    }
1053
1054    // -----------------------------------------------------------------------
1055    // 8. add_edge invalid strength < 0
1056    // -----------------------------------------------------------------------
1057    #[test]
1058    fn test_add_edge_strength_negative() {
1059        let mut t = make_tracer();
1060        t.add_node(node("A", "e", 0))
1061            .expect("test setup: add_node failed");
1062        t.add_node(node("B", "e", 1))
1063            .expect("test setup: add_node failed");
1064        let err = t
1065            .add_edge(edge("A", "B", CausalRelation::Precedes, -0.1))
1066            .unwrap_err();
1067        assert!(matches!(err, TracerError::InvalidStrength(_)));
1068    }
1069
1070    // -----------------------------------------------------------------------
1071    // 9. add_edge boundary strengths (0.0 and 1.0 are valid)
1072    // -----------------------------------------------------------------------
1073    #[test]
1074    fn test_add_edge_boundary_strengths() {
1075        let mut t = make_tracer();
1076        t.add_node(node("A", "e", 0))
1077            .expect("test setup: add_node failed");
1078        t.add_node(node("B", "e", 1))
1079            .expect("test setup: add_node failed");
1080        t.add_node(node("C", "e", 2))
1081            .expect("test setup: add_node failed");
1082        assert!(t
1083            .add_edge(edge("A", "B", CausalRelation::Precedes, 0.0))
1084            .is_ok());
1085        assert!(t
1086            .add_edge(edge("B", "C", CausalRelation::Precedes, 1.0))
1087            .is_ok());
1088    }
1089
1090    // -----------------------------------------------------------------------
1091    // 10. cycle detection — direct cycle
1092    // -----------------------------------------------------------------------
1093    #[test]
1094    fn test_cycle_direct() {
1095        let mut t = make_tracer();
1096        t.add_node(node("A", "e", 0))
1097            .expect("test setup: add_node failed");
1098        t.add_node(node("B", "e", 1))
1099            .expect("test setup: add_node failed");
1100        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.9))
1101            .expect("test setup: add_edge failed");
1102        let err = t
1103            .add_edge(edge("B", "A", CausalRelation::DirectCause, 0.9))
1104            .unwrap_err();
1105        assert!(matches!(err, TracerError::CycleDetected { .. }));
1106    }
1107
1108    // -----------------------------------------------------------------------
1109    // 11. cycle detection — indirect cycle A->B->C->A
1110    // -----------------------------------------------------------------------
1111    #[test]
1112    fn test_cycle_indirect() {
1113        let mut t = make_tracer();
1114        for id in ["A", "B", "C"] {
1115            t.add_node(node(id, "e", 0))
1116                .expect("test setup: add_node failed");
1117        }
1118        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.8))
1119            .expect("test setup: add_edge failed");
1120        t.add_edge(edge("B", "C", CausalRelation::DirectCause, 0.8))
1121            .expect("test setup: add_edge failed");
1122        let err = t
1123            .add_edge(edge("C", "A", CausalRelation::DirectCause, 0.8))
1124            .unwrap_err();
1125        assert!(matches!(err, TracerError::CycleDetected { .. }));
1126    }
1127
1128    // -----------------------------------------------------------------------
1129    // 12. cycle detection — self-loop
1130    // -----------------------------------------------------------------------
1131    #[test]
1132    fn test_cycle_self_loop() {
1133        let mut t = make_tracer();
1134        t.add_node(node("A", "e", 0))
1135            .expect("test setup: add_node failed");
1136        let err = t
1137            .add_edge(edge("A", "A", CausalRelation::DirectCause, 0.5))
1138            .unwrap_err();
1139        assert!(matches!(err, TracerError::CycleDetected { .. }));
1140    }
1141
1142    // -----------------------------------------------------------------------
1143    // 13. cycle detection disabled
1144    // -----------------------------------------------------------------------
1145    #[test]
1146    fn test_cycle_detection_disabled() {
1147        let mut t = CausalChainTracer::new(TracerConfig {
1148            enable_cycle_detection: false,
1149            ..Default::default()
1150        });
1151        t.add_node(node("A", "e", 0))
1152            .expect("test setup: add_node failed");
1153        t.add_node(node("B", "e", 1))
1154            .expect("test setup: add_node failed");
1155        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.9))
1156            .expect("test setup: add_edge failed");
1157        // With detection disabled, this back-edge is accepted.
1158        assert!(t
1159            .add_edge(edge("B", "A", CausalRelation::DirectCause, 0.9))
1160            .is_ok());
1161    }
1162
1163    // -----------------------------------------------------------------------
1164    // 14. trace — simple two-node chain
1165    // -----------------------------------------------------------------------
1166    #[test]
1167    fn test_trace_simple_chain() {
1168        let mut t = make_tracer();
1169        t.add_node(node("A", "e", 0))
1170            .expect("test setup: add_node failed");
1171        t.add_node(node("B", "e", 1))
1172            .expect("test setup: add_node failed");
1173        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.9))
1174            .expect("test setup: add_edge failed");
1175        let q = TraceQuery {
1176            root_event_id: Some("A".to_string()),
1177            max_depth: 4,
1178            ..Default::default()
1179        };
1180        let chains = t.trace(&q).expect("test setup: trace failed");
1181        assert_eq!(chains.len(), 1);
1182        assert_eq!(chains[0].nodes.len(), 2);
1183        assert!((chains[0].chain_confidence - 0.9).abs() < 1e-9);
1184    }
1185
1186    // -----------------------------------------------------------------------
1187    // 15. trace — linear chain A->B->C confidence product
1188    // -----------------------------------------------------------------------
1189    #[test]
1190    fn test_trace_confidence_product() {
1191        let mut t = make_tracer();
1192        for (id, ts) in [("A", 0u64), ("B", 1), ("C", 2)] {
1193            t.add_node(node(id, "e", ts))
1194                .expect("test setup: add_node failed");
1195        }
1196        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.8))
1197            .expect("test setup: add_edge failed");
1198        t.add_edge(edge("B", "C", CausalRelation::DirectCause, 0.5))
1199            .expect("test setup: add_edge failed");
1200        let q = TraceQuery {
1201            root_event_id: Some("A".to_string()),
1202            max_depth: 4,
1203            ..Default::default()
1204        };
1205        let chains = t.trace(&q).expect("test setup: trace failed");
1206        assert_eq!(chains.len(), 1);
1207        assert!((chains[0].chain_confidence - 0.4).abs() < 1e-9);
1208    }
1209
1210    // -----------------------------------------------------------------------
1211    // 16. trace — max_depth limits
1212    // -----------------------------------------------------------------------
1213    #[test]
1214    fn test_trace_max_depth() {
1215        let mut t = make_tracer();
1216        for (id, ts) in [("A", 0u64), ("B", 1), ("C", 2), ("D", 3)] {
1217            t.add_node(node(id, "e", ts))
1218                .expect("test setup: add_node failed");
1219        }
1220        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.9))
1221            .expect("test setup: add_edge failed");
1222        t.add_edge(edge("B", "C", CausalRelation::DirectCause, 0.9))
1223            .expect("test setup: add_edge failed");
1224        t.add_edge(edge("C", "D", CausalRelation::DirectCause, 0.9))
1225            .expect("test setup: add_edge failed");
1226
1227        let q = TraceQuery {
1228            root_event_id: Some("A".to_string()),
1229            max_depth: 1,
1230            ..Default::default()
1231        };
1232        let chains = t.trace(&q).expect("test setup: trace failed");
1233        // Depth 1 means we can only reach B from A.
1234        assert_eq!(chains.len(), 1);
1235        assert_eq!(chains[0].depth, 1);
1236    }
1237
1238    // -----------------------------------------------------------------------
1239    // 17. trace — min_strength filter
1240    // -----------------------------------------------------------------------
1241    #[test]
1242    fn test_trace_min_strength_filter() {
1243        let mut t = make_tracer();
1244        t.add_node(node("A", "e", 0))
1245            .expect("test setup: add_node failed");
1246        t.add_node(node("B", "e", 1))
1247            .expect("test setup: add_node failed");
1248        t.add_node(node("C", "e", 2))
1249            .expect("test setup: add_node failed");
1250        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.3))
1251            .expect("test setup: add_edge failed");
1252        t.add_edge(edge("A", "C", CausalRelation::DirectCause, 0.9))
1253            .expect("test setup: add_edge failed");
1254        let q = TraceQuery {
1255            root_event_id: Some("A".to_string()),
1256            min_strength: 0.5,
1257            max_depth: 4,
1258            ..Default::default()
1259        };
1260        let chains = t.trace(&q).expect("test setup: trace failed");
1261        // Only A->C survives.
1262        assert_eq!(chains.len(), 1);
1263        assert_eq!(chains[0].leaf_ids[0], "C");
1264    }
1265
1266    // -----------------------------------------------------------------------
1267    // 18. trace — event_type filter
1268    // -----------------------------------------------------------------------
1269    #[test]
1270    fn test_trace_event_type_filter() {
1271        let mut t = make_tracer();
1272        t.add_node(node("A", "request", 0))
1273            .expect("test setup: add_node failed");
1274        t.add_node(node("B", "response", 1))
1275            .expect("test setup: add_node failed");
1276        t.add_node(node("C", "error", 2))
1277            .expect("test setup: add_node failed");
1278        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.9))
1279            .expect("test setup: add_edge failed");
1280        t.add_edge(edge("A", "C", CausalRelation::DirectCause, 0.9))
1281            .expect("test setup: add_edge failed");
1282
1283        let q = TraceQuery {
1284            root_event_id: Some("A".to_string()),
1285            event_types: vec!["request".to_string(), "response".to_string()],
1286            max_depth: 4,
1287            ..Default::default()
1288        };
1289        let chains = t.trace(&q).expect("test setup: trace failed");
1290        // Only A->B (error C is filtered).
1291        assert_eq!(chains.len(), 1);
1292        assert!(chains[0].nodes.iter().all(|n| n.event_type != "error"));
1293    }
1294
1295    // -----------------------------------------------------------------------
1296    // 19. trace — time_window_us filter
1297    // -----------------------------------------------------------------------
1298    #[test]
1299    fn test_trace_time_window_filter() {
1300        let mut t = make_tracer();
1301        t.add_node(node("A", "e", 0))
1302            .expect("test setup: add_node failed");
1303        t.add_node(node("B", "e", 100))
1304            .expect("test setup: add_node failed");
1305        t.add_node(node("C", "e", 500))
1306            .expect("test setup: add_node failed");
1307        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.9))
1308            .expect("test setup: add_edge failed");
1309        t.add_edge(edge("A", "C", CausalRelation::DirectCause, 0.9))
1310            .expect("test setup: add_edge failed");
1311
1312        let q = TraceQuery {
1313            root_event_id: Some("A".to_string()),
1314            time_window_us: Some((0, 200)),
1315            max_depth: 4,
1316            ..Default::default()
1317        };
1318        let chains = t.trace(&q).expect("test setup: trace failed");
1319        // C is at ts=500 > 200, filtered out.
1320        assert!(chains
1321            .iter()
1322            .all(|c| !c.leaf_ids.contains(&"C".to_string())));
1323    }
1324
1325    // -----------------------------------------------------------------------
1326    // 20. trace — include_relations filter
1327    // -----------------------------------------------------------------------
1328    #[test]
1329    fn test_trace_relation_filter() {
1330        let mut t = make_tracer();
1331        t.add_node(node("A", "e", 0))
1332            .expect("test setup: add_node failed");
1333        t.add_node(node("B", "e", 1))
1334            .expect("test setup: add_node failed");
1335        t.add_node(node("C", "e", 2))
1336            .expect("test setup: add_node failed");
1337        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.9))
1338            .expect("test setup: add_edge failed");
1339        t.add_edge(edge("A", "C", CausalRelation::Correlates, 0.9))
1340            .expect("test setup: add_edge failed");
1341
1342        let q = TraceQuery {
1343            root_event_id: Some("A".to_string()),
1344            include_relations: vec![CausalRelation::DirectCause],
1345            max_depth: 4,
1346            ..Default::default()
1347        };
1348        let chains = t.trace(&q).expect("test setup: trace failed");
1349        // Correlates edge to C is excluded.
1350        assert!(chains
1351            .iter()
1352            .all(|c| !c.leaf_ids.contains(&"C".to_string())));
1353    }
1354
1355    // -----------------------------------------------------------------------
1356    // 21. trace — confidence_threshold
1357    // -----------------------------------------------------------------------
1358    #[test]
1359    fn test_trace_confidence_threshold() {
1360        let mut t = CausalChainTracer::new(TracerConfig {
1361            confidence_threshold: 0.5,
1362            ..Default::default()
1363        });
1364        t.add_node(node("A", "e", 0))
1365            .expect("test setup: add_node failed");
1366        t.add_node(node("B", "e", 1))
1367            .expect("test setup: add_node failed");
1368        t.add_node(node("C", "e", 2))
1369            .expect("test setup: add_node failed");
1370        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.9))
1371            .expect("test setup: add_edge failed");
1372        // A->C has confidence 0.3 < threshold.
1373        t.add_edge(edge("A", "C", CausalRelation::DirectCause, 0.3))
1374            .expect("test setup: add_edge failed");
1375        let q = TraceQuery {
1376            root_event_id: Some("A".to_string()),
1377            max_depth: 4,
1378            ..Default::default()
1379        };
1380        let chains = t.trace(&q).expect("test setup: trace failed");
1381        assert!(chains.iter().all(|c| c.chain_confidence >= 0.5));
1382    }
1383
1384    // -----------------------------------------------------------------------
1385    // 22. trace — branching graph
1386    // -----------------------------------------------------------------------
1387    #[test]
1388    fn test_trace_branching() {
1389        let mut t = make_tracer();
1390        // A -> B, A -> C, B -> D, C -> D
1391        for id in ["A", "B", "C", "D"] {
1392            t.add_node(node(id, "e", 0))
1393                .expect("test setup: add_node failed");
1394        }
1395        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.8))
1396            .expect("test setup: add_edge failed");
1397        t.add_edge(edge("A", "C", CausalRelation::DirectCause, 0.7))
1398            .expect("test setup: add_edge failed");
1399        t.add_edge(edge("B", "D", CausalRelation::DirectCause, 0.9))
1400            .expect("test setup: add_edge failed");
1401        t.add_edge(edge("C", "D", CausalRelation::DirectCause, 0.6))
1402            .expect("test setup: add_edge failed");
1403
1404        let q = TraceQuery {
1405            root_event_id: Some("A".to_string()),
1406            max_depth: 8,
1407            ..Default::default()
1408        };
1409        let chains = t.trace(&q).expect("test setup: trace failed");
1410        // Two paths: A->B->D and A->C->D.
1411        assert_eq!(chains.len(), 2);
1412    }
1413
1414    // -----------------------------------------------------------------------
1415    // 23. trace — no roots (empty graph)
1416    // -----------------------------------------------------------------------
1417    #[test]
1418    fn test_trace_empty_graph() {
1419        let mut t = make_tracer();
1420        let q = TraceQuery::default();
1421        let chains = t.trace(&q).expect("test setup: trace failed");
1422        assert!(chains.is_empty());
1423    }
1424
1425    // -----------------------------------------------------------------------
1426    // 24. trace — root_event_id not found
1427    // -----------------------------------------------------------------------
1428    #[test]
1429    fn test_trace_root_not_found() {
1430        let mut t = make_tracer();
1431        let q = TraceQuery {
1432            root_event_id: Some("MISSING".to_string()),
1433            ..Default::default()
1434        };
1435        let err = t.trace(&q).unwrap_err();
1436        assert!(matches!(err, TracerError::NodeNotFound(_)));
1437    }
1438
1439    // -----------------------------------------------------------------------
1440    // 25. shortest_path — direct connection
1441    // -----------------------------------------------------------------------
1442    #[test]
1443    fn test_shortest_path_direct() {
1444        let mut t = make_tracer();
1445        t.add_node(node("A", "e", 0))
1446            .expect("test setup: add_node failed");
1447        t.add_node(node("B", "e", 1))
1448            .expect("test setup: add_node failed");
1449        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.9))
1450            .expect("test setup: add_edge failed");
1451        let path = t
1452            .shortest_path("A", "B")
1453            .expect("test setup: shortest_path failed")
1454            .expect("test setup: expected Some path");
1455        assert_eq!(path, vec!["A", "B"]);
1456    }
1457
1458    // -----------------------------------------------------------------------
1459    // 26. shortest_path — multi-hop
1460    // -----------------------------------------------------------------------
1461    #[test]
1462    fn test_shortest_path_multi_hop() {
1463        let mut t = make_tracer();
1464        for id in ["A", "B", "C"] {
1465            t.add_node(node(id, "e", 0))
1466                .expect("test setup: add_node failed");
1467        }
1468        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.8))
1469            .expect("test setup: add_edge failed");
1470        t.add_edge(edge("B", "C", CausalRelation::DirectCause, 0.8))
1471            .expect("test setup: add_edge failed");
1472        let path = t
1473            .shortest_path("A", "C")
1474            .expect("test setup: shortest_path failed")
1475            .expect("test setup: expected Some path");
1476        assert_eq!(path.len(), 3);
1477    }
1478
1479    // -----------------------------------------------------------------------
1480    // 27. shortest_path — no path
1481    // -----------------------------------------------------------------------
1482    #[test]
1483    fn test_shortest_path_no_path() {
1484        let mut t = make_tracer();
1485        t.add_node(node("A", "e", 0))
1486            .expect("test setup: add_node failed");
1487        t.add_node(node("B", "e", 1))
1488            .expect("test setup: add_node failed");
1489        let result = t
1490            .shortest_path("A", "B")
1491            .expect("test setup: shortest_path failed");
1492        assert!(result.is_none());
1493    }
1494
1495    // -----------------------------------------------------------------------
1496    // 28. shortest_path — same node
1497    // -----------------------------------------------------------------------
1498    #[test]
1499    fn test_shortest_path_same_node() {
1500        let mut t = make_tracer();
1501        t.add_node(node("A", "e", 0))
1502            .expect("test setup: add_node failed");
1503        let path = t
1504            .shortest_path("A", "A")
1505            .expect("test setup: shortest_path failed")
1506            .expect("test setup: expected Some path");
1507        assert_eq!(path, vec!["A"]);
1508    }
1509
1510    // -----------------------------------------------------------------------
1511    // 29. shortest_path — missing node error
1512    // -----------------------------------------------------------------------
1513    #[test]
1514    fn test_shortest_path_missing_node() {
1515        let t = make_tracer();
1516        let err = t.shortest_path("X", "Y").unwrap_err();
1517        assert!(matches!(err, TracerError::NodeNotFound(_)));
1518    }
1519
1520    // -----------------------------------------------------------------------
1521    // 30. shortest_path — BFS finds shortest not longest
1522    // -----------------------------------------------------------------------
1523    #[test]
1524    fn test_shortest_path_is_shortest() {
1525        let mut t = make_tracer();
1526        // Two paths: A->B->D (len 3) and A->C->D (len 3); both equally short.
1527        for id in ["A", "B", "C", "D"] {
1528            t.add_node(node(id, "e", 0))
1529                .expect("test setup: add_node failed");
1530        }
1531        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.9))
1532            .expect("test setup: add_edge failed");
1533        t.add_edge(edge("B", "D", CausalRelation::DirectCause, 0.9))
1534            .expect("test setup: add_edge failed");
1535        t.add_edge(edge("A", "C", CausalRelation::DirectCause, 0.9))
1536            .expect("test setup: add_edge failed");
1537        t.add_edge(edge("C", "D", CausalRelation::DirectCause, 0.9))
1538            .expect("test setup: add_edge failed");
1539        let path = t
1540            .shortest_path("A", "D")
1541            .expect("test setup: shortest_path failed")
1542            .expect("test setup: expected Some path");
1543        assert_eq!(path.len(), 3);
1544    }
1545
1546    // -----------------------------------------------------------------------
1547    // 31. strongest_path — simple case
1548    // -----------------------------------------------------------------------
1549    #[test]
1550    fn test_strongest_path_simple() {
1551        let mut t = make_tracer();
1552        t.add_node(node("A", "e", 0))
1553            .expect("test setup: add_node failed");
1554        t.add_node(node("B", "e", 1))
1555            .expect("test setup: add_node failed");
1556        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.7))
1557            .expect("test setup: add_edge failed");
1558        let chain = t
1559            .strongest_path("A", "B")
1560            .expect("test setup: strongest_path failed")
1561            .expect("test setup: expected Some chain");
1562        assert!((chain.chain_confidence - 0.7).abs() < 1e-9);
1563    }
1564
1565    // -----------------------------------------------------------------------
1566    // 32. strongest_path — picks higher product
1567    // -----------------------------------------------------------------------
1568    #[test]
1569    fn test_strongest_path_picks_higher() {
1570        let mut t = make_tracer();
1571        // Path 1: A->B->D  product = 0.9 * 0.9 = 0.81
1572        // Path 2: A->C->D  product = 0.5 * 0.5 = 0.25
1573        for id in ["A", "B", "C", "D"] {
1574            t.add_node(node(id, "e", 0))
1575                .expect("test setup: add_node failed");
1576        }
1577        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.9))
1578            .expect("test setup: add_edge failed");
1579        t.add_edge(edge("B", "D", CausalRelation::DirectCause, 0.9))
1580            .expect("test setup: add_edge failed");
1581        t.add_edge(edge("A", "C", CausalRelation::DirectCause, 0.5))
1582            .expect("test setup: add_edge failed");
1583        t.add_edge(edge("C", "D", CausalRelation::DirectCause, 0.5))
1584            .expect("test setup: add_edge failed");
1585        let chain = t
1586            .strongest_path("A", "D")
1587            .expect("test setup: strongest_path failed")
1588            .expect("test setup: expected Some chain");
1589        assert!(chain.chain_confidence > 0.8);
1590    }
1591
1592    // -----------------------------------------------------------------------
1593    // 33. strongest_path — same node
1594    // -----------------------------------------------------------------------
1595    #[test]
1596    fn test_strongest_path_same_node() {
1597        let mut t = make_tracer();
1598        t.add_node(node("A", "e", 0))
1599            .expect("test setup: add_node failed");
1600        let chain = t
1601            .strongest_path("A", "A")
1602            .expect("test setup: strongest_path failed")
1603            .expect("test setup: expected Some chain");
1604        assert!((chain.chain_confidence - 1.0).abs() < 1e-9);
1605        assert_eq!(chain.depth, 0);
1606    }
1607
1608    // -----------------------------------------------------------------------
1609    // 34. strongest_path — no path
1610    // -----------------------------------------------------------------------
1611    #[test]
1612    fn test_strongest_path_no_path() {
1613        let mut t = make_tracer();
1614        t.add_node(node("A", "e", 0))
1615            .expect("test setup: add_node failed");
1616        t.add_node(node("B", "e", 1))
1617            .expect("test setup: add_node failed");
1618        let result = t
1619            .strongest_path("A", "B")
1620            .expect("test setup: strongest_path failed");
1621        assert!(result.is_none());
1622    }
1623
1624    // -----------------------------------------------------------------------
1625    // 35. strongest_path — missing node error
1626    // -----------------------------------------------------------------------
1627    #[test]
1628    fn test_strongest_path_missing() {
1629        let t = make_tracer();
1630        let err = t.strongest_path("X", "Y").unwrap_err();
1631        assert!(matches!(err, TracerError::NodeNotFound(_)));
1632    }
1633
1634    // -----------------------------------------------------------------------
1635    // 36. root_causes — single root
1636    // -----------------------------------------------------------------------
1637    #[test]
1638    fn test_root_causes_single() {
1639        let mut t = make_tracer();
1640        for id in ["A", "B", "C"] {
1641            t.add_node(node(id, "e", 0))
1642                .expect("test setup: add_node failed");
1643        }
1644        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.9))
1645            .expect("test setup: add_edge failed");
1646        t.add_edge(edge("B", "C", CausalRelation::DirectCause, 0.9))
1647            .expect("test setup: add_edge failed");
1648        let roots = t.root_causes("C").expect("test setup: root_causes failed");
1649        assert_eq!(roots.len(), 1);
1650        assert_eq!(roots[0].id, "A");
1651    }
1652
1653    // -----------------------------------------------------------------------
1654    // 37. root_causes — multiple roots
1655    // -----------------------------------------------------------------------
1656    #[test]
1657    fn test_root_causes_multiple() {
1658        let mut t = make_tracer();
1659        for id in ["A", "B", "C"] {
1660            t.add_node(node(id, "e", 0))
1661                .expect("test setup: add_node failed");
1662        }
1663        t.add_edge(edge("A", "C", CausalRelation::DirectCause, 0.9))
1664            .expect("test setup: add_edge failed");
1665        t.add_edge(edge("B", "C", CausalRelation::DirectCause, 0.9))
1666            .expect("test setup: add_edge failed");
1667        let mut roots = t.root_causes("C").expect("test setup: root_causes failed");
1668        roots.sort_by(|a, b| a.id.cmp(&b.id));
1669        assert_eq!(roots.len(), 2);
1670        assert_eq!(roots[0].id, "A");
1671        assert_eq!(roots[1].id, "B");
1672    }
1673
1674    // -----------------------------------------------------------------------
1675    // 38. root_causes — node with no ancestors
1676    // -----------------------------------------------------------------------
1677    #[test]
1678    fn test_root_causes_no_ancestors() {
1679        let mut t = make_tracer();
1680        t.add_node(node("A", "e", 0))
1681            .expect("test setup: add_node failed");
1682        let roots = t.root_causes("A").expect("test setup: root_causes failed");
1683        // A itself has no ancestors, so no roots other than itself.
1684        assert!(roots.is_empty());
1685    }
1686
1687    // -----------------------------------------------------------------------
1688    // 39. root_causes — missing node
1689    // -----------------------------------------------------------------------
1690    #[test]
1691    fn test_root_causes_missing() {
1692        let t = make_tracer();
1693        let err = t.root_causes("MISSING").unwrap_err();
1694        assert!(matches!(err, TracerError::NodeNotFound(_)));
1695    }
1696
1697    // -----------------------------------------------------------------------
1698    // 40. downstream_effects — depth 1
1699    // -----------------------------------------------------------------------
1700    #[test]
1701    fn test_downstream_effects_depth1() {
1702        let mut t = make_tracer();
1703        for id in ["A", "B", "C", "D"] {
1704            t.add_node(node(id, "e", 0))
1705                .expect("test setup: add_node failed");
1706        }
1707        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.9))
1708            .expect("test setup: add_edge failed");
1709        t.add_edge(edge("B", "C", CausalRelation::DirectCause, 0.9))
1710            .expect("test setup: add_edge failed");
1711        t.add_edge(edge("B", "D", CausalRelation::DirectCause, 0.9))
1712            .expect("test setup: add_edge failed");
1713        let effects = t
1714            .downstream_effects("A", 1)
1715            .expect("test setup: downstream_effects failed");
1716        let ids: Vec<&str> = effects.iter().map(|n| n.id.as_str()).collect();
1717        assert!(ids.contains(&"B"));
1718        assert!(!ids.contains(&"C"));
1719    }
1720
1721    // -----------------------------------------------------------------------
1722    // 41. downstream_effects — deep traversal
1723    // -----------------------------------------------------------------------
1724    #[test]
1725    fn test_downstream_effects_deep() {
1726        let mut t = make_tracer();
1727        for (id, ts) in [("A", 0u64), ("B", 1), ("C", 2), ("D", 3)] {
1728            t.add_node(node(id, "e", ts))
1729                .expect("test setup: add_node failed");
1730        }
1731        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.9))
1732            .expect("test setup: add_edge failed");
1733        t.add_edge(edge("B", "C", CausalRelation::DirectCause, 0.9))
1734            .expect("test setup: add_edge failed");
1735        t.add_edge(edge("C", "D", CausalRelation::DirectCause, 0.9))
1736            .expect("test setup: add_edge failed");
1737        let effects = t
1738            .downstream_effects("A", 10)
1739            .expect("test setup: downstream_effects failed");
1740        assert_eq!(effects.len(), 3);
1741    }
1742
1743    // -----------------------------------------------------------------------
1744    // 42. downstream_effects — missing node
1745    // -----------------------------------------------------------------------
1746    #[test]
1747    fn test_downstream_effects_missing() {
1748        let t = make_tracer();
1749        let err = t.downstream_effects("MISSING", 5).unwrap_err();
1750        assert!(matches!(err, TracerError::NodeNotFound(_)));
1751    }
1752
1753    // -----------------------------------------------------------------------
1754    // 43. downstream_effects — zero depth
1755    // -----------------------------------------------------------------------
1756    #[test]
1757    fn test_downstream_effects_zero_depth() {
1758        let mut t = make_tracer();
1759        t.add_node(node("A", "e", 0))
1760            .expect("test setup: add_node failed");
1761        t.add_node(node("B", "e", 1))
1762            .expect("test setup: add_node failed");
1763        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.9))
1764            .expect("test setup: add_edge failed");
1765        let effects = t
1766            .downstream_effects("A", 0)
1767            .expect("test setup: downstream_effects failed");
1768        // Depth 0 means no expansion.
1769        assert!(effects.is_empty());
1770    }
1771
1772    // -----------------------------------------------------------------------
1773    // 44. remove_node — basic
1774    // -----------------------------------------------------------------------
1775    #[test]
1776    fn test_remove_node_basic() {
1777        let mut t = make_tracer();
1778        t.add_node(node("A", "e", 0))
1779            .expect("test setup: add_node failed");
1780        t.add_node(node("B", "e", 1))
1781            .expect("test setup: add_node failed");
1782        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.8))
1783            .expect("test setup: add_edge failed");
1784        t.remove_node("A").expect("test setup: remove_node failed");
1785        assert_eq!(t.stats().nodes_tracked, 1);
1786        assert_eq!(t.stats().edges_tracked, 0);
1787    }
1788
1789    // -----------------------------------------------------------------------
1790    // 45. remove_node — removes connected edges
1791    // -----------------------------------------------------------------------
1792    #[test]
1793    fn test_remove_node_removes_edges() {
1794        let mut t = make_tracer();
1795        for id in ["A", "B", "C"] {
1796            t.add_node(node(id, "e", 0))
1797                .expect("test setup: add_node failed");
1798        }
1799        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.9))
1800            .expect("test setup: add_edge failed");
1801        t.add_edge(edge("B", "C", CausalRelation::DirectCause, 0.9))
1802            .expect("test setup: add_edge failed");
1803        t.remove_node("B").expect("test setup: remove_node failed");
1804        assert_eq!(t.stats().edges_tracked, 0);
1805    }
1806
1807    // -----------------------------------------------------------------------
1808    // 46. remove_node — missing node
1809    // -----------------------------------------------------------------------
1810    #[test]
1811    fn test_remove_node_missing() {
1812        let mut t = make_tracer();
1813        let err = t.remove_node("MISSING").unwrap_err();
1814        assert!(matches!(err, TracerError::NodeNotFound(_)));
1815    }
1816
1817    // -----------------------------------------------------------------------
1818    // 47. stats — basic correctness
1819    // -----------------------------------------------------------------------
1820    #[test]
1821    fn test_stats_basic() {
1822        let mut t = make_tracer();
1823        t.add_node(node("A", "e", 0))
1824            .expect("test setup: add_node failed");
1825        t.add_node(node("B", "e", 1))
1826            .expect("test setup: add_node failed");
1827        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.9))
1828            .expect("test setup: add_edge failed");
1829        let s = t.stats();
1830        assert_eq!(s.nodes_tracked, 2);
1831        assert_eq!(s.edges_tracked, 1);
1832    }
1833
1834    // -----------------------------------------------------------------------
1835    // 48. stats — cycles_detected counter
1836    // -----------------------------------------------------------------------
1837    #[test]
1838    fn test_stats_cycles_detected() {
1839        let mut t = make_tracer();
1840        t.add_node(node("A", "e", 0))
1841            .expect("test setup: add_node failed");
1842        t.add_node(node("B", "e", 1))
1843            .expect("test setup: add_node failed");
1844        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.9))
1845            .expect("test setup: add_edge failed");
1846        let _ = t.add_edge(edge("B", "A", CausalRelation::DirectCause, 0.9));
1847        assert_eq!(t.stats().cycles_detected, 1);
1848    }
1849
1850    // -----------------------------------------------------------------------
1851    // 49. xorshift64 — deterministic PRNG
1852    // -----------------------------------------------------------------------
1853    #[test]
1854    fn test_xorshift64_deterministic() {
1855        let mut state = 12345u64;
1856        let v1 = xorshift64(&mut state);
1857        let mut state2 = 12345u64;
1858        let v2 = xorshift64(&mut state2);
1859        assert_eq!(v1, v2);
1860        assert_ne!(v1, 0);
1861    }
1862
1863    // -----------------------------------------------------------------------
1864    // 50. large graph stress test with PRNG
1865    // -----------------------------------------------------------------------
1866    #[test]
1867    fn test_large_graph_stress() {
1868        let mut t = make_tracer();
1869        let mut rng = 999_999u64;
1870        let n = 50usize;
1871
1872        for i in 0..n {
1873            t.add_node(node(&i.to_string(), "stress", xorshift64(&mut rng) % 1000))
1874                .expect("test setup: add_node failed");
1875        }
1876
1877        // Add a random DAG (always i < j to avoid cycles).
1878        let mut added = 0usize;
1879        for i in 0..n {
1880            for j in (i + 1)..n {
1881                let r = xorshift64(&mut rng);
1882                if r.is_multiple_of(5) {
1883                    let strength = (r % 100) as f64 / 100.0;
1884                    let _ = t.add_edge(edge(
1885                        &i.to_string(),
1886                        &j.to_string(),
1887                        CausalRelation::DirectCause,
1888                        strength,
1889                    ));
1890                    added += 1;
1891                }
1892            }
1893        }
1894
1895        assert!(added > 0, "expected some edges");
1896        let q = TraceQuery {
1897            root_event_id: Some("0".to_string()),
1898            max_depth: 5,
1899            ..Default::default()
1900        };
1901        let chains = t.trace(&q).expect("test setup: trace failed");
1902        // Just verify no panic and some chains were discovered.
1903        assert!(chains.len() <= 10_000);
1904    }
1905
1906    // -----------------------------------------------------------------------
1907    // 51. trace — all relations (empty include_relations = no filter)
1908    // -----------------------------------------------------------------------
1909    #[test]
1910    fn test_trace_no_relation_filter() {
1911        let mut t = make_tracer();
1912        t.add_node(node("A", "e", 0))
1913            .expect("test setup: add_node failed");
1914        t.add_node(node("B", "e", 1))
1915            .expect("test setup: add_node failed");
1916        t.add_node(node("C", "e", 2))
1917            .expect("test setup: add_node failed");
1918        t.add_edge(edge("A", "B", CausalRelation::Inhibits, 0.9))
1919            .expect("test setup: add_edge failed");
1920        t.add_edge(edge("A", "C", CausalRelation::IndirectCause, 0.9))
1921            .expect("test setup: add_edge failed");
1922        let q = TraceQuery {
1923            root_event_id: Some("A".to_string()),
1924            include_relations: vec![], // empty = no filter
1925            max_depth: 4,
1926            ..Default::default()
1927        };
1928        let chains = t.trace(&q).expect("test setup: trace failed");
1929        assert_eq!(chains.len(), 2);
1930    }
1931
1932    // -----------------------------------------------------------------------
1933    // 52. CausalNode with_attribute builder
1934    // -----------------------------------------------------------------------
1935    #[test]
1936    fn test_causal_node_with_attribute() {
1937        let n = CausalNode::new("A", "login", 0, 1.0)
1938            .with_attribute("ip", "192.168.1.1")
1939            .with_attribute("user", "bob");
1940        assert_eq!(n.attributes.len(), 2);
1941        assert_eq!(
1942            n.attributes[0],
1943            ("ip".to_string(), "192.168.1.1".to_string())
1944        );
1945    }
1946
1947    // -----------------------------------------------------------------------
1948    // 53. trace from implicit roots (no root_event_id)
1949    // -----------------------------------------------------------------------
1950    #[test]
1951    fn test_trace_implicit_roots() {
1952        let mut t = make_tracer();
1953        // Two separate root nodes.
1954        t.add_node(node("R1", "e", 0))
1955            .expect("test setup: add_node failed");
1956        t.add_node(node("R2", "e", 0))
1957            .expect("test setup: add_node failed");
1958        t.add_node(node("X", "e", 1))
1959            .expect("test setup: add_node failed");
1960        t.add_edge(edge("R1", "X", CausalRelation::DirectCause, 0.8))
1961            .expect("test setup: add_edge failed");
1962        t.add_edge(edge("R2", "X", CausalRelation::DirectCause, 0.7))
1963            .expect("test setup: add_edge failed");
1964
1965        let q = TraceQuery {
1966            root_event_id: None,
1967            max_depth: 4,
1968            ..Default::default()
1969        };
1970        let chains = t.trace(&q).expect("test setup: trace failed");
1971        // Each root produces one chain.
1972        assert_eq!(chains.len(), 2);
1973    }
1974
1975    // -----------------------------------------------------------------------
1976    // 54. TracerError Display
1977    // -----------------------------------------------------------------------
1978    #[test]
1979    fn test_tracer_error_display() {
1980        let e = TracerError::NodeNotFound("ABC".to_string());
1981        assert!(e.to_string().contains("ABC"));
1982        let e2 = TracerError::InvalidStrength(2.0);
1983        assert!(e2.to_string().contains("2"));
1984        let e3 = TracerError::CycleDetected {
1985            path: vec!["A".to_string(), "B".to_string()],
1986        };
1987        assert!(e3.to_string().contains("A -> B"));
1988    }
1989
1990    // -----------------------------------------------------------------------
1991    // 55. trace chains_traced counter
1992    // -----------------------------------------------------------------------
1993    #[test]
1994    fn test_chains_traced_counter() {
1995        let mut t = make_tracer();
1996        t.add_node(node("A", "e", 0))
1997            .expect("test setup: add_node failed");
1998        t.add_node(node("B", "e", 1))
1999            .expect("test setup: add_node failed");
2000        t.add_edge(edge("A", "B", CausalRelation::DirectCause, 0.9))
2001            .expect("test setup: add_edge failed");
2002        let q = TraceQuery {
2003            root_event_id: Some("A".to_string()),
2004            max_depth: 4,
2005            ..Default::default()
2006        };
2007        t.trace(&q).expect("test setup: trace failed");
2008        t.trace(&q).expect("test setup: trace failed");
2009        assert_eq!(t.stats().chains_traced, 2);
2010    }
2011}