Skip to main content

cea_store/
lib.rs

1//! # cea-store
2//!
3//! Storage contract for causal edit attribution graphs.
4//!
5//! This Tier 3 crate defines the narrow persistence boundary used by
6//! `cea-core`. It owns adapter-facing row shapes and idempotent graph update
7//! semantics, but not attribution policy or SQLite implementation details.
8
9#[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    /// Mirrors `cea_core::EdgeStats::alpha` for persisted edges.
41    pub alpha: f64,
42    /// Mirrors `cea_core::EdgeStats::beta` for persisted edges.
43    pub beta: f64,
44    /// Mirrors `cea_core::EdgeStats::observations`.
45    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    /// Persist one positive observation for the edge identified by
60    /// `(cause_node_id, effect_node_id, version_id)`.
61    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    /// Return the persisted effect ids previously observed for one
70    /// `(cause_node_id, version_id)` pair.
71    fn load_effect_ids_for_cause(
72        &self,
73        cause_node_id: &str,
74        version_id: &str,
75    ) -> Result<Vec<String>, CeaStoreError>;
76    /// Persist one negative observation for an existing edge when the cause
77    /// reappears but the effect is absent in the new run.
78    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}