Skip to main content

edda_aggregate/
risk.rs

1//! Override risk scoring for active decisions.
2//!
3//! Each decision receives a `risk_score` (0.0..1.0) computed from five weighted
4//! factors: downstream dependents, age, execution references, cross-project
5//! usage, and approval status.
6
7use edda_core::Event;
8use serde::Serialize;
9use std::collections::{HashMap, HashSet};
10
11/// Risk assessment for a single decision.
12#[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/// Individual risk factor values used to compute the overall score.
24#[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
33/// Input describing one active decision for risk computation.
34pub 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
42/// Compute risk scores for a set of decisions given all events across projects.
43///
44/// `now_iso` should be an ISO 8601 timestamp used as the reference point for
45/// age calculations (e.g. `"2026-03-12T00:00:00Z"`).
46pub 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    // Pre-compute: how many provenance refs target each event_id
53    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        // Count provenance links targeting decision events
59        for prov in &event.refs.provenance {
60            *downstream_counts.entry(prov.target.as_str()).or_insert(0) += 1;
61        }
62
63        // Count execution events referencing decisions
64        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        // Track approved decisions (approval events referencing a decision)
71        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
120/// Weighted risk formula.
121fn 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    // Approved decisions carry higher override risk because they have
127    // organizational backing — overriding them has greater organizational impact.
128    let approval = if f.has_approval { 0.1 } else { 0.0 };
129    downstream + age + exec + cross + approval
130}
131
132/// Map score to human-readable level.
133fn 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
143/// Compute age in days from an ISO 8601 timestamp to `now_iso`.
144fn compute_age_days(ts: Option<&str>, now_iso: &str) -> u32 {
145    let Some(ts) = ts else { return 0 };
146    // Simple date-only comparison using the first 10 chars (YYYY-MM-DD)
147    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    // Rough day calculation (good enough for risk scoring)
170    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        // Create many downstream refs and execution events
232        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}