1use edda_core::Event;
8use serde::Serialize;
9use std::collections::{HashMap, HashSet};
10
11#[derive(Debug, Clone, Serialize)]
13pub struct DecisionRisk {
14 pub event_id: String,
15 pub key: String,
16 pub value: String,
17 pub project: String,
18 pub risk_score: f64,
19 pub risk_level: String,
20 pub factors: RiskFactors,
21}
22
23#[derive(Debug, Clone, Serialize)]
25pub struct RiskFactors {
26 pub downstream_count: usize,
27 pub age_days: u32,
28 pub execution_refs: usize,
29 pub cross_project: bool,
30 pub has_approval: bool,
31}
32
33pub struct DecisionInput {
35 pub event_id: String,
36 pub key: String,
37 pub value: String,
38 pub project: String,
39 pub ts: Option<String>,
40}
41
42pub fn compute_decision_risks(
47 decisions: &[DecisionInput],
48 all_events: &[Event],
49 now_iso: &str,
50 cross_project_ids: &HashSet<String>,
51) -> Vec<DecisionRisk> {
52 let mut downstream_counts: HashMap<&str, usize> = HashMap::new();
54 let mut execution_counts: HashMap<&str, usize> = HashMap::new();
55 let mut approval_set: HashSet<&str> = HashSet::new();
56
57 for event in all_events {
58 for prov in &event.refs.provenance {
60 *downstream_counts.entry(prov.target.as_str()).or_insert(0) += 1;
61 }
62
63 if event.event_type == "execution_event" {
65 for prov in &event.refs.provenance {
66 *execution_counts.entry(prov.target.as_str()).or_insert(0) += 1;
67 }
68 }
69
70 if event.event_type == "approval" {
72 for prov in &event.refs.provenance {
73 approval_set.insert(prov.target.as_str());
74 }
75 for eid in &event.refs.events {
76 approval_set.insert(eid.as_str());
77 }
78 }
79 }
80
81 decisions
82 .iter()
83 .map(|d| {
84 let downstream_count = downstream_counts
85 .get(d.event_id.as_str())
86 .copied()
87 .unwrap_or(0);
88 let execution_refs = execution_counts
89 .get(d.event_id.as_str())
90 .copied()
91 .unwrap_or(0);
92 let cross_project = cross_project_ids.contains(&d.event_id);
93 let has_approval = approval_set.contains(d.event_id.as_str());
94 let age_days = compute_age_days(d.ts.as_deref(), now_iso);
95
96 let factors = RiskFactors {
97 downstream_count,
98 age_days,
99 execution_refs,
100 cross_project,
101 has_approval,
102 };
103
104 let risk_score = score_from_factors(&factors);
105 let risk_level = level_from_score(risk_score).to_string();
106
107 DecisionRisk {
108 event_id: d.event_id.clone(),
109 key: d.key.clone(),
110 value: d.value.clone(),
111 project: d.project.clone(),
112 risk_score,
113 risk_level,
114 factors,
115 }
116 })
117 .collect()
118}
119
120fn score_from_factors(f: &RiskFactors) -> f64 {
122 let downstream = (f.downstream_count as f64 / 5.0).min(1.0) * 0.3;
123 let age = (f.age_days as f64 / 30.0).min(1.0) * 0.15;
124 let exec = (f.execution_refs as f64 / 10.0).min(1.0) * 0.25;
125 let cross = if f.cross_project { 0.2 } else { 0.0 };
126 let approval = if f.has_approval { 0.1 } else { 0.0 };
129 downstream + age + exec + cross + approval
130}
131
132fn level_from_score(score: f64) -> &'static str {
134 if score > 0.6 {
135 "high"
136 } else if score >= 0.3 {
137 "medium"
138 } else {
139 "low"
140 }
141}
142
143fn compute_age_days(ts: Option<&str>, now_iso: &str) -> u32 {
145 let Some(ts) = ts else { return 0 };
146 let ts_date = &ts[..10.min(ts.len())];
148 let now_date = &now_iso[..10.min(now_iso.len())];
149
150 let parse = |d: &str| -> Option<(i32, u32, u32)> {
151 let parts: Vec<&str> = d.split('-').collect();
152 if parts.len() < 3 {
153 return None;
154 }
155 Some((
156 parts[0].parse().ok()?,
157 parts[1].parse().ok()?,
158 parts[2].parse().ok()?,
159 ))
160 };
161
162 let Some((y1, m1, d1)) = parse(ts_date) else {
163 return 0;
164 };
165 let Some((y2, m2, d2)) = parse(now_date) else {
166 return 0;
167 };
168
169 let days1 = y1 * 365 + m1 as i32 * 30 + d1 as i32;
171 let days2 = y2 * 365 + m2 as i32 * 30 + d2 as i32;
172 (days2 - days1).max(0) as u32
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use edda_core::types::Refs;
179
180 fn make_decision(event_id: &str, key: &str, value: &str, ts: &str) -> DecisionInput {
181 DecisionInput {
182 event_id: event_id.to_string(),
183 key: key.to_string(),
184 value: value.to_string(),
185 project: "test-project".to_string(),
186 ts: Some(ts.to_string()),
187 }
188 }
189
190 fn make_event(event_type: &str, refs: Refs) -> Event {
191 Event {
192 event_id: format!("evt_{}", uuid_stub()),
193 ts: "2026-03-12T00:00:00Z".to_string(),
194 event_type: event_type.to_string(),
195 branch: "main".to_string(),
196 parent_hash: None,
197 hash: String::new(),
198 payload: serde_json::json!({}),
199 refs,
200 schema_version: 1,
201 digests: vec![],
202 event_family: None,
203 event_level: None,
204 }
205 }
206
207 static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
208 fn uuid_stub() -> u32 {
209 COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
210 }
211
212 #[test]
213 fn risk_score_zero_for_isolated_new_decision() {
214 let decisions = vec![make_decision("evt_1", "db.engine", "sqlite", "2026-03-12")];
215 let risks =
216 compute_decision_risks(&decisions, &[], "2026-03-12T00:00:00Z", &HashSet::new());
217 assert_eq!(risks.len(), 1);
218 assert_eq!(risks[0].risk_score, 0.0);
219 assert_eq!(risks[0].risk_level, "low");
220 }
221
222 #[test]
223 fn risk_score_high_for_old_decision_with_many_refs() {
224 let decisions = vec![make_decision(
225 "evt_old",
226 "auth.strategy",
227 "JWT",
228 "2025-01-01",
229 )];
230
231 let mut events = Vec::new();
233 for _ in 0..6 {
234 events.push(make_event(
235 "decision",
236 Refs {
237 provenance: vec![edda_core::types::Provenance {
238 target: "evt_old".to_string(),
239 rel: "supersedes".to_string(),
240 note: None,
241 }],
242 ..Default::default()
243 },
244 ));
245 }
246 for _ in 0..12 {
247 events.push(make_event(
248 "execution_event",
249 Refs {
250 provenance: vec![edda_core::types::Provenance {
251 target: "evt_old".to_string(),
252 rel: "based_on".to_string(),
253 note: None,
254 }],
255 ..Default::default()
256 },
257 ));
258 }
259
260 let mut cross = HashSet::new();
261 cross.insert("evt_old".to_string());
262
263 let risks = compute_decision_risks(&decisions, &events, "2026-03-12T00:00:00Z", &cross);
264 assert_eq!(risks[0].risk_level, "high");
265 assert!(risks[0].risk_score > 0.6);
266 }
267
268 #[test]
269 fn risk_level_thresholds() {
270 assert_eq!(level_from_score(0.0), "low");
271 assert_eq!(level_from_score(0.29), "low");
272 assert_eq!(level_from_score(0.3), "medium");
273 assert_eq!(level_from_score(0.6), "medium");
274 assert_eq!(level_from_score(0.61), "high");
275 assert_eq!(level_from_score(1.0), "high");
276 }
277
278 #[test]
279 fn cross_project_flag_increases_risk() {
280 let decisions = vec![make_decision("evt_cp", "api.version", "v2", "2026-03-12")];
281
282 let risks_no_cross =
283 compute_decision_risks(&decisions, &[], "2026-03-12T00:00:00Z", &HashSet::new());
284
285 let mut cross = HashSet::new();
286 cross.insert("evt_cp".to_string());
287 let risks_cross = compute_decision_risks(&decisions, &[], "2026-03-12T00:00:00Z", &cross);
288
289 assert!(risks_cross[0].risk_score > risks_no_cross[0].risk_score);
290 assert_eq!(
291 risks_cross[0].risk_score - risks_no_cross[0].risk_score,
292 0.2
293 );
294 }
295}