1use serde::{Deserialize, Serialize};
2
3use crate::differential::ModeDifferentialRunReport;
4use crate::observatory::ObservatoryReport;
5use crate::probe::ProbePackReport;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum ReleaseRiskLevel {
11 Low,
13 Guarded,
15 Elevated,
17 Critical,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
23pub struct ReleaseRiskThresholds {
24 pub guarded_at: f64,
26 pub elevated_at: f64,
28 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 #[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#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
70pub struct ReleaseRiskWeights {
71 pub probe_failures: f64,
73 pub drift_failures: f64,
75 pub observatory_regressions: f64,
77 pub incidents_7d: f64,
79 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct ReleaseRiskInput {
98 pub probe_failures: usize,
100 pub probe_total: usize,
102 pub drift_failed_pairs: usize,
104 pub drift_total_pairs: usize,
106 pub observatory_regressions: usize,
108 pub observatory_total_samples: usize,
110 pub incident_count_7d: usize,
112 pub incident_count_30d: usize,
114}
115
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118pub struct ReleaseRiskBreakdown {
119 pub probe_failure_ratio: f64,
121 pub drift_failure_ratio: f64,
123 pub observatory_regression_ratio: f64,
125 pub incident_pressure_7d: f64,
127 pub incident_pressure_30d: f64,
129}
130
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133pub struct ReleaseRiskAssessment {
134 pub score: f64,
136 pub level: ReleaseRiskLevel,
138 pub requires_escalation: bool,
140 pub escalation_reasons: Vec<String>,
142 pub breakdown: ReleaseRiskBreakdown,
144}
145
146#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148pub struct ReleaseCandidateRiskSnapshot {
149 pub candidate_id: String,
151 pub risk_score: f64,
153 pub risk_level: ReleaseRiskLevel,
155 pub requires_escalation: bool,
157 pub incident_count_7d: usize,
159 pub observatory_regressions: usize,
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "snake_case")]
166pub enum ReleaseTrendDirection {
167 Improving,
169 Stable,
171 Degrading,
173}
174
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177pub struct ReleaseTrendPoint {
178 pub candidate_id: String,
180 pub risk_score: f64,
182 pub risk_delta: f64,
184 pub risk_level: ReleaseRiskLevel,
186 pub requires_escalation: bool,
188 pub trend: ReleaseTrendDirection,
190}
191
192#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
194pub struct ReleaseTrendReport {
195 pub points: Vec<ReleaseTrendPoint>,
197 pub degrading_streak: usize,
199 pub requires_escalation: bool,
201}
202
203#[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#[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#[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}