Skip to main content

edda_aggregate/
graph.rs

1//! Decision dependency graph extraction from provenance references.
2
3use edda_ledger::Ledger;
4use edda_store::registry::ProjectEntry;
5use serde::Serialize;
6use std::collections::HashSet;
7use std::path::Path;
8
9/// A node in the dependency graph representing a decision event.
10#[derive(Debug, Clone, Serialize)]
11pub struct GraphNode {
12    pub event_id: String,
13    pub key: String,
14    pub value: String,
15    pub project: String,
16    pub ts: String,
17}
18
19/// An edge in the dependency graph representing a provenance relationship.
20#[derive(Debug, Clone, Serialize)]
21pub struct GraphEdge {
22    pub source: String,
23    pub target: String,
24    pub rel: String,
25}
26
27/// The full dependency graph: nodes (decisions) + edges (provenance links).
28#[derive(Debug, Clone, Serialize)]
29pub struct DependencyGraph {
30    pub nodes: Vec<GraphNode>,
31    pub edges: Vec<GraphEdge>,
32}
33
34/// Build a dependency graph from all decision events across registered projects.
35///
36/// Nodes are decision events; edges are provenance links between them.
37pub fn build_dependency_graph(projects: &[ProjectEntry]) -> DependencyGraph {
38    let mut nodes = Vec::new();
39    let mut edges = Vec::new();
40    let mut decision_ids: HashSet<String> = HashSet::new();
41
42    for entry in projects {
43        let root = Path::new(&entry.path);
44        let ledger = match Ledger::open(root) {
45            Ok(l) => l,
46            Err(_) => continue,
47        };
48
49        let events = match ledger.iter_events() {
50            Ok(e) => e,
51            Err(_) => continue,
52        };
53
54        for event in &events {
55            if edda_core::decision::is_decision(&event.payload) {
56                let dec = edda_core::decision::extract_decision(&event.payload);
57                let key = dec.as_ref().map(|d| d.key.clone()).unwrap_or_default();
58                let value = dec.as_ref().map(|d| d.value.clone()).unwrap_or_default();
59
60                nodes.push(GraphNode {
61                    event_id: event.event_id.clone(),
62                    key,
63                    value,
64                    project: entry.name.clone(),
65                    ts: event.ts.clone(),
66                });
67                decision_ids.insert(event.event_id.clone());
68            }
69
70            // Extract provenance edges
71            for prov in &event.refs.provenance {
72                edges.push(GraphEdge {
73                    source: event.event_id.clone(),
74                    target: prov.target.clone(),
75                    rel: prov.rel.clone(),
76                });
77            }
78        }
79    }
80
81    // Filter edges to only include those between known decision nodes
82    let edges = edges
83        .into_iter()
84        .filter(|e| decision_ids.contains(&e.source) || decision_ids.contains(&e.target))
85        .collect();
86
87    DependencyGraph { nodes, edges }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    fn make_decision_payload(
95        key: &str,
96        value: &str,
97        reason: Option<&str>,
98    ) -> edda_core::types::DecisionPayload {
99        edda_core::types::DecisionPayload {
100            key: key.to_string(),
101            value: value.to_string(),
102            reason: reason.map(|r| r.to_string()),
103            scope: None,
104            authority: None,
105            affected_paths: None,
106            tags: None,
107            review_after: None,
108            reversibility: None,
109            village_id: None,
110            cites: None,
111        }
112    }
113
114    #[test]
115    fn empty_projects_returns_empty_graph() {
116        let graph = build_dependency_graph(&[]);
117        assert!(graph.nodes.is_empty());
118        assert!(graph.edges.is_empty());
119    }
120
121    #[test]
122    fn single_decision_produces_node_no_edges() {
123        let tmp = tempfile::tempdir().unwrap();
124        let root = tmp.path();
125
126        let paths = edda_ledger::EddaPaths::discover(root);
127        edda_ledger::ledger::init_workspace(&paths).unwrap();
128        edda_ledger::ledger::init_head(&paths, "main").unwrap();
129
130        let ledger = Ledger::open(root).unwrap();
131        let payload = make_decision_payload("db.engine", "sqlite", Some("embedded"));
132        let event = edda_core::event::new_decision_event("main", None, "user", &payload).unwrap();
133        ledger.append_event(&event).unwrap();
134
135        let entry = ProjectEntry {
136            project_id: "test".to_string(),
137            path: root.to_string_lossy().to_string(),
138            name: "test-project".to_string(),
139            registered_at: "2026-03-01T00:00:00Z".to_string(),
140            last_seen: "2026-03-01T00:00:00Z".to_string(),
141            group: None,
142        };
143
144        let graph = build_dependency_graph(&[entry]);
145        assert_eq!(graph.nodes.len(), 1);
146        assert_eq!(graph.nodes[0].key, "db.engine");
147        assert_eq!(graph.nodes[0].value, "sqlite");
148        assert!(graph.edges.is_empty());
149    }
150
151    #[test]
152    fn superseded_decision_produces_edge() {
153        let tmp = tempfile::tempdir().unwrap();
154        let root = tmp.path();
155
156        let paths = edda_ledger::EddaPaths::discover(root);
157        edda_ledger::ledger::init_workspace(&paths).unwrap();
158        edda_ledger::ledger::init_head(&paths, "main").unwrap();
159
160        let ledger = Ledger::open(root).unwrap();
161
162        // First decision
163        let payload1 = make_decision_payload("db.engine", "sqlite", Some("mvp"));
164        let event1 = edda_core::event::new_decision_event("main", None, "user", &payload1).unwrap();
165        ledger.append_event(&event1).unwrap();
166        let event1_id = event1.event_id.clone();
167
168        // Second decision that supersedes the first
169        let payload2 = make_decision_payload("db.engine", "postgres", Some("scale"));
170        let mut event2 =
171            edda_core::event::new_decision_event("main", None, "user", &payload2).unwrap();
172        event2.refs.provenance.push(edda_core::types::Provenance {
173            target: event1_id,
174            rel: edda_core::types::rel::SUPERSEDES.to_string(),
175            note: Some("key 'db.engine' re-decided".to_string()),
176        });
177        event2.parent_hash = ledger.last_event_hash().unwrap();
178        edda_core::event::finalize_event(&mut event2).unwrap();
179        ledger.append_event(&event2).unwrap();
180
181        let entry = ProjectEntry {
182            project_id: "test".to_string(),
183            path: root.to_string_lossy().to_string(),
184            name: "test-project".to_string(),
185            registered_at: "2026-03-01T00:00:00Z".to_string(),
186            last_seen: "2026-03-01T00:00:00Z".to_string(),
187            group: None,
188        };
189
190        let graph = build_dependency_graph(&[entry]);
191        assert_eq!(graph.nodes.len(), 2);
192        // The superseding decision should create a provenance edge
193        assert!(
194            !graph.edges.is_empty(),
195            "Expected at least one edge from supersession"
196        );
197    }
198}