Skip to main content

sentinel_core/report/
interpret.rs

1//! Interpretation helpers: classify scoring metrics into human-readable bands.
2//!
3//! These thresholds are **heuristic rendering aids** used by the CLI text
4//! output to annotate numerical metrics like `io_intensity_score` and
5//! `io_waste_ratio` with a `(healthy | moderate | high | critical)` label.
6//!
7//! # What is and isn't anchored on real data
8//!
9//! - [`IIS_HIGH`] (5.0) is anchored on the N+1 detector's
10//!   `n_plus_one_threshold` config default. An endpoint whose IIS reaches
11//!   5.0 is arithmetically at the point where `detect_n_plus_one` starts
12//!   emitting findings.
13//! - [`IIS_CRITICAL`] (10.0) is mechanically anchored on
14//!   `crate::detect::n_plus_one::CRITICAL_OCCURRENCE_THRESHOLD` via the
15//!   `iis_critical_matches_n_plus_one_detector_threshold` drift-guard
16//!   test. Changing either value without updating the other will fail
17//!   the test at build time.
18//! - [`IIS_MODERATE`] (2.0) is a **rule of thumb**, not empirical. It
19//!   encodes the intuition that a typical CRUD endpoint makes 1-2 I/O ops
20//!   per request. Aggregators and dashboards will show many "moderate"
21//!   endpoints that are legitimate.
22//! - [`WASTE_RATIO_HIGH`] (0.30) is anchored on the **default**
23//!   `io_waste_ratio_max`. Users who override the quality gate in their
24//!   `.perf-sentinel.toml` still see this fixed heuristic, the gate is a
25//!   user policy, the interpretation is a fixed heuristic. By design.
26//!
27//! # JSON stability contract
28//!
29//! The [`InterpretationLevel`] bands ship as sibling fields in the JSON
30//! output (`io_intensity_band` next to `io_intensity_score`,
31//! `io_waste_ratio_band` next to `io_waste_ratio`). The contract is:
32//!
33//! - **Enum values are stable across versions** (`"healthy"`, `"moderate"`,
34//!   `"high"`, `"critical"`). Downstream consumers can rely on these
35//!   names in SARIF, Grafana, perf-lint.
36//! - **Thresholds are versioned with the binary** and may evolve. A
37//!   consumer who wants a version-independent classification must read
38//!   the raw `io_intensity_score` / `io_waste_ratio` fields and apply
39//!   their own bands.
40//!
41//! This mirrors the existing pattern where `co2.model: "io_proxy_v1" |
42//! "io_proxy_v2" | "io_proxy_v3"` evolves across versions without breaking
43//! consumers who just want to know which model was used.
44
45/// Four-level interpretation band for a numerical score.
46///
47/// Produced by [`InterpretationLevel::for_iis`] and
48/// [`InterpretationLevel::for_waste_ratio`]. Rendered as a short lowercase
49/// label by [`InterpretationLevel::short_label`]. The CLI maps each level
50/// to an ANSI color in `sentinel-cli/src/main.rs`. The serde
51/// representation is lowercase (`"healthy"` / `"moderate"` / `"high"` /
52/// `"critical"`) and is stable across versions, see the module docstring
53/// for the stability contract.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum InterpretationLevel {
57    /// Signal is below the moderate threshold. Nothing to investigate.
58    Healthy,
59    /// Signal is above baseline but below the action threshold. Informational.
60    Moderate,
61    /// Signal is at or above the action threshold. Worth investigating.
62    High,
63    /// Signal is at or above the critical threshold. Very likely a bug.
64    Critical,
65}
66
67// --- IIS thresholds ---
68
69/// Rule-of-thumb lower bound: >= 2 I/O ops per request is above the simple
70/// CRUD baseline (1 SQL + maybe 1 cache). **Heuristic**, not empirical.
71pub const IIS_MODERATE: f64 = 2.0;
72
73/// High threshold, anchored on `Config::default().n_plus_one_threshold = 5`.
74/// An endpoint whose IIS reaches 5.0 is arithmetically at the point where
75/// `detect_n_plus_one` starts emitting findings.
76pub const IIS_HIGH: f64 = 5.0;
77
78/// Critical threshold, mechanically anchored on
79/// [`crate::detect::n_plus_one::CRITICAL_OCCURRENCE_THRESHOLD`] via the
80/// `iis_critical_matches_n_plus_one_detector_threshold` drift-guard
81/// test. The detector flips an N+1 finding to `Severity::Critical`
82/// exactly when `indices.len() >= CRITICAL_OCCURRENCE_THRESHOLD`.
83pub const IIS_CRITICAL: f64 = 10.0;
84
85// --- waste ratio thresholds ---
86
87/// Below this, avoidable I/O is marginal.
88pub const WASTE_RATIO_MODERATE: f64 = 0.10;
89
90/// Anchored on the **default** `io_waste_ratio_max = 0.30`. Above this, the
91/// default quality gate would fail. See module docs for why we anchor on
92/// the default rather than the user's config.
93pub const WASTE_RATIO_HIGH: f64 = 0.30;
94
95/// Half or more of analyzed I/O is avoidable waste.
96pub const WASTE_RATIO_CRITICAL: f64 = 0.50;
97
98impl InterpretationLevel {
99    /// Private four-level `>=` classifier shared by [`for_iis`] and
100    /// [`for_waste_ratio`]. Takes the three thresholds in the same
101    /// order `(moderate, high, critical)` both public wrappers use.
102    ///
103    /// `NaN` falls through to [`Healthy`] because NaN compares false
104    /// against every threshold, intentional: missing data should not
105    /// render as a red CLI warning.
106    ///
107    /// [`for_iis`]: Self::for_iis
108    /// [`for_waste_ratio`]: Self::for_waste_ratio
109    /// [`Healthy`]: Self::Healthy
110    #[inline]
111    fn classify(value: f64, moderate: f64, high: f64, critical: f64) -> Self {
112        if value >= critical {
113            Self::Critical
114        } else if value >= high {
115            Self::High
116        } else if value >= moderate {
117            Self::Moderate
118        } else {
119            Self::Healthy
120        }
121    }
122
123    /// Classify an I/O Intensity Score (IIS) into a band.
124    ///
125    /// Comparisons use `>=` to match the N+1 detector's own
126    /// `indices.len() >= 10` convention: an IIS of exactly 10.0 is
127    /// [`Critical`], not [`High`].
128    ///
129    /// `NaN` is treated as [`Healthy`] (it compares false against every
130    /// threshold), which is the safe choice: missing data should not
131    /// trigger red output.
132    ///
133    /// [`Critical`]: Self::Critical
134    /// [`High`]: Self::High
135    /// [`Healthy`]: Self::Healthy
136    #[must_use]
137    pub fn for_iis(iis: f64) -> Self {
138        Self::classify(iis, IIS_MODERATE, IIS_HIGH, IIS_CRITICAL)
139    }
140
141    /// Classify an I/O waste ratio (0.0 to 1.0) into a band.
142    ///
143    /// See the module docstring for why this is anchored on the *default*
144    /// quality gate threshold rather than the user's configured value.
145    ///
146    /// `NaN` is treated as [`Healthy`] for the same reason as
147    /// [`for_iis`](Self::for_iis).
148    ///
149    /// [`Healthy`]: Self::Healthy
150    #[must_use]
151    pub fn for_waste_ratio(ratio: f64) -> Self {
152        Self::classify(
153            ratio,
154            WASTE_RATIO_MODERATE,
155            WASTE_RATIO_HIGH,
156            WASTE_RATIO_CRITICAL,
157        )
158    }
159
160    /// Return a short lowercase label, suitable for CLI parenthetical
161    /// rendering: `"healthy"`, `"moderate"`, `"high"`, `"critical"`.
162    ///
163    /// Takes `self` by value because `InterpretationLevel` is `Copy`
164    /// (fieldless enum); there's no reason to add a deref.
165    #[must_use]
166    pub const fn short_label(self) -> &'static str {
167        match self {
168            Self::Healthy => "healthy",
169            Self::Moderate => "moderate",
170            Self::High => "high",
171            Self::Critical => "critical",
172        }
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    // --- IIS boundary tests ---
181
182    #[test]
183    fn iis_healthy_below_moderate() {
184        assert_eq!(
185            InterpretationLevel::for_iis(0.0),
186            InterpretationLevel::Healthy
187        );
188        assert_eq!(
189            InterpretationLevel::for_iis(1.0),
190            InterpretationLevel::Healthy
191        );
192        assert_eq!(
193            InterpretationLevel::for_iis(1.99),
194            InterpretationLevel::Healthy
195        );
196    }
197
198    #[test]
199    fn iis_moderate_at_and_above_2() {
200        assert_eq!(
201            InterpretationLevel::for_iis(2.0),
202            InterpretationLevel::Moderate
203        );
204        assert_eq!(
205            InterpretationLevel::for_iis(3.5),
206            InterpretationLevel::Moderate
207        );
208        assert_eq!(
209            InterpretationLevel::for_iis(4.99),
210            InterpretationLevel::Moderate
211        );
212    }
213
214    #[test]
215    fn iis_high_at_and_above_5() {
216        assert_eq!(InterpretationLevel::for_iis(5.0), InterpretationLevel::High);
217        assert_eq!(InterpretationLevel::for_iis(7.5), InterpretationLevel::High);
218        assert_eq!(
219            InterpretationLevel::for_iis(9.99),
220            InterpretationLevel::High
221        );
222    }
223
224    #[test]
225    fn iis_critical_at_and_above_10() {
226        assert_eq!(
227            InterpretationLevel::for_iis(10.0),
228            InterpretationLevel::Critical
229        );
230        assert_eq!(
231            InterpretationLevel::for_iis(22.0),
232            InterpretationLevel::Critical
233        );
234        assert_eq!(
235            InterpretationLevel::for_iis(100.0),
236            InterpretationLevel::Critical
237        );
238    }
239
240    /// Cross-module mechanical drift guard: `IIS_CRITICAL` must match
241    /// the N+1 detector's severity-escalation threshold. If one of the
242    /// two is updated, this test forces the other to follow by directly
243    /// comparing against the crate-internal constant, not a literal.
244    #[test]
245    fn iis_critical_matches_n_plus_one_detector_threshold() {
246        use crate::detect::n_plus_one::CRITICAL_OCCURRENCE_THRESHOLD;
247        // The detector uses `indices.len() >= CRITICAL_OCCURRENCE_THRESHOLD`
248        // to flip to Severity::Critical. IIS_CRITICAL must classify the
249        // same boundary as Critical on the interpretation side.
250        #[allow(clippy::cast_precision_loss)]
251        let detector_threshold_f64 = CRITICAL_OCCURRENCE_THRESHOLD as f64;
252        assert!(
253            (IIS_CRITICAL - detector_threshold_f64).abs() < f64::EPSILON,
254            "IIS_CRITICAL ({IIS_CRITICAL}) drifted from \
255             detect::n_plus_one::CRITICAL_OCCURRENCE_THRESHOLD \
256             ({CRITICAL_OCCURRENCE_THRESHOLD}); update both or neither"
257        );
258    }
259
260    /// `IIS_HIGH` is anchored on the default `n_plus_one_threshold`
261    /// (crates/sentinel-core/src/config.rs, `Config::default`).
262    ///
263    /// This test reads the runtime value of `Config::default().n_plus_one_threshold`
264    /// and asserts they match. A bare literal comparison (`IIS_HIGH == 5.0`)
265    /// would not catch the case where someone bumps the config default but
266    /// forgets to update `IIS_HIGH`, the point of a drift guard is to
267    /// follow the anchor, not to freeze a magic number.
268    ///
269    /// `f64::from(u32)` is lossless today. If someone widens
270    /// `n_plus_one_threshold` from `u32` to `usize`, `f64::from(usize)`
271    /// does not exist and this test will stop compiling, forcing a
272    /// manual decision on how to cast. That hard break is the drift
273    /// guard here: do NOT paper over it with `as f64`, the type
274    /// change should get human attention.
275    #[test]
276    fn iis_high_matches_n_plus_one_threshold_default() {
277        let default_threshold = crate::config::Config::default()
278            .detection
279            .n_plus_one_threshold;
280        let threshold_f64 = f64::from(default_threshold);
281        assert!(
282            (IIS_HIGH - threshold_f64).abs() < f64::EPSILON,
283            "IIS_HIGH ({IIS_HIGH}) drifted away from \
284             Config::default().n_plus_one_threshold ({default_threshold}); \
285             update both or neither"
286        );
287    }
288
289    // --- waste ratio boundary tests ---
290
291    #[test]
292    fn waste_ratio_healthy_below_10_percent() {
293        assert_eq!(
294            InterpretationLevel::for_waste_ratio(0.0),
295            InterpretationLevel::Healthy
296        );
297        assert_eq!(
298            InterpretationLevel::for_waste_ratio(0.05),
299            InterpretationLevel::Healthy
300        );
301        assert_eq!(
302            InterpretationLevel::for_waste_ratio(0.09),
303            InterpretationLevel::Healthy
304        );
305    }
306
307    #[test]
308    fn waste_ratio_moderate_between_10_and_30_percent() {
309        assert_eq!(
310            InterpretationLevel::for_waste_ratio(0.10),
311            InterpretationLevel::Moderate
312        );
313        assert_eq!(
314            InterpretationLevel::for_waste_ratio(0.20),
315            InterpretationLevel::Moderate
316        );
317        assert_eq!(
318            InterpretationLevel::for_waste_ratio(0.29),
319            InterpretationLevel::Moderate
320        );
321    }
322
323    #[test]
324    fn waste_ratio_high_between_30_and_50_percent() {
325        assert_eq!(
326            InterpretationLevel::for_waste_ratio(0.30),
327            InterpretationLevel::High
328        );
329        assert_eq!(
330            InterpretationLevel::for_waste_ratio(0.40),
331            InterpretationLevel::High
332        );
333        assert_eq!(
334            InterpretationLevel::for_waste_ratio(0.49),
335            InterpretationLevel::High
336        );
337    }
338
339    #[test]
340    fn waste_ratio_critical_at_and_above_50_percent() {
341        assert_eq!(
342            InterpretationLevel::for_waste_ratio(0.50),
343            InterpretationLevel::Critical
344        );
345        assert_eq!(
346            InterpretationLevel::for_waste_ratio(0.75),
347            InterpretationLevel::Critical
348        );
349        // demo.json fixture: 9 avoidable / 10 total = 0.9
350        assert_eq!(
351            InterpretationLevel::for_waste_ratio(0.9),
352            InterpretationLevel::Critical
353        );
354        assert_eq!(
355            InterpretationLevel::for_waste_ratio(1.0),
356            InterpretationLevel::Critical
357        );
358    }
359
360    /// `WASTE_RATIO_HIGH` is anchored on the **default** quality gate
361    /// threshold. The anchor is structural: a user who raises
362    /// `io_waste_ratio_max = 0.80` in their config still sees the same
363    /// 30% heuristic band because the two dials express independent
364    /// concepts (policy vs heuristic).
365    #[test]
366    fn waste_ratio_high_matches_default_gate_threshold() {
367        assert!(
368            (WASTE_RATIO_HIGH - 0.30).abs() < f64::EPSILON,
369            "WASTE_RATIO_HIGH drifted from the default io_waste_ratio_max (0.30); \
370             update both or neither"
371        );
372    }
373
374    // --- short_label tests ---
375
376    #[test]
377    fn short_label_returns_lowercase_string() {
378        assert_eq!(InterpretationLevel::Healthy.short_label(), "healthy");
379        assert_eq!(InterpretationLevel::Moderate.short_label(), "moderate");
380        assert_eq!(InterpretationLevel::High.short_label(), "high");
381        assert_eq!(InterpretationLevel::Critical.short_label(), "critical");
382    }
383
384    // --- NaN handling ---
385
386    #[test]
387    fn nan_iis_classified_healthy() {
388        // NaN compares false against all thresholds, so it falls through
389        // to Healthy. This is the safe default: missing data should not
390        // render as a red CLI warning.
391        assert_eq!(
392            InterpretationLevel::for_iis(f64::NAN),
393            InterpretationLevel::Healthy
394        );
395    }
396
397    #[test]
398    fn nan_waste_ratio_classified_healthy() {
399        assert_eq!(
400            InterpretationLevel::for_waste_ratio(f64::NAN),
401            InterpretationLevel::Healthy
402        );
403    }
404
405    // --- Infinity handling ---
406
407    /// `+Infinity` must classify as `Critical` (it satisfies every `>=`
408    /// threshold). `-Infinity` must classify as `Healthy` (it satisfies
409    /// none). These document the behavior for downstream renderers that
410    /// format `f64` scores with `{:.1}` / `{:.6}`, Rust's `Display` impl
411    /// for infinities prints `"inf"` / `"-inf"` without panicking, so the
412    /// CLI stays crash-safe even on adversarial inputs.
413    #[test]
414    fn positive_infinity_iis_classified_critical() {
415        assert_eq!(
416            InterpretationLevel::for_iis(f64::INFINITY),
417            InterpretationLevel::Critical
418        );
419    }
420
421    #[test]
422    fn negative_infinity_iis_classified_healthy() {
423        assert_eq!(
424            InterpretationLevel::for_iis(f64::NEG_INFINITY),
425            InterpretationLevel::Healthy
426        );
427    }
428
429    #[test]
430    fn positive_infinity_waste_ratio_classified_critical() {
431        assert_eq!(
432            InterpretationLevel::for_waste_ratio(f64::INFINITY),
433            InterpretationLevel::Critical
434        );
435    }
436
437    #[test]
438    fn negative_infinity_waste_ratio_classified_healthy() {
439        assert_eq!(
440            InterpretationLevel::for_waste_ratio(f64::NEG_INFINITY),
441            InterpretationLevel::Healthy
442        );
443    }
444}