Skip to main content

hotspots_core/
risk.rs

1//! Local Risk Score (LRS) calculation
2//!
3//! Global invariants enforced:
4//! - Deterministic risk calculations
5//! - Monotonic risk transforms
6
7use crate::metrics::RawMetrics;
8use serde::{Deserialize, Serialize};
9
10/// Risk components after transformation
11#[derive(Debug, Clone)]
12pub struct RiskComponents {
13    pub r_cc: f64,
14    pub r_nd: f64,
15    pub r_fo: f64,
16    pub r_ns: f64,
17}
18
19/// Risk band classification (ordered: Low < Moderate < High < Critical)
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum RiskBand {
23    Low,      // < 3
24    Moderate, // 3-6
25    High,     // 6-9
26    Critical, // >= 9
27}
28
29impl RiskBand {
30    pub fn as_str(&self) -> &'static str {
31        match self {
32            RiskBand::Low => "low",
33            RiskBand::Moderate => "moderate",
34            RiskBand::High => "high",
35            RiskBand::Critical => "critical",
36        }
37    }
38
39    pub fn parse(s: &str) -> Option<Self> {
40        match s {
41            "low" => Some(RiskBand::Low),
42            "moderate" => Some(RiskBand::Moderate),
43            "high" => Some(RiskBand::High),
44            "critical" => Some(RiskBand::Critical),
45            _ => None,
46        }
47    }
48}
49
50impl std::fmt::Display for RiskBand {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        f.write_str(self.as_str())
53    }
54}
55
56/// Calculate risk transforms from raw metrics
57///
58/// Transforms:
59/// - R_cc = min(log2(CC + 1), 6)
60/// - R_nd = min(ND, 8)
61/// - R_fo = min(log2(FO + 1), 6)
62/// - R_ns = min(NS, 6)
63pub fn calculate_risk_components(metrics: &RawMetrics) -> RiskComponents {
64    RiskComponents {
65        r_cc: (metrics.cc as f64 + 1.0).log2().min(6.0),
66        r_nd: (metrics.nd as f64).min(8.0),
67        r_fo: (metrics.fo as f64 + 1.0).log2().min(6.0),
68        r_ns: (metrics.ns as f64).min(6.0),
69    }
70}
71
72/// Configurable weights for LRS calculation
73#[derive(Debug, Clone, Copy)]
74pub struct LrsWeights {
75    pub cc: f64,
76    pub nd: f64,
77    pub fo: f64,
78    pub ns: f64,
79}
80
81impl Default for LrsWeights {
82    fn default() -> Self {
83        LrsWeights {
84            cc: 1.0,
85            nd: 0.8,
86            fo: 0.6,
87            ns: 0.7,
88        }
89    }
90}
91
92/// Configurable risk band thresholds
93#[derive(Debug, Clone, Copy)]
94pub struct RiskThresholds {
95    pub moderate: f64,
96    pub high: f64,
97    pub critical: f64,
98}
99
100impl Default for RiskThresholds {
101    fn default() -> Self {
102        RiskThresholds {
103            moderate: 3.0,
104            high: 6.0,
105            critical: 9.0,
106        }
107    }
108}
109
110/// Calculate Local Risk Score (LRS) with default weights
111///
112/// Formula:
113/// LRS = 1.0 * R_cc + 0.8 * R_nd + 0.6 * R_fo + 0.7 * R_ns
114pub fn calculate_lrs(risk: &RiskComponents) -> f64 {
115    calculate_lrs_with_weights(risk, &LrsWeights::default())
116}
117
118/// Calculate LRS with custom weights
119pub fn calculate_lrs_with_weights(risk: &RiskComponents, weights: &LrsWeights) -> f64 {
120    weights.cc * risk.r_cc
121        + weights.nd * risk.r_nd
122        + weights.fo * risk.r_fo
123        + weights.ns * risk.r_ns
124}
125
126/// Assign risk band based on LRS with default thresholds
127pub fn assign_risk_band(lrs: f64) -> RiskBand {
128    assign_risk_band_with_thresholds(lrs, &RiskThresholds::default())
129}
130
131/// Assign risk band with custom thresholds
132pub fn assign_risk_band_with_thresholds(lrs: f64, thresholds: &RiskThresholds) -> RiskBand {
133    if lrs < thresholds.moderate {
134        RiskBand::Low
135    } else if lrs < thresholds.high {
136        RiskBand::Moderate
137    } else if lrs < thresholds.critical {
138        RiskBand::High
139    } else {
140        RiskBand::Critical
141    }
142}
143
144/// Calculate complete risk analysis from raw metrics (default weights/thresholds)
145pub fn analyze_risk(metrics: &RawMetrics) -> (RiskComponents, f64, RiskBand) {
146    let risk = calculate_risk_components(metrics);
147    let lrs = calculate_lrs(&risk);
148    let band = assign_risk_band(lrs);
149    (risk, lrs, band)
150}
151
152/// Calculate complete risk analysis with custom weights and thresholds
153pub fn analyze_risk_with_config(
154    metrics: &RawMetrics,
155    weights: &LrsWeights,
156    thresholds: &RiskThresholds,
157) -> (RiskComponents, f64, RiskBand) {
158    let risk = calculate_risk_components(metrics);
159    let lrs = calculate_lrs_with_weights(&risk, weights);
160    let band = assign_risk_band_with_thresholds(lrs, thresholds);
161    (risk, lrs, band)
162}