1#[derive(Debug, thiserror::Error)]
10pub enum CeaStoreError {
11 #[error("serialization error: {0}")]
12 Serialization(#[from] serde_json::Error),
13
14 #[error("{0}")]
15 Backend(String),
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum UpdateResult {
20 Applied {
21 edges_added: usize,
22 edges_updated: usize,
23 },
24 AlreadyProcessed,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct CeaNodeRow {
29 pub node_id: String,
30 pub node_kind: String,
31 pub sig_json: String,
32}
33
34#[derive(Debug, Clone, PartialEq)]
35pub struct CeaEdgeRow {
36 pub edge_id: String,
37 pub cause_node_id: String,
38 pub effect_node_id: String,
39 pub weight: f64,
40 pub alpha: f64,
42 pub beta: f64,
44 pub count: i64,
46 pub confidence: f64,
47 pub version_id: String,
48 pub last_seen: String,
49}
50
51pub trait CeaStoreWriteTx {
52 fn has_run(&self, run_hash: &str) -> Result<bool, CeaStoreError>;
53 fn upsert_node(
54 &self,
55 node_id: &str,
56 node_kind: &str,
57 sig_json: &str,
58 ) -> Result<(), CeaStoreError>;
59 fn upsert_edge(
62 &self,
63 edge_id: &str,
64 cause_node_id: &str,
65 effect_node_id: &str,
66 weight_delta: f64,
67 version_id: &str,
68 ) -> Result<bool, CeaStoreError>;
69 fn load_effect_ids_for_cause(
72 &self,
73 cause_node_id: &str,
74 version_id: &str,
75 ) -> Result<Vec<String>, CeaStoreError>;
76 fn reinforce_negative_edge(
79 &self,
80 cause_node_id: &str,
81 effect_node_id: &str,
82 amount: f64,
83 version_id: &str,
84 ) -> Result<(), CeaStoreError>;
85 fn insert_run_log(
86 &self,
87 run_hash: &str,
88 eval_id: &str,
89 edges_added: i64,
90 edges_updated: i64,
91 ) -> Result<(), CeaStoreError>;
92}
93
94pub trait CeaStore {
95 fn with_write_tx<T, F>(&self, f: F) -> Result<T, CeaStoreError>
96 where
97 F: FnOnce(&dyn CeaStoreWriteTx) -> Result<T, CeaStoreError>;
98
99 fn load_nodes(&self) -> Result<Vec<CeaNodeRow>, CeaStoreError>;
100 fn load_edges(&self, version_id: Option<&str>) -> Result<Vec<CeaEdgeRow>, CeaStoreError>;
101}
102
103pub fn update_graph<S: CeaStore>(
104 store: &S,
105 result: &cea_core::AttributedRunResult,
106 eval_id: &str,
107 version_id: &str,
108 decay_factor: f64,
109) -> Result<UpdateResult, CeaStoreError> {
110 use std::collections::{BTreeMap, BTreeSet};
111
112 store.with_write_tx(|tx| {
113 if tx.has_run(&result.run_hash)? {
114 return Ok(UpdateResult::AlreadyProcessed);
115 }
116
117 let mut edges_added = 0_usize;
118 let mut edges_updated = 0_usize;
119 let mut observed_effects_by_cause = BTreeMap::<String, BTreeSet<String>>::new();
120
121 for triple in &result.triples {
122 let cause_id = cea_core::edit_op_node_id(&triple.cause);
123 let effect_id = cea_core::effect_node_id(&triple.effect);
124
125 tx.upsert_node(&cause_id, "cause", &serde_json::to_string(&triple.cause)?)?;
126 tx.upsert_node(
127 &effect_id,
128 "effect",
129 &serde_json::to_string(&triple.effect)?,
130 )?;
131
132 let score =
133 cea_core::attribution_score(triple.distance, &triple.effect.severity, decay_factor);
134 let edge_id = format!("{cause_id}_{effect_id}_{version_id}");
135 if tx.upsert_edge(&edge_id, &cause_id, &effect_id, score, version_id)? {
136 edges_added += 1;
137 } else {
138 edges_updated += 1;
139 }
140
141 observed_effects_by_cause
142 .entry(cause_id)
143 .or_default()
144 .insert(effect_id);
145 }
146
147 for (cause_id, observed_effects) in &observed_effects_by_cause {
148 for effect_id in tx.load_effect_ids_for_cause(cause_id, version_id)? {
149 if observed_effects.contains(&effect_id) {
150 continue;
151 }
152
153 tx.reinforce_negative_edge(cause_id, &effect_id, 1.0, version_id)?;
154 edges_updated += 1;
155 }
156 }
157
158 tx.insert_run_log(
159 &result.run_hash,
160 eval_id,
161 edges_added as i64,
162 edges_updated as i64,
163 )?;
164
165 Ok(UpdateResult::Applied {
166 edges_added,
167 edges_updated,
168 })
169 })
170}
171
172pub fn load_graph<S: CeaStore>(
173 store: &S,
174 version_id: Option<&str>,
175) -> Result<cea_core::CausalGraph, CeaStoreError> {
176 let nodes = store.load_nodes()?;
177 let edges = store.load_edges(version_id)?;
178 let mut graph = cea_core::CausalGraph::new();
179
180 for node in &nodes {
181 match node.node_kind.as_str() {
182 "cause" => {
183 if let Ok(signature) =
184 serde_json::from_str::<cea_core::EditOpSignature>(&node.sig_json)
185 {
186 let index = graph.graph.add_node(cea_core::CausalNode::Cause(signature));
187 graph.node_index_map.insert(node.node_id.clone(), index);
188 }
189 }
190 "effect" => {
191 if let Ok(signature) =
192 serde_json::from_str::<check_runner::EffectSignature>(&node.sig_json)
193 {
194 let index = graph
195 .graph
196 .add_node(cea_core::CausalNode::Effect(signature));
197 graph.node_index_map.insert(node.node_id.clone(), index);
198 }
199 }
200 _ => {}
201 }
202 }
203
204 for edge in &edges {
205 let cause_index = graph.node_index_map.get(&edge.cause_node_id);
206 let effect_index = graph.node_index_map.get(&edge.effect_node_id);
207 if let (Some(cause_index), Some(effect_index)) = (cause_index, effect_index) {
208 let stats = cea_core::EdgeStats {
209 alpha: edge.alpha,
210 beta: edge.beta,
211 observations: edge.count.max(0) as u64,
212 };
213 graph.graph.add_edge(
214 *cause_index,
215 *effect_index,
216 cea_core::CausalEdge {
217 weight: edge.weight,
218 count: edge.count as u64,
219 confidence: stats.confidence(),
220 stats,
221 },
222 );
223 }
224 }
225
226 Ok(graph)
227}
228
229#[cfg(test)]
230mod tests {
231 use std::cell::RefCell;
232 use std::collections::{BTreeMap, BTreeSet};
233
234 use cea_core::{
235 AnchorKind, AttributedRunResult, AttributionTriple, EditOpKind, EditOpSignature, FileIndex,
236 OpIndex, ScopeTag,
237 };
238 use check_runner::{CheckResult, EffectSignature, ParsedCheckOutput};
239
240 use super::*;
241
242 #[derive(Clone, Default)]
243 struct FakeState {
244 run_hashes: BTreeSet<String>,
245 nodes: BTreeMap<String, CeaNodeRow>,
246 edges: BTreeMap<(String, String, String), CeaEdgeRow>,
247 }
248
249 struct FakeStore {
250 state: RefCell<FakeState>,
251 fail_run_log: bool,
252 }
253
254 struct FakeWriteTx<'a> {
255 state: &'a RefCell<FakeState>,
256 fail_run_log: bool,
257 }
258
259 impl CeaStoreWriteTx for FakeWriteTx<'_> {
260 fn has_run(&self, run_hash: &str) -> Result<bool, CeaStoreError> {
261 Ok(self.state.borrow().run_hashes.contains(run_hash))
262 }
263
264 fn upsert_node(
265 &self,
266 node_id: &str,
267 node_kind: &str,
268 sig_json: &str,
269 ) -> Result<(), CeaStoreError> {
270 let mut state = self.state.borrow_mut();
271 state
272 .nodes
273 .entry(node_id.to_string())
274 .or_insert(CeaNodeRow {
275 node_id: node_id.to_string(),
276 node_kind: node_kind.to_string(),
277 sig_json: sig_json.to_string(),
278 });
279 Ok(())
280 }
281
282 fn upsert_edge(
283 &self,
284 edge_id: &str,
285 cause_node_id: &str,
286 effect_node_id: &str,
287 weight_delta: f64,
288 version_id: &str,
289 ) -> Result<bool, CeaStoreError> {
290 let mut state = self.state.borrow_mut();
291 let key = (
292 cause_node_id.to_string(),
293 effect_node_id.to_string(),
294 version_id.to_string(),
295 );
296 match state.edges.get_mut(&key) {
297 Some(edge) => {
298 let mut stats = cea_core::EdgeStats {
299 alpha: edge.alpha,
300 beta: edge.beta,
301 observations: edge.count.max(0) as u64,
302 };
303 stats.observe_positive(weight_delta);
304 edge.weight += weight_delta;
305 edge.alpha = stats.alpha;
306 edge.beta = stats.beta;
307 edge.count = stats.observations as i64;
308 edge.confidence = stats.confidence();
309 Ok(false)
310 }
311 None => {
312 let mut stats = cea_core::EdgeStats::default();
313 stats.observe_positive(weight_delta);
314 state.edges.insert(
315 key,
316 CeaEdgeRow {
317 edge_id: edge_id.to_string(),
318 cause_node_id: cause_node_id.to_string(),
319 effect_node_id: effect_node_id.to_string(),
320 weight: weight_delta,
321 alpha: stats.alpha,
322 beta: stats.beta,
323 count: stats.observations as i64,
324 confidence: stats.confidence(),
325 version_id: version_id.to_string(),
326 last_seen: "now".to_string(),
327 },
328 );
329 Ok(true)
330 }
331 }
332 }
333
334 fn insert_run_log(
335 &self,
336 run_hash: &str,
337 _eval_id: &str,
338 _edges_added: i64,
339 _edges_updated: i64,
340 ) -> Result<(), CeaStoreError> {
341 if self.fail_run_log {
342 return Err(CeaStoreError::Backend(
343 "injected run log failure".to_string(),
344 ));
345 }
346
347 self.state
348 .borrow_mut()
349 .run_hashes
350 .insert(run_hash.to_string());
351 Ok(())
352 }
353
354 fn load_effect_ids_for_cause(
355 &self,
356 cause_node_id: &str,
357 version_id: &str,
358 ) -> Result<Vec<String>, CeaStoreError> {
359 let state = self.state.borrow();
360 let mut effect_ids = state
361 .edges
362 .iter()
363 .filter_map(|((stored_cause_id, effect_id, stored_version_id), _)| {
364 (stored_cause_id == cause_node_id && stored_version_id == version_id)
365 .then_some(effect_id.clone())
366 })
367 .collect::<Vec<_>>();
368 effect_ids.sort();
369 Ok(effect_ids)
370 }
371
372 fn reinforce_negative_edge(
373 &self,
374 cause_node_id: &str,
375 effect_node_id: &str,
376 amount: f64,
377 version_id: &str,
378 ) -> Result<(), CeaStoreError> {
379 let mut state = self.state.borrow_mut();
380 let key = (
381 cause_node_id.to_string(),
382 effect_node_id.to_string(),
383 version_id.to_string(),
384 );
385 let edge = state.edges.get_mut(&key).ok_or_else(|| {
386 CeaStoreError::Backend(format!(
387 "missing edge for negative observation: {cause_node_id} -> {effect_node_id} ({version_id})"
388 ))
389 })?;
390 let mut stats = cea_core::EdgeStats {
391 alpha: edge.alpha,
392 beta: edge.beta,
393 observations: edge.count.max(0) as u64,
394 };
395 stats.observe_negative(amount);
396 edge.alpha = stats.alpha;
397 edge.beta = stats.beta;
398 edge.count = stats.observations as i64;
399 edge.confidence = stats.confidence();
400 edge.last_seen = "now".to_string();
401 Ok(())
402 }
403 }
404
405 impl CeaStore for FakeStore {
406 fn with_write_tx<T, F>(&self, f: F) -> Result<T, CeaStoreError>
407 where
408 F: FnOnce(&dyn CeaStoreWriteTx) -> Result<T, CeaStoreError>,
409 {
410 let working = RefCell::new(self.state.borrow().clone());
411 let tx = FakeWriteTx {
412 state: &working,
413 fail_run_log: self.fail_run_log,
414 };
415 let result = f(&tx);
416 if result.is_ok() {
417 *self.state.borrow_mut() = working.into_inner();
418 }
419 result
420 }
421
422 fn load_nodes(&self) -> Result<Vec<CeaNodeRow>, CeaStoreError> {
423 Ok(self.state.borrow().nodes.values().cloned().collect())
424 }
425
426 fn load_edges(&self, version_id: Option<&str>) -> Result<Vec<CeaEdgeRow>, CeaStoreError> {
427 Ok(self
428 .state
429 .borrow()
430 .edges
431 .values()
432 .filter(|edge| match version_id {
433 Some(version) => edge.version_id == version,
434 None => true,
435 })
436 .cloned()
437 .collect())
438 }
439 }
440
441 fn sample_signature(seed: &str) -> EditOpSignature {
442 EditOpSignature {
443 op_kind: EditOpKind::Insert,
444 anchor_kind: AnchorKind::AfterLine,
445 lines_added: 2,
446 lines_removed: 0,
447 context_hash: format!("{seed:0>8}"),
448 file_extension: "rs".to_string(),
449 scope_tag: ScopeTag::Function,
450 op_index: OpIndex(0),
451 file_index: FileIndex(0),
452 }
453 }
454
455 fn sample_effect(seed: &str) -> EffectSignature {
456 EffectSignature {
457 check_kind: "clippy".to_string(),
458 outcome: "warning".to_string(),
459 severity: "warning".to_string(),
460 message_class: format!("unused_{seed}"),
461 line_offset_from_edit: Some(1),
462 }
463 }
464
465 fn sample_result(seed: &str) -> AttributedRunResult {
466 sample_result_with(seed, seed)
467 }
468
469 fn sample_result_with(cause_seed: &str, effect_seed: &str) -> AttributedRunResult {
470 let triple = AttributionTriple {
471 cause: sample_signature(cause_seed),
472 effect: sample_effect(effect_seed),
473 distance: 2,
474 weight: 1.0,
475 };
476
477 AttributedRunResult::new(
478 vec![triple],
479 CheckResult {
480 fmt_pass: true,
481 clippy_pass: false,
482 test_pass: true,
483 fmt_output: ParsedCheckOutput::default(),
484 clippy_output: ParsedCheckOutput::default(),
485 test_output: ParsedCheckOutput::default(),
486 total_duration_ms: 1,
487 },
488 )
489 }
490
491 #[test]
492 fn update_graph_rolls_back_when_run_log_insert_fails() {
493 let store = FakeStore {
494 state: RefCell::new(FakeState::default()),
495 fail_run_log: true,
496 };
497 let result = sample_result("rollback");
498
499 let err = update_graph(&store, &result, "eval-fail", "v1", 0.95).unwrap_err();
500 assert!(matches!(err, CeaStoreError::Backend(_)));
501 assert!(store.load_nodes().unwrap().is_empty());
502 assert!(store.load_edges(None).unwrap().is_empty());
503 }
504
505 #[test]
506 fn update_graph_is_idempotent_after_commit() {
507 let store = FakeStore {
508 state: RefCell::new(FakeState::default()),
509 fail_run_log: false,
510 };
511 let result = sample_result("once");
512
513 let first = update_graph(&store, &result, "eval-ok", "v1", 0.95).unwrap();
514 assert_eq!(
515 first,
516 UpdateResult::Applied {
517 edges_added: 1,
518 edges_updated: 0,
519 }
520 );
521 let second = update_graph(&store, &result, "eval-ok", "v1", 0.95).unwrap();
522 assert_eq!(second, UpdateResult::AlreadyProcessed);
523 assert_eq!(store.load_nodes().unwrap().len(), 2);
524 assert_eq!(store.load_edges(None).unwrap().len(), 1);
525 }
526
527 #[test]
528 fn update_graph_persists_negative_evidence_for_absent_effects() {
529 let store = FakeStore {
530 state: RefCell::new(FakeState::default()),
531 fail_run_log: false,
532 };
533
534 update_graph(
535 &store,
536 &sample_result_with("shared", "one"),
537 "eval-1",
538 "v1",
539 0.95,
540 )
541 .unwrap();
542 let second = update_graph(
543 &store,
544 &sample_result_with("shared", "two"),
545 "eval-2",
546 "v1",
547 0.95,
548 )
549 .unwrap();
550 assert_eq!(
551 second,
552 UpdateResult::Applied {
553 edges_added: 1,
554 edges_updated: 1,
555 }
556 );
557
558 let cause_id = cea_core::edit_op_node_id(&sample_signature("shared"));
559 let first_effect_id = cea_core::effect_node_id(&sample_effect("one"));
560 let second_effect_id = cea_core::effect_node_id(&sample_effect("two"));
561 let edges = store.load_edges(Some("v1")).unwrap();
562 let first_edge = edges
563 .iter()
564 .find(|edge| edge.cause_node_id == cause_id && edge.effect_node_id == first_effect_id)
565 .unwrap();
566 let second_edge = edges
567 .iter()
568 .find(|edge| edge.cause_node_id == cause_id && edge.effect_node_id == second_effect_id)
569 .unwrap();
570
571 assert_eq!(first_edge.beta, 2.0);
572 assert_eq!(first_edge.count, 2);
573 assert_eq!(second_edge.beta, 1.0);
574 assert_eq!(second_edge.count, 1);
575 }
576
577 #[test]
578 fn load_graph_filters_edges_by_version() {
579 let store = FakeStore {
580 state: RefCell::new(FakeState::default()),
581 fail_run_log: false,
582 };
583
584 let result_v1 = sample_result("v1");
585 let result_v2 = sample_result("v2");
586 update_graph(&store, &result_v1, "eval-1", "v1", 0.95).unwrap();
587 update_graph(&store, &result_v2, "eval-2", "v2", 0.95).unwrap();
588
589 let filtered = load_graph(&store, Some("v1")).unwrap();
590 let all = load_graph(&store, None).unwrap();
591 assert_eq!(filtered.graph.edge_count(), 1);
592 assert_eq!(all.graph.edge_count(), 2);
593 }
594
595 #[test]
596 fn load_graph_restores_persisted_edge_stats() {
597 let cause = sample_signature("stats");
598 let effect = sample_effect("stats");
599 let cause_id = cea_core::edit_op_node_id(&cause);
600 let effect_id = cea_core::effect_node_id(&effect);
601 let stats = cea_core::EdgeStats {
602 alpha: 3.5,
603 beta: 2.25,
604 observations: 4,
605 };
606 let store = FakeStore {
607 state: RefCell::new(FakeState {
608 run_hashes: BTreeSet::new(),
609 nodes: BTreeMap::from([
610 (
611 cause_id.clone(),
612 CeaNodeRow {
613 node_id: cause_id.clone(),
614 node_kind: "cause".to_string(),
615 sig_json: serde_json::to_string(&cause).unwrap(),
616 },
617 ),
618 (
619 effect_id.clone(),
620 CeaNodeRow {
621 node_id: effect_id.clone(),
622 node_kind: "effect".to_string(),
623 sig_json: serde_json::to_string(&effect).unwrap(),
624 },
625 ),
626 ]),
627 edges: BTreeMap::from([(
628 (cause_id.clone(), effect_id.clone(), "v1".to_string()),
629 CeaEdgeRow {
630 edge_id: format!("{cause_id}_{effect_id}_v1"),
631 cause_node_id: cause_id.clone(),
632 effect_node_id: effect_id.clone(),
633 weight: 2.5,
634 alpha: stats.alpha,
635 beta: stats.beta,
636 count: stats.observations as i64,
637 confidence: 0.0,
638 version_id: "v1".to_string(),
639 last_seen: "now".to_string(),
640 },
641 )]),
642 }),
643 fail_run_log: false,
644 };
645
646 let graph = load_graph(&store, Some("v1")).unwrap();
647 let cause_index = graph.node_index_map[&cause_id];
648 let effect_index = graph.node_index_map[&effect_id];
649 let edge = graph.graph.find_edge(cause_index, effect_index).unwrap();
650 let edge = graph.graph.edge_weight(edge).unwrap();
651
652 assert_eq!(edge.stats, stats);
653 assert_eq!(edge.count, stats.observations);
654 assert!((edge.confidence - stats.confidence()).abs() < 1e-12);
655 }
656}