Skip to main content

cea_core/
graph.rs

1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3
4use petgraph::graph::{DiGraph, EdgeIndex, NodeIndex};
5use petgraph::visit::EdgeRef;
6use serde::{Deserialize, Serialize};
7
8use crate::calibration;
9use check_runner::EffectSignature;
10
11use crate::attribution::{edit_op_node_id, effect_node_id, AttributedRunResult, AttributionConfig};
12use crate::error::CeaCoreError;
13use crate::types::EditOpSignature;
14
15const GRAPH_SCHEMA_VERSION: u32 = 1;
16
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct EdgeStats {
19    /// Positive evidence (effect observed when the cause appeared).
20    pub alpha: f64,
21    /// Negative evidence (cause observed while this effect was absent).
22    pub beta: f64,
23    /// Total update events applied to the edge.
24    pub observations: u64,
25}
26
27impl Default for EdgeStats {
28    fn default() -> Self {
29        Self {
30            alpha: 1.0,
31            beta: 1.0,
32            observations: 0,
33        }
34    }
35}
36
37impl EdgeStats {
38    pub fn observe_positive(&mut self, amount: f64) {
39        let amount = amount.max(0.0);
40        self.alpha += amount;
41        self.observations += 1;
42    }
43
44    pub fn observe_negative(&mut self, amount: f64) {
45        let amount = amount.max(0.0);
46        self.beta += amount;
47        self.observations += 1;
48    }
49
50    pub fn mean(&self) -> f64 {
51        self.alpha / (self.alpha + self.beta).max(f64::EPSILON)
52    }
53
54    pub fn variance(&self) -> f64 {
55        let total = self.alpha + self.beta;
56        (self.alpha * self.beta) / ((total * total) * (total + 1.0)).max(f64::EPSILON)
57    }
58
59    pub fn confidence(&self) -> f64 {
60        let reliability = calibration::conservative_reliability(self.alpha, self.beta);
61        calibration::advisory_confidence(
62            reliability,
63            1.0,
64            self.observations as f64,
65            1,
66            calibration::MIN_SAMPLES_PER_SIGNATURE,
67        )
68    }
69}
70
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub enum CausalNode {
73    Cause(EditOpSignature),
74    Effect(EffectSignature),
75}
76
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78pub struct CausalEdge {
79    pub weight: f64,
80    pub count: u64,
81    pub confidence: f64,
82    pub stats: EdgeStats,
83}
84
85impl CausalEdge {
86    fn new(initial_weight: f64) -> Self {
87        let mut edge = Self {
88            weight: initial_weight.max(0.0),
89            count: 0,
90            confidence: 0.0,
91            stats: EdgeStats::default(),
92        };
93        edge.stats.observe_positive(initial_weight.max(0.0));
94        edge.sync_legacy_fields();
95        edge
96    }
97
98    fn reinforce_positive(&mut self, amount: f64) {
99        self.weight += amount.max(0.0);
100        self.stats.observe_positive(amount);
101        self.sync_legacy_fields();
102    }
103
104    fn reinforce_negative(&mut self, amount: f64) {
105        self.stats.observe_negative(amount);
106        self.sync_legacy_fields();
107    }
108
109    fn decay(&mut self, factor: f64) {
110        self.weight *= factor.clamp(0.0, 1.0);
111    }
112
113    fn sync_legacy_fields(&mut self) {
114        self.count = self.stats.observations;
115        self.confidence = self.stats.confidence();
116    }
117}
118
119#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
120pub struct CoverageSummary {
121    pub total_cause_nodes: usize,
122    pub total_effect_nodes: usize,
123    pub total_edges: usize,
124    pub mean_confidence: f64,
125}
126
127#[derive(Debug)]
128pub struct CausalGraph {
129    pub graph: DiGraph<CausalNode, CausalEdge>,
130    pub node_index_map: HashMap<String, NodeIndex>,
131}
132
133impl CausalGraph {
134    pub fn new() -> Self {
135        Self {
136            graph: DiGraph::new(),
137            node_index_map: HashMap::new(),
138        }
139    }
140
141    pub fn ensure_cause_node(&mut self, signature: &EditOpSignature) -> NodeIndex {
142        let node_id = edit_op_node_id(signature);
143        if let Some(index) = self.node_index_map.get(&node_id) {
144            return *index;
145        }
146
147        let index = self.graph.add_node(CausalNode::Cause(signature.clone()));
148        self.node_index_map.insert(node_id, index);
149        index
150    }
151
152    pub fn ensure_effect_node(&mut self, signature: &EffectSignature) -> NodeIndex {
153        let node_id = effect_node_id(signature);
154        if let Some(index) = self.node_index_map.get(&node_id) {
155            return *index;
156        }
157
158        let index = self.graph.add_node(CausalNode::Effect(signature.clone()));
159        self.node_index_map.insert(node_id, index);
160        index
161    }
162
163    pub fn update_edge(&mut self, cause_index: NodeIndex, effect_index: NodeIndex, score: f64) {
164        self.reinforce_edge(cause_index, effect_index, score, true);
165    }
166
167    pub fn ingest_run(
168        &mut self,
169        run: &AttributedRunResult,
170        config: &AttributionConfig,
171    ) -> Result<(), CeaCoreError> {
172        config.validate()?;
173        self.apply_decay(config.decay_factor);
174
175        let mut observed_effects_by_cause: HashMap<NodeIndex, HashSet<String>> = HashMap::new();
176        for triple in &run.triples {
177            let cause_index = self.ensure_cause_node(&triple.cause);
178            let effect_index = self.ensure_effect_node(&triple.effect);
179            let effect_id = effect_node_id(&triple.effect);
180            self.reinforce_edge(cause_index, effect_index, triple.weight, true);
181            observed_effects_by_cause
182                .entry(cause_index)
183                .or_default()
184                .insert(effect_id);
185        }
186
187        for (cause_index, observed_effects) in observed_effects_by_cause {
188            let edges = self
189                .graph
190                .edges(cause_index)
191                .map(|edge| (edge.id(), edge.target()))
192                .collect::<Vec<_>>();
193            for (edge_index, target_index) in edges {
194                let effect_id = match self.graph.node_weight(target_index) {
195                    Some(CausalNode::Effect(signature)) => effect_node_id(signature),
196                    _ => {
197                        return Err(CeaCoreError::GraphCorruption(
198                            "cause node points to a non-effect node".to_string(),
199                        ))
200                    }
201                };
202                if !observed_effects.contains(&effect_id) {
203                    self.reinforce_edge_by_index(edge_index, 1.0, false)?;
204                }
205            }
206        }
207
208        Ok(())
209    }
210
211    pub fn outgoing_edges(&self, node_index: NodeIndex) -> Vec<(NodeIndex, &CausalEdge)> {
212        self.graph
213            .edges(node_index)
214            .map(|edge| (edge.target(), edge.weight()))
215            .collect()
216    }
217
218    pub fn apply_decay(&mut self, factor: f64) {
219        for edge in self.graph.edge_weights_mut() {
220            edge.decay(factor);
221        }
222    }
223
224    pub fn coverage_summary(&self) -> CoverageSummary {
225        let mut total_cause_nodes = 0_usize;
226        let mut total_effect_nodes = 0_usize;
227        let mut confidence_sum = 0.0;
228        let mut edge_count = 0_usize;
229
230        for node in self.graph.node_weights() {
231            match node {
232                CausalNode::Cause(_) => total_cause_nodes += 1,
233                CausalNode::Effect(_) => total_effect_nodes += 1,
234            }
235        }
236
237        for edge in self.graph.edge_weights() {
238            edge_count += 1;
239            confidence_sum += edge.confidence;
240        }
241
242        CoverageSummary {
243            total_cause_nodes,
244            total_effect_nodes,
245            total_edges: edge_count,
246            mean_confidence: if edge_count == 0 {
247                0.0
248            } else {
249                confidence_sum / edge_count as f64
250            },
251        }
252    }
253
254    pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<(), CeaCoreError> {
255        let snapshot = GraphSnapshot::from_graph(self)?;
256        // postcard 1.1 with the use-std feature: to_stdvec replaces
257        // bincode 1.x's `bincode::serialize`. The error type is
258        // postcard::Error (single enum, vs bincode 2.x's split
259        // EncodeError/DecodeError), which converts via #[from] in
260        // CeaCoreError::Persistence.
261        let bytes = postcard::to_stdvec(&snapshot)?;
262        std::fs::write(path, bytes)?;
263        Ok(())
264    }
265
266    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, CeaCoreError> {
267        let bytes = std::fs::read(path)?;
268        // postcard 1.1: from_bytes replaces bincode 1.x's
269        // `bincode::deserialize`. Decode failures propagate as
270        // postcard::Error → CeaCoreError::Persistence via #[from].
271        // Prior code (bincode 1.x) surfaced decode failures as
272        // CeaCoreError::GraphCorruption. That was a domain-level
273        // decision (load failures mean the file is corrupt or
274        // wrong-version) — preserved here by mapping Persistence →
275        // GraphCorruption in the call site.
276        let snapshot: GraphSnapshot = postcard::from_bytes(&bytes)
277            .map_err(|e| CeaCoreError::GraphCorruption(e.to_string()))?;
278        snapshot.into_graph()
279    }
280
281    pub(crate) fn cause_nodes(&self) -> Vec<(NodeIndex, &EditOpSignature)> {
282        self.graph
283            .node_indices()
284            .filter_map(|index| match self.graph.node_weight(index) {
285                Some(CausalNode::Cause(signature)) => Some((index, signature)),
286                _ => None,
287            })
288            .collect()
289    }
290
291    fn reinforce_edge(
292        &mut self,
293        cause_index: NodeIndex,
294        effect_index: NodeIndex,
295        score: f64,
296        positive: bool,
297    ) {
298        if let Some(edge_index) = self.graph.find_edge(cause_index, effect_index) {
299            let _ = self.reinforce_edge_by_index(edge_index, score, positive);
300            return;
301        }
302
303        let mut edge = CausalEdge::new(score);
304        if !positive {
305            edge.reinforce_negative(score);
306        }
307        self.graph.add_edge(cause_index, effect_index, edge);
308    }
309
310    fn reinforce_edge_by_index(
311        &mut self,
312        edge_index: EdgeIndex,
313        score: f64,
314        positive: bool,
315    ) -> Result<(), CeaCoreError> {
316        let edge = self.graph.edge_weight_mut(edge_index).ok_or_else(|| {
317            CeaCoreError::GraphCorruption(format!("missing edge weight for edge {edge_index:?}"))
318        })?;
319        if positive {
320            edge.reinforce_positive(score);
321        } else {
322            edge.reinforce_negative(score);
323        }
324        Ok(())
325    }
326}
327
328impl Default for CausalGraph {
329    fn default() -> Self {
330        Self::new()
331    }
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
335struct GraphSnapshot {
336    version: u32,
337    nodes: Vec<NodeRecord>,
338    edges: Vec<EdgeRecord>,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
342struct NodeRecord {
343    id: String,
344    node: CausalNode,
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize)]
348struct EdgeRecord {
349    source_id: String,
350    target_id: String,
351    edge: CausalEdge,
352}
353
354impl GraphSnapshot {
355    fn from_graph(graph: &CausalGraph) -> Result<Self, CeaCoreError> {
356        let mut seen_ids = HashSet::new();
357        let mut nodes = Vec::new();
358        for index in graph.graph.node_indices() {
359            if let Some(node) = graph.graph.node_weight(index).cloned() {
360                let id = node_identifier(&node);
361                if !seen_ids.insert(id.clone()) {
362                    return Err(CeaCoreError::GraphCorruption(format!(
363                        "duplicate node id while saving graph: {id}"
364                    )));
365                }
366                nodes.push(NodeRecord { id, node });
367            }
368        }
369
370        let edges = graph
371            .graph
372            .edge_references()
373            .map(|edge| {
374                let source_id = graph
375                    .graph
376                    .node_weight(edge.source())
377                    .map(node_identifier)
378                    .expect("petgraph edge references must point to a valid source node");
379                let target_id = graph
380                    .graph
381                    .node_weight(edge.target())
382                    .map(node_identifier)
383                    .expect("petgraph edge references must point to a valid target node");
384                EdgeRecord {
385                    source_id,
386                    target_id,
387                    edge: edge.weight().clone(),
388                }
389            })
390            .collect::<Vec<_>>();
391
392        Ok(Self {
393            version: GRAPH_SCHEMA_VERSION,
394            nodes,
395            edges,
396        })
397    }
398
399    fn into_graph(self) -> Result<CausalGraph, CeaCoreError> {
400        if self.version != GRAPH_SCHEMA_VERSION {
401            return Err(CeaCoreError::UnsupportedSchemaVersion {
402                expected: GRAPH_SCHEMA_VERSION,
403                found: self.version,
404            });
405        }
406
407        let mut graph = CausalGraph::new();
408        for record in self.nodes {
409            let index = graph.graph.add_node(record.node);
410            graph.node_index_map.insert(record.id, index);
411        }
412
413        for edge in self.edges {
414            let source = graph
415                .node_index_map
416                .get(&edge.source_id)
417                .copied()
418                .ok_or_else(|| {
419                    CeaCoreError::GraphCorruption(format!(
420                        "missing source node during load: {}",
421                        edge.source_id
422                    ))
423                })?;
424            let target = graph
425                .node_index_map
426                .get(&edge.target_id)
427                .copied()
428                .ok_or_else(|| {
429                    CeaCoreError::GraphCorruption(format!(
430                        "missing target node during load: {}",
431                        edge.target_id
432                    ))
433                })?;
434            graph.graph.add_edge(source, target, edge.edge);
435        }
436
437        Ok(graph)
438    }
439}
440
441fn node_identifier(node: &CausalNode) -> String {
442    match node {
443        CausalNode::Cause(signature) => edit_op_node_id(signature),
444        CausalNode::Effect(signature) => effect_node_id(signature),
445    }
446}