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 pub alpha: f64,
21 pub beta: f64,
23 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 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 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}