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}