Skip to main content

stygian_charon/
release_risk.rs

1use serde::{Deserialize, Serialize};
2
3use crate::differential::ModeDifferentialRunReport;
4use crate::observatory::ObservatoryReport;
5use crate::probe::ProbePackReport;
6
7/// Release risk level derived from a normalized risk score.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum ReleaseRiskLevel {
11    /// Risk is low enough for routine rollout.
12    Low,
13    /// Risk is noticeable and should be watched closely.
14    Guarded,
15    /// Risk is high enough to require rollout caution.
16    Elevated,
17    /// Risk is severe and should block rollout.
18    Critical,
19}
20
21/// Thresholds for classifying release risk scores.
22#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
23pub struct ReleaseRiskThresholds {
24    /// Score at or above this value is `Guarded`.
25    pub guarded_at: f64,
26    /// Score at or above this value is `Elevated`.
27    pub elevated_at: f64,
28    /// Score at or above this value is `Critical`.
29    pub critical_at: f64,
30}
31
32impl Default for ReleaseRiskThresholds {
33    fn default() -> Self {
34        Self {
35            guarded_at: 0.30,
36            elevated_at: 0.55,
37            critical_at: 0.75,
38        }
39    }
40}
41
42impl ReleaseRiskThresholds {
43    /// Classify a normalized risk score into a risk level.
44    ///
45    /// # Example
46    ///
47    /// ```rust
48    /// use stygian_charon::ReleaseRiskLevel;
49    /// use stygian_charon::ReleaseRiskThresholds;
50    ///
51    /// let thresholds = ReleaseRiskThresholds::default();
52    /// assert_eq!(thresholds.classify(0.8), ReleaseRiskLevel::Critical);
53    /// ```
54    #[must_use]
55    pub fn classify(&self, score: f64) -> ReleaseRiskLevel {
56        if score >= self.critical_at {
57            ReleaseRiskLevel::Critical
58        } else if score >= self.elevated_at {
59            ReleaseRiskLevel::Elevated
60        } else if score >= self.guarded_at {
61            ReleaseRiskLevel::Guarded
62        } else {
63            ReleaseRiskLevel::Low
64        }
65    }
66}
67
68/// Weights used to aggregate a release risk score.
69#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
70pub struct ReleaseRiskWeights {
71    /// Weight for probe-pack failure ratio.
72    pub probe_failures: f64,
73    /// Weight for mode-differential drift failure ratio.
74    pub drift_failures: f64,
75    /// Weight for observatory regression ratio.
76    pub observatory_regressions: f64,
77    /// Weight for incidents observed in the last 7 days.
78    pub incidents_7d: f64,
79    /// Weight for incidents observed in the last 30 days.
80    pub incidents_30d: f64,
81}
82
83impl Default for ReleaseRiskWeights {
84    fn default() -> Self {
85        Self {
86            probe_failures: 0.35,
87            drift_failures: 0.25,
88            observatory_regressions: 0.20,
89            incidents_7d: 0.15,
90            incidents_30d: 0.05,
91        }
92    }
93}
94
95/// Input signals used to compute release risk.
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct ReleaseRiskInput {
98    /// Probe-pack failures.
99    pub probe_failures: usize,
100    /// Total probes in the pack.
101    pub probe_total: usize,
102    /// Mode-differential pairs that failed thresholds.
103    pub drift_failed_pairs: usize,
104    /// Total compared differential pairs.
105    pub drift_total_pairs: usize,
106    /// Observatory comparisons marked as likely regressions.
107    pub observatory_regressions: usize,
108    /// Total observatory comparisons.
109    pub observatory_total_samples: usize,
110    /// Incident count for the last 7 days.
111    pub incident_count_7d: usize,
112    /// Incident count for the last 30 days.
113    pub incident_count_30d: usize,
114}
115
116/// Component-level breakdown for a release risk score.
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118pub struct ReleaseRiskBreakdown {
119    /// Probe failure component in [0.0, 1.0].
120    pub probe_failure_ratio: f64,
121    /// Drift failure component in [0.0, 1.0].
122    pub drift_failure_ratio: f64,
123    /// Observatory regression component in [0.0, 1.0].
124    pub observatory_regression_ratio: f64,
125    /// Last-7-day incident component in [0.0, 1.0].
126    pub incident_pressure_7d: f64,
127    /// Last-30-day incident component in [0.0, 1.0].
128    pub incident_pressure_30d: f64,
129}
130
131/// Final release risk assessment for one candidate.
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133pub struct ReleaseRiskAssessment {
134    /// Aggregate normalized score in [0.0, 1.0].
135    pub score: f64,
136    /// Risk level derived from thresholds.
137    pub level: ReleaseRiskLevel,
138    /// Whether escalation should block or gate rollout.
139    pub requires_escalation: bool,
140    /// Human-readable escalation reasons.
141    pub escalation_reasons: Vec<String>,
142    /// Component-level score breakdown.
143    pub breakdown: ReleaseRiskBreakdown,
144}
145
146/// Compact snapshot for one release candidate.
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148pub struct ReleaseCandidateRiskSnapshot {
149    /// Candidate identifier (for example `rc-2026-05-06.1`).
150    pub candidate_id: String,
151    /// Risk score for this candidate.
152    pub risk_score: f64,
153    /// Classified risk level.
154    pub risk_level: ReleaseRiskLevel,
155    /// Whether this candidate requires escalation.
156    pub requires_escalation: bool,
157    /// Incident count observed during the 7-day lookback.
158    pub incident_count_7d: usize,
159    /// Observatory regressions observed for this candidate.
160    pub observatory_regressions: usize,
161}
162
163/// Direction of risk movement between adjacent candidates.
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "snake_case")]
166pub enum ReleaseTrendDirection {
167    /// Risk score moved down by a material amount.
168    Improving,
169    /// Risk score remained effectively flat.
170    Stable,
171    /// Risk score moved up by a material amount.
172    Degrading,
173}
174
175/// One trend row in a release-candidate risk timeline.
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177pub struct ReleaseTrendPoint {
178    /// Candidate identifier.
179    pub candidate_id: String,
180    /// Candidate risk score.
181    pub risk_score: f64,
182    /// Score delta from previous candidate (0.0 for first point).
183    pub risk_delta: f64,
184    /// Candidate risk level.
185    pub risk_level: ReleaseRiskLevel,
186    /// Whether this candidate requires escalation.
187    pub requires_escalation: bool,
188    /// Trend direction from the previous candidate.
189    pub trend: ReleaseTrendDirection,
190}
191
192/// Aggregate trend report across release candidates.
193#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
194pub struct ReleaseTrendReport {
195    /// Ordered trend points.
196    pub points: Vec<ReleaseTrendPoint>,
197    /// Number of consecutive degrading steps ending at the latest candidate.
198    pub degrading_streak: usize,
199    /// Whether the trend indicates escalation pressure.
200    pub requires_escalation: bool,
201}
202
203/// Build release-risk input from existing Charon reports plus incident counts.
204///
205/// # Example
206///
207/// ```rust,no_run
208/// use stygian_charon::release_risk_input_from_reports;
209///
210/// # let probe_report = todo!("probe report");
211/// # let differential_report = todo!("differential report");
212/// # let observatory_report = todo!("observatory report");
213/// let input = release_risk_input_from_reports(
214///     &probe_report,
215///     &differential_report,
216///     &observatory_report,
217///     1,
218///     2,
219/// );
220/// assert!(input.probe_total >= input.probe_failures);
221/// ```
222#[must_use]
223pub fn release_risk_input_from_reports(
224    probe_report: &ProbePackReport,
225    differential_report: &ModeDifferentialRunReport,
226    observatory_report: &ObservatoryReport,
227    incident_count_7d: usize,
228    incident_count_30d: usize,
229) -> ReleaseRiskInput {
230    let observatory_regressions = observatory_report
231        .comparisons
232        .iter()
233        .filter(|comparison| comparison.recommended_action == "investigate_regression")
234        .count();
235
236    ReleaseRiskInput {
237        probe_failures: probe_report.failed,
238        probe_total: probe_report.total,
239        drift_failed_pairs: differential_report.failing_pairs,
240        drift_total_pairs: differential_report.pair_results.len(),
241        observatory_regressions,
242        observatory_total_samples: observatory_report.comparisons.len(),
243        incident_count_7d,
244        incident_count_30d,
245    }
246}
247
248/// Compute a release risk assessment from normalized signals.
249///
250/// # Example
251///
252/// ```rust
253/// use stygian_charon::ReleaseRiskInput;
254/// use stygian_charon::assess_release_risk;
255///
256/// let assessment = assess_release_risk(
257///     &ReleaseRiskInput {
258///         probe_failures: 1,
259///         probe_total: 10,
260///         drift_failed_pairs: 0,
261///         drift_total_pairs: 4,
262///         observatory_regressions: 1,
263///         observatory_total_samples: 4,
264///         incident_count_7d: 0,
265///         incident_count_30d: 1,
266///     },
267///     None,
268///     None,
269/// );
270/// assert!((0.0..=1.0).contains(&assessment.score));
271/// ```
272#[must_use]
273pub fn assess_release_risk(
274    input: &ReleaseRiskInput,
275    thresholds: Option<ReleaseRiskThresholds>,
276    weights: Option<ReleaseRiskWeights>,
277) -> ReleaseRiskAssessment {
278    let thresholds = thresholds.unwrap_or_default();
279    let weights = weights.unwrap_or_default();
280
281    let breakdown = ReleaseRiskBreakdown {
282        probe_failure_ratio: ratio(input.probe_failures, input.probe_total),
283        drift_failure_ratio: ratio(input.drift_failed_pairs, input.drift_total_pairs),
284        observatory_regression_ratio: ratio(
285            input.observatory_regressions,
286            input.observatory_total_samples,
287        ),
288        incident_pressure_7d: scaled_incident_pressure(input.incident_count_7d, 3),
289        incident_pressure_30d: scaled_incident_pressure(input.incident_count_30d, 10),
290    };
291
292    let raw_score = breakdown.incident_pressure_30d.mul_add(
293        weights.incidents_30d,
294        breakdown.incident_pressure_7d.mul_add(
295            weights.incidents_7d,
296            breakdown.observatory_regression_ratio.mul_add(
297                weights.observatory_regressions,
298                breakdown.probe_failure_ratio.mul_add(
299                    weights.probe_failures,
300                    breakdown.drift_failure_ratio * weights.drift_failures,
301                ),
302            ),
303        ),
304    );
305
306    let score = raw_score.clamp(0.0, 1.0);
307    let level = thresholds.classify(score);
308
309    let mut escalation_reasons = Vec::new();
310
311    if matches!(level, ReleaseRiskLevel::Critical) {
312        escalation_reasons.push("aggregate score reached critical threshold".to_string());
313    }
314    if breakdown.probe_failure_ratio >= 0.10 {
315        escalation_reasons.push("probe pack failure ratio is at least 10%".to_string());
316    }
317    if breakdown.drift_failure_ratio >= 0.20 {
318        escalation_reasons.push("drift threshold failures are at least 20%".to_string());
319    }
320    if breakdown.observatory_regression_ratio >= 0.25 {
321        escalation_reasons.push("observatory regression ratio is at least 25%".to_string());
322    }
323    if input.incident_count_7d >= 3 {
324        escalation_reasons.push("incident count in last 7 days is at least 3".to_string());
325    }
326
327    ReleaseRiskAssessment {
328        score,
329        level,
330        requires_escalation: !escalation_reasons.is_empty(),
331        escalation_reasons,
332        breakdown,
333    }
334}
335
336/// Build an ordered release-candidate trend report from candidate snapshots.
337///
338/// # Example
339///
340/// ```rust
341/// use stygian_charon::{
342///     ReleaseCandidateRiskSnapshot, ReleaseRiskLevel, build_release_trend_report,
343/// };
344///
345/// let trend = build_release_trend_report(&[
346///     ReleaseCandidateRiskSnapshot {
347///         candidate_id: "rc1".to_string(),
348///         risk_score: 0.20,
349///         risk_level: ReleaseRiskLevel::Low,
350///         requires_escalation: false,
351///         incident_count_7d: 0,
352///         observatory_regressions: 0,
353///     },
354///     ReleaseCandidateRiskSnapshot {
355///         candidate_id: "rc2".to_string(),
356///         risk_score: 0.35,
357///         risk_level: ReleaseRiskLevel::Guarded,
358///         requires_escalation: false,
359///         incident_count_7d: 1,
360///         observatory_regressions: 1,
361///     },
362/// ]);
363/// assert_eq!(trend.points.len(), 2);
364/// ```
365#[must_use]
366pub fn build_release_trend_report(
367    candidates: &[ReleaseCandidateRiskSnapshot],
368) -> ReleaseTrendReport {
369    let mut points = Vec::with_capacity(candidates.len());
370
371    let mut previous_score: Option<f64> = None;
372    for candidate in candidates {
373        let risk_delta = previous_score.map_or(0.0, |previous| candidate.risk_score - previous);
374        let trend = classify_trend_delta(risk_delta);
375
376        points.push(ReleaseTrendPoint {
377            candidate_id: candidate.candidate_id.clone(),
378            risk_score: candidate.risk_score,
379            risk_delta,
380            risk_level: candidate.risk_level,
381            requires_escalation: candidate.requires_escalation,
382            trend,
383        });
384
385        previous_score = Some(candidate.risk_score);
386    }
387
388    let degrading_streak = trailing_degrading_streak(&points);
389    let latest_requires_escalation = points
390        .last()
391        .is_some_and(|latest| latest.requires_escalation);
392
393    ReleaseTrendReport {
394        points,
395        degrading_streak,
396        requires_escalation: latest_requires_escalation || degrading_streak >= 3,
397    }
398}
399
400fn ratio(numerator: usize, denominator: usize) -> f64 {
401    if denominator == 0 {
402        0.0
403    } else {
404        usize_to_f64_saturating(numerator) / usize_to_f64_saturating(denominator)
405    }
406}
407
408fn scaled_incident_pressure(incidents: usize, saturation_point: usize) -> f64 {
409    if saturation_point == 0 {
410        return 0.0;
411    }
412
413    (usize_to_f64_saturating(incidents) / usize_to_f64_saturating(saturation_point)).clamp(0.0, 1.0)
414}
415
416fn usize_to_f64_saturating(value: usize) -> f64 {
417    f64::from(u32::try_from(value).unwrap_or(u32::MAX))
418}
419
420fn classify_trend_delta(risk_delta: f64) -> ReleaseTrendDirection {
421    if risk_delta >= 0.03 {
422        ReleaseTrendDirection::Degrading
423    } else if risk_delta <= -0.03 {
424        ReleaseTrendDirection::Improving
425    } else {
426        ReleaseTrendDirection::Stable
427    }
428}
429
430fn trailing_degrading_streak(points: &[ReleaseTrendPoint]) -> usize {
431    let mut streak = 0_usize;
432
433    for point in points.iter().rev() {
434        if point.trend == ReleaseTrendDirection::Degrading {
435            streak = streak.saturating_add(1);
436        } else {
437            break;
438        }
439    }
440
441    streak
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn assess_release_risk_sets_escalation_reasons_for_threshold_breaches() {
450        let assessment = assess_release_risk(
451            &ReleaseRiskInput {
452                probe_failures: 3,
453                probe_total: 10,
454                drift_failed_pairs: 2,
455                drift_total_pairs: 8,
456                observatory_regressions: 2,
457                observatory_total_samples: 6,
458                incident_count_7d: 3,
459                incident_count_30d: 7,
460            },
461            None,
462            None,
463        );
464
465        assert!(assessment.requires_escalation);
466        assert!(!assessment.escalation_reasons.is_empty());
467        assert!((0.0..=1.0).contains(&assessment.score));
468    }
469
470    #[test]
471    fn assess_release_risk_is_low_for_clean_inputs() {
472        let assessment = assess_release_risk(
473            &ReleaseRiskInput {
474                probe_failures: 0,
475                probe_total: 10,
476                drift_failed_pairs: 0,
477                drift_total_pairs: 8,
478                observatory_regressions: 0,
479                observatory_total_samples: 6,
480                incident_count_7d: 0,
481                incident_count_30d: 0,
482            },
483            None,
484            None,
485        );
486
487        assert_eq!(assessment.level, ReleaseRiskLevel::Low);
488        assert!(!assessment.requires_escalation);
489        assert!(assessment.escalation_reasons.is_empty());
490    }
491
492    #[test]
493    fn release_trend_report_tracks_degrading_streak() {
494        let report = build_release_trend_report(&[
495            ReleaseCandidateRiskSnapshot {
496                candidate_id: "rc1".to_string(),
497                risk_score: 0.20,
498                risk_level: ReleaseRiskLevel::Low,
499                requires_escalation: false,
500                incident_count_7d: 0,
501                observatory_regressions: 0,
502            },
503            ReleaseCandidateRiskSnapshot {
504                candidate_id: "rc2".to_string(),
505                risk_score: 0.28,
506                risk_level: ReleaseRiskLevel::Low,
507                requires_escalation: false,
508                incident_count_7d: 0,
509                observatory_regressions: 0,
510            },
511            ReleaseCandidateRiskSnapshot {
512                candidate_id: "rc3".to_string(),
513                risk_score: 0.34,
514                risk_level: ReleaseRiskLevel::Guarded,
515                requires_escalation: false,
516                incident_count_7d: 1,
517                observatory_regressions: 1,
518            },
519            ReleaseCandidateRiskSnapshot {
520                candidate_id: "rc4".to_string(),
521                risk_score: 0.40,
522                risk_level: ReleaseRiskLevel::Guarded,
523                requires_escalation: false,
524                incident_count_7d: 1,
525                observatory_regressions: 1,
526            },
527        ]);
528
529        assert_eq!(report.points.len(), 4);
530        assert_eq!(report.degrading_streak, 3);
531        assert!(report.requires_escalation);
532    }
533}