Skip to main content

coreason_runtime_rust/execution_plane/
blast_radius.rs

1// Copyright (c) 2026 CoReason, Inc.
2// All rights reserved.
3
4//! Defeasible Cascade Blast Radius Calculator (#336).
5//!
6//! Replaces `coreason_runtime/execution_plane/blast_radius.py`.
7//!
8//! Computes the propagation blast radius when a belief node is defeated
9//! in the epistemic knowledge graph. Tracks cascade depth and affected
10//! nodes for rollback planning.
11//!
12//! Zero Waste: Delegates all graph traversal to `petgraph` (OSS).
13//! We only write the domain-specific defeasibility scoring logic.
14
15use petgraph::graph::{DiGraph, NodeIndex};
16use petgraph::visit::{Bfs, Reversed};
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19
20/// Result of a blast radius computation.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct CascadeResult {
23    pub defeated_node: String,
24    pub affected_nodes: Vec<String>,
25    pub cascade_depth: u32,
26    pub defeasibility_scores: HashMap<String, f64>,
27    pub total_affected: usize,
28}
29
30/// Internal graph wrapper that builds a `petgraph::DiGraph` from an adjacency list.
31///
32/// We keep this thin: the graph construction is our glue code,
33/// all traversal is delegated to petgraph's BFS/DFS implementations.
34struct EpistemicGraph<'a> {
35    graph: DiGraph<&'a str, ()>,
36    node_map: HashMap<&'a str, NodeIndex>,
37}
38
39impl<'a> EpistemicGraph<'a> {
40    /// Build from adjacency list. O(V + E).
41    fn from_adjacency(adjacency: &'a HashMap<String, Vec<String>>) -> Self {
42        let mut graph = DiGraph::<&'a str, ()>::new();
43        let mut node_map: HashMap<&'a str, NodeIndex> = HashMap::new();
44
45        // Collect all unique nodes
46        for (parent, children) in adjacency {
47            node_map
48                .entry(parent.as_str())
49                .or_insert_with(|| graph.add_node(parent.as_str()));
50            for child in children {
51                node_map
52                    .entry(child.as_str())
53                    .or_insert_with(|| graph.add_node(child.as_str()));
54            }
55        }
56
57        // Add edges
58        for (parent, children) in adjacency {
59            let &parent_idx = node_map.get(parent.as_str()).unwrap();
60            for child in children {
61                let &child_idx = node_map.get(child.as_str()).unwrap();
62                graph.add_edge(parent_idx, child_idx, ());
63            }
64        }
65
66        Self { graph, node_map }
67    }
68
69    /// Return a reversed view of the graph (swapped edge directions).
70    /// Uses petgraph's zero-cost `Reversed` wrapper — no data is copied.
71    fn reversed_view(&self) -> Reversed<&DiGraph<&'a str, ()>> {
72        Reversed(&self.graph)
73    }
74}
75
76/// Computes belief invalidation cascades in a knowledge graph (#336).
77///
78/// When a belief node is defeated (e.g., new evidence contradicts it),
79/// all downstream nodes that depend on it may also be invalidated.
80/// This calculator delegates BFS traversal to `petgraph`.
81pub struct BlastRadiusCalculator;
82
83impl BlastRadiusCalculator {
84    /// Compute the blast radius of defeating a node.
85    ///
86    /// Uses `petgraph::visit::Bfs` for the forward reachability traversal,
87    /// then applies domain-specific defeasibility decay scoring.
88    pub fn calculate_blast_radius(
89        defeated_node_cid: &str,
90        adjacency: &HashMap<String, Vec<String>>,
91    ) -> CascadeResult {
92        let eg = EpistemicGraph::from_adjacency(adjacency);
93
94        let start_idx = match eg.node_map.get(defeated_node_cid) {
95            Some(&idx) => idx,
96            None => {
97                return CascadeResult {
98                    defeated_node: defeated_node_cid.to_string(),
99                    affected_nodes: Vec::new(),
100                    cascade_depth: 0,
101                    defeasibility_scores: HashMap::new(),
102                    total_affected: 0,
103                };
104            }
105        };
106
107        // Use petgraph's BFS to discover all reachable nodes
108        let mut bfs = Bfs::new(&eg.graph, start_idx);
109        let mut depth_map: HashMap<NodeIndex, u32> = HashMap::new();
110        depth_map.insert(start_idx, 0);
111
112        let mut affected: Vec<String> = Vec::new();
113        let mut defeasibility: HashMap<String, f64> = HashMap::new();
114        let mut max_depth: u32 = 0;
115
116        while let Some(node_idx) = bfs.next(&eg.graph) {
117            let node_label = eg.graph[node_idx];
118
119            // Compute depth from parent depths (BFS guarantees shortest path)
120            let depth = if node_idx == start_idx {
121                0
122            } else {
123                // Find the minimum depth among predecessors already visited
124                eg.graph
125                    .neighbors_directed(node_idx, petgraph::Direction::Incoming)
126                    .filter_map(|pred| depth_map.get(&pred))
127                    .min()
128                    .map(|d| d + 1)
129                    .unwrap_or(0)
130            };
131            depth_map.insert(node_idx, depth);
132            max_depth = max_depth.max(depth);
133
134            if node_label != defeated_node_cid {
135                affected.push(node_label.to_string());
136            }
137
138            // Domain-specific: defeasibility score decays with depth
139            defeasibility.insert(node_label.to_string(), 1.0 / (1.0 + depth as f64));
140        }
141
142        CascadeResult {
143            defeated_node: defeated_node_cid.to_string(),
144            affected_nodes: affected.clone(),
145            cascade_depth: max_depth,
146            defeasibility_scores: defeasibility,
147            total_affected: affected.len(),
148        }
149    }
150
151    /// Compute how vulnerable a node is to cascade invalidation.
152    ///
153    /// Uses `petgraph::visit::Bfs` on the reversed graph to count ancestors.
154    /// A node with many ancestors that could be defeated has a high
155    /// defeasibility score (0 = safe, 1 = highly vulnerable).
156    pub fn compute_defeasibility_score(
157        node_cid: &str,
158        adjacency: &HashMap<String, Vec<String>>,
159    ) -> f64 {
160        let eg = EpistemicGraph::from_adjacency(adjacency);
161
162        let start_idx = match eg.node_map.get(node_cid) {
163            Some(&idx) => idx,
164            None => return 0.0,
165        };
166
167        // Use Reversed view and BFS to count ancestors (zero-cost graph reversal)
168        let rev = eg.reversed_view();
169        let mut bfs = Bfs::new(&rev, start_idx);
170        let mut ancestor_count: usize = 0;
171
172        while let Some(node_idx) = bfs.next(&rev) {
173            if node_idx != start_idx {
174                ancestor_count += 1;
175            }
176        }
177
178        let total_nodes = adjacency.len().max(1);
179        (ancestor_count as f64 / total_nodes as f64).min(1.0)
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    fn sample_graph() -> HashMap<String, Vec<String>> {
188        let mut g = HashMap::new();
189        g.insert("root".to_string(), vec!["a".to_string(), "b".to_string()]);
190        g.insert("a".to_string(), vec!["c".to_string(), "d".to_string()]);
191        g.insert("b".to_string(), vec!["d".to_string(), "e".to_string()]);
192        g.insert("c".to_string(), vec![]);
193        g.insert("d".to_string(), vec!["f".to_string()]);
194        g.insert("e".to_string(), vec![]);
195        g.insert("f".to_string(), vec![]);
196        g
197    }
198
199    #[test]
200    fn test_blast_radius_from_root() {
201        let graph = sample_graph();
202        let result = BlastRadiusCalculator::calculate_blast_radius("root", &graph);
203
204        assert_eq!(result.defeated_node, "root");
205        // root → a, b → c, d, e → f = 6 total nodes, 5 affected (excluding root)
206        assert!(result.total_affected >= 5);
207        assert!(result.cascade_depth >= 2);
208        assert_eq!(result.defeasibility_scores["root"], 1.0);
209    }
210
211    #[test]
212    fn test_blast_radius_leaf_node() {
213        let graph = sample_graph();
214        let result = BlastRadiusCalculator::calculate_blast_radius("f", &graph);
215
216        assert_eq!(result.total_affected, 0);
217        assert_eq!(result.cascade_depth, 0);
218    }
219
220    #[test]
221    fn test_blast_radius_missing_node() {
222        let graph = sample_graph();
223        let result = BlastRadiusCalculator::calculate_blast_radius("nonexistent", &graph);
224
225        assert_eq!(result.total_affected, 0);
226        assert_eq!(result.cascade_depth, 0);
227    }
228
229    #[test]
230    fn test_defeasibility_score_root_has_no_ancestors() {
231        let graph = sample_graph();
232        let score = BlastRadiusCalculator::compute_defeasibility_score("root", &graph);
233        assert_eq!(score, 0.0);
234    }
235
236    #[test]
237    fn test_defeasibility_score_deep_node_has_ancestors() {
238        let graph = sample_graph();
239        let score = BlastRadiusCalculator::compute_defeasibility_score("f", &graph);
240        assert!(score > 0.0);
241    }
242}