Skip to main content

cu_profiler_core/
confidence.rs

1//! Confidence scoring.
2//!
3//! Every measurement carries a [`Confidence`]. The tool never claims more
4//! certainty than the evidence supports, and it always explains *why* a score
5//! is not [`ConfidenceLevel::High`].
6
7use serde::{Deserialize, Serialize};
8
9/// Qualitative confidence in a measurement.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum ConfidenceLevel {
13    /// Reasons are present but do not undermine the result.
14    Unknown,
15    /// Multiple weak signals; treat the number as indicative only.
16    Low,
17    /// Minor caveats; the number is broadly trustworthy.
18    Medium,
19    /// No material caveats detected.
20    High,
21}
22
23impl ConfidenceLevel {
24    /// Lowercase, human-facing label (`"High"`, `"Medium"`, ...).
25    #[must_use]
26    pub fn label(self) -> &'static str {
27        match self {
28            Self::High => "High",
29            Self::Medium => "Medium",
30            Self::Low => "Low",
31            Self::Unknown => "Unknown",
32        }
33    }
34}
35
36/// A confidence score plus the reasons that shaped it.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct Confidence {
39    /// The qualitative level.
40    pub level: ConfidenceLevel,
41    /// Why the level is what it is. Always populated when `level != High`.
42    pub reasons: Vec<String>,
43}
44
45impl Confidence {
46    /// A high-confidence score with no caveats.
47    #[must_use]
48    pub fn high() -> Self {
49        Self {
50            level: ConfidenceLevel::High,
51            reasons: Vec::new(),
52        }
53    }
54
55    /// An unknown score with a single explanatory reason.
56    #[must_use]
57    pub fn unknown(reason: impl Into<String>) -> Self {
58        Self {
59            level: ConfidenceLevel::Unknown,
60            reasons: vec![reason.into()],
61        }
62    }
63}
64
65/// Inputs to confidence scoring. Caller fills in what it knows; absent signals
66/// are conservative defaults.
67#[derive(Debug, Clone)]
68pub struct ConfidenceFactors {
69    /// Did the simulation succeed (or fail as expected)?
70    pub simulation_ok: bool,
71    /// Were the logs parsed without leftover unrecognised lines?
72    pub logs_complete: bool,
73    /// Number of parser warnings collected.
74    pub parser_warnings: usize,
75    /// Did the baseline fingerprint match (None when no baseline was compared)?
76    pub baseline_matched: Option<bool>,
77    /// Percentage of total CU that could not be attributed to a scope (0..=100).
78    pub unattributed_pct: f64,
79    /// Number of scope markers detected.
80    pub scope_markers: usize,
81    /// Whether runtime/version metadata was available.
82    pub metadata_available: bool,
83}
84
85impl Default for ConfidenceFactors {
86    fn default() -> Self {
87        Self {
88            simulation_ok: true,
89            logs_complete: true,
90            parser_warnings: 0,
91            baseline_matched: None,
92            unattributed_pct: 0.0,
93            scope_markers: 0,
94            metadata_available: false,
95        }
96    }
97}
98
99/// Score a measurement from its [`ConfidenceFactors`].
100///
101/// The model is deliberately simple and monotone: each adverse signal can only
102/// lower the level, never raise it, and each contributes a reason string.
103#[must_use]
104pub fn score(factors: &ConfidenceFactors) -> Confidence {
105    // `level` only ever moves downward. Because the enum is ordered
106    // `Unknown < Low < Medium < High`, the worse level is the smaller one, so
107    // `level.min(target)` demotes correctly.
108    let mut level = ConfidenceLevel::High;
109    let mut reasons = Vec::new();
110
111    if !factors.simulation_ok {
112        level = level.min(ConfidenceLevel::Low);
113        reasons.push("simulation did not complete as expected".to_string());
114    }
115    if !factors.logs_complete {
116        level = level.min(ConfidenceLevel::Low);
117        reasons.push("logs were incomplete or contained unrecognised lines".to_string());
118    }
119    if factors.parser_warnings > 0 {
120        level = level.min(ConfidenceLevel::Medium);
121        reasons.push(format!("{} parser warning(s)", factors.parser_warnings));
122    }
123    match factors.baseline_matched {
124        Some(true) => reasons.push("baseline matched".to_string()),
125        Some(false) => {
126            level = level.min(ConfidenceLevel::Low);
127            reasons.push("baseline fingerprint did not match".to_string());
128        }
129        None => {}
130    }
131    if factors.unattributed_pct >= 20.0 {
132        level = level.min(ConfidenceLevel::Medium);
133        reasons.push(format!("{:.0}% unattributed CU", factors.unattributed_pct));
134    }
135    if factors.scope_markers > 0 {
136        reasons.push(format!("{} scope markers detected", factors.scope_markers));
137    }
138    if !factors.metadata_available {
139        level = level.min(ConfidenceLevel::Medium);
140        reasons.push("runtime/version metadata unavailable".to_string());
141    }
142
143    Confidence { level, reasons }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn clean_run_with_metadata_is_high() {
152        let f = ConfidenceFactors {
153            metadata_available: true,
154            ..Default::default()
155        };
156        assert_eq!(score(&f).level, ConfidenceLevel::High);
157    }
158
159    #[test]
160    fn failed_simulation_is_low() {
161        let f = ConfidenceFactors {
162            simulation_ok: false,
163            metadata_available: true,
164            ..Default::default()
165        };
166        assert_eq!(score(&f).level, ConfidenceLevel::Low);
167    }
168
169    #[test]
170    fn unattributed_cu_demotes_to_medium_with_reason() {
171        let f = ConfidenceFactors {
172            unattributed_pct: 22.0,
173            metadata_available: true,
174            ..Default::default()
175        };
176        let c = score(&f);
177        assert_eq!(c.level, ConfidenceLevel::Medium);
178        assert!(c.reasons.iter().any(|r| r.contains("22% unattributed")));
179    }
180
181    #[test]
182    fn levels_order_high_above_low() {
183        assert!(ConfidenceLevel::High > ConfidenceLevel::Low);
184        assert!(ConfidenceLevel::Medium > ConfidenceLevel::Unknown);
185    }
186}