Skip to main content

monitrs_core/diagnostics/
finding.rs

1//! The §11.1 rule interface: what a rule is, and what it may say.
2//!
3//! A [`Finding`] is the *only* thing a rule may produce. It carries the raw
4//! evidence and the time window that evidence covers, because §11.3 requires both
5//! and because a conclusion without its inputs cannot be checked by the person
6//! reading it. [`Confidence`] is part of the payload for the same reason: §11.3
7//! requires heuristics to be marked as such, and §2.2 forbids claiming causation.
8
9use core::time::Duration;
10
11use crate::model::{Confidence, Measurement, Severity, SystemSnapshot};
12use crate::units::{ByteUnits, format_duration};
13
14use super::HistoryWindow;
15
16/// The span of time one piece of evidence covers (§11.3).
17///
18/// A measurement read from the current sample and a count taken over fifteen
19/// samples are both evidence, but they support very different claims, so the
20/// window travels with the measurement rather than being described in prose.
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize))]
23pub struct TimeWindow {
24    /// Monotonic span between the oldest and newest sample the evidence covers.
25    pub span: Duration,
26    /// How many samples were actually read.
27    ///
28    /// Samples whose input was unavailable are not counted: §26 forbids treating
29    /// a missing reading as a reading (§8.2).
30    pub samples: usize,
31}
32
33impl TimeWindow {
34    /// A single reading from the snapshot being evaluated.
35    pub const CURRENT_SAMPLE: Self = Self {
36        span: Duration::ZERO,
37        samples: 1,
38    };
39
40    /// A window covering `samples` readings across `span`.
41    #[must_use]
42    pub const fn new(span: Duration, samples: usize) -> Self {
43        Self { span, samples }
44    }
45
46    /// A single reading that is itself a moving average, such as a Linux PSI
47    /// `avg10` figure.
48    ///
49    /// One read of `avg10` already summarizes ten seconds of kernel-measured
50    /// stall time, so the honest window is those ten seconds even though only one
51    /// sample was read.
52    #[must_use]
53    pub const fn moving_average(span: Duration) -> Self {
54        Self { span, samples: 1 }
55    }
56
57    /// Whether this is a single instantaneous reading.
58    #[must_use]
59    pub const fn is_current_sample(&self) -> bool {
60        self.samples <= 1 && self.span.is_zero()
61    }
62
63    /// Renders the window for the Inspect screen (§7.5).
64    #[must_use]
65    pub fn render(&self) -> String {
66        if self.is_current_sample() {
67            return "current sample".to_owned();
68        }
69        if self.samples <= 1 {
70            return format!("last {}", format_duration(self.span));
71        }
72        format!(
73            "{} samples over {}",
74            self.samples,
75            format_duration(self.span)
76        )
77    }
78}
79
80/// One raw measurement plus the window it was measured over (§11.3).
81#[derive(Clone, Copy, Debug, PartialEq)]
82#[cfg_attr(feature = "serde", derive(serde::Serialize))]
83pub struct Evidence {
84    /// The raw measurement. Never a derived verdict, always a number.
85    pub measurement: Measurement,
86    /// The time window `measurement` covers.
87    pub window: TimeWindow,
88}
89
90impl Evidence {
91    /// Builds evidence covering an explicit window.
92    #[must_use]
93    pub const fn new(measurement: Measurement, window: TimeWindow) -> Self {
94        Self {
95            measurement,
96            window,
97        }
98    }
99
100    /// Builds evidence read from the snapshot being evaluated.
101    #[must_use]
102    pub const fn current(measurement: Measurement) -> Self {
103        Self::new(measurement, TimeWindow::CURRENT_SAMPLE)
104    }
105
106    /// Renders as `label value (window)`, e.g. `cpu busy 91% (15 samples over 14s)`.
107    ///
108    /// The byte unit family is passed in because §12 makes it a display setting;
109    /// nothing in the diagnostic engine decides how a byte count looks.
110    #[must_use]
111    pub fn render(&self, units: ByteUnits) -> String {
112        if self.window.is_current_sample() {
113            return self.measurement.render(units);
114        }
115        format!(
116            "{} ({})",
117            self.measurement.render(units),
118            self.window.render()
119        )
120    }
121}
122
123/// One rule's conclusion about the current state of the system (§11.1).
124#[derive(Clone, Debug, PartialEq)]
125#[cfg_attr(feature = "serde", derive(serde::Serialize))]
126pub struct Finding {
127    /// The stable identifier of the rule that produced this.
128    ///
129    /// A `&'static str` rather than an enum so that rules can be registered
130    /// without every consumer needing to be recompiled against a closed set, and
131    /// so that the id can be logged and exported verbatim.
132    pub rule_id: &'static str,
133    /// How serious this is.
134    pub severity: Severity,
135    /// A short headline, e.g. `Sustained CPU saturation`.
136    pub title: String,
137    /// One or two sentences of explanation, including the counts the rule used.
138    ///
139    /// Deliberately free of byte counts: the unit family is a display setting, so
140    /// byte-valued figures belong in [`Self::evidence`] where the UI can format
141    /// them (§12).
142    pub summary: String,
143    /// The raw measurements the conclusion rests on (§11.3).
144    pub evidence: Vec<Evidence>,
145    /// How much the evidence actually supports the conclusion (§11.3).
146    pub confidence: Confidence,
147}
148
149impl Finding {
150    /// Builds a finding with no evidence attached yet.
151    #[must_use]
152    pub fn new(
153        rule_id: &'static str,
154        severity: Severity,
155        title: impl Into<String>,
156        summary: impl Into<String>,
157        confidence: Confidence,
158    ) -> Self {
159        Self {
160            rule_id,
161            severity,
162            title: title.into(),
163            summary: summary.into(),
164            evidence: Vec::new(),
165            confidence,
166        }
167    }
168
169    /// Attaches the raw evidence §11.3 requires.
170    #[must_use]
171    pub fn with_evidence(mut self, evidence: Vec<Evidence>) -> Self {
172        self.evidence = evidence;
173        self
174    }
175
176    /// The redundant non-color cue for this finding's severity (§5.2).
177    #[must_use]
178    pub const fn symbol(&self) -> char {
179        self.severity.symbol()
180    }
181
182    /// The `WATCH: Sustained CPU saturation` line from §11.3's example.
183    #[must_use]
184    pub fn headline(&self) -> String {
185        format!("{}: {}", self.severity.label().to_uppercase(), self.title)
186    }
187
188    /// The `Evidence: ...` line from §11.3's example.
189    #[must_use]
190    pub fn render_evidence(&self, units: ByteUnits) -> String {
191        self.evidence
192            .iter()
193            .map(|item| item.render(units))
194            .collect::<Vec<_>>()
195            .join("; ")
196    }
197
198    /// The `Confidence: medium.` line from §11.3's example.
199    #[must_use]
200    pub fn render_confidence(&self) -> String {
201        format!("confidence: {}", self.confidence.label())
202    }
203}
204
205/// A deterministic rule over collected evidence (§11.1).
206///
207/// Rules are stateless and side-effect free: everything they may read is in the
208/// two arguments, so the same inputs always produce the same finding. That is
209/// what makes them testable from fixtures, and it is why hysteresis lives in
210/// [`super::PressureEngine`] rather than inside a rule (§11.3).
211///
212/// `Send + Sync` because §10.3 puts sampling on its own thread; a rule set must
213/// be shareable without a lock.
214pub trait DiagnosticRule: Send + Sync {
215    /// The stable identifier used in logs, exports, and tests.
216    fn id(&self) -> &'static str;
217
218    /// Evaluates the rule, returning a finding only when the rule actually fires.
219    ///
220    /// `None` is the normal answer. §11.3's minimum-sample requirement means a
221    /// rule must also return `None` while history is too short to support the
222    /// claim it makes, rather than guessing from one sample.
223    fn evaluate(&self, current: &SystemSnapshot, history: &HistoryWindow<'_>) -> Option<Finding>;
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use crate::model::MeasuredValue;
230    use crate::units::Percent;
231
232    fn percent(value: f32) -> Percent {
233        Percent::new(value).expect("valid percent")
234    }
235
236    #[test]
237    fn a_single_reading_renders_without_a_window_suffix() {
238        let evidence = Evidence::current(Measurement::new(
239            "cpu busy",
240            MeasuredValue::Percent(percent(91.0)),
241        ));
242        assert_eq!(evidence.render(ByteUnits::Iec), "cpu busy 91%");
243        assert!(evidence.window.is_current_sample());
244    }
245
246    #[test]
247    fn evidence_over_a_window_names_the_window_it_covers() {
248        let evidence = Evidence::new(
249            Measurement::new("samples above threshold", MeasuredValue::Count(12)),
250            TimeWindow::new(Duration::from_secs(14), 15),
251        );
252        assert_eq!(
253            evidence.render(ByteUnits::Iec),
254            "samples above threshold 12 (15 samples over 14s)"
255        );
256    }
257
258    #[test]
259    fn a_moving_average_reports_the_span_it_summarizes_not_one_sample() {
260        let window = TimeWindow::moving_average(Duration::from_secs(10));
261        assert_eq!(window.samples, 1);
262        assert_eq!(window.render(), "last 10s");
263        assert!(!window.is_current_sample());
264    }
265
266    #[test]
267    fn byte_evidence_is_rendered_in_the_callers_unit_family() {
268        let evidence = Evidence::current(Measurement::new(
269            "available",
270            MeasuredValue::Bytes(4 * 1024 * 1024 * 1024),
271        ));
272        assert_eq!(evidence.render(ByteUnits::Iec), "available 4.0 GiB");
273        assert_eq!(evidence.render(ByteUnits::Si), "available 4.3 GB");
274    }
275
276    #[test]
277    fn a_finding_renders_the_three_lines_from_the_specification_example() {
278        let finding = Finding::new(
279            "cpu.sustained_saturation",
280            Severity::Watch,
281            "Sustained CPU saturation",
282            "CPU busy at or above 90% in 12 of the last 15 samples.",
283            Confidence::Medium,
284        )
285        .with_evidence(vec![
286            Evidence::new(
287                Measurement::new("cpu busy", MeasuredValue::Percent(percent(91.0))),
288                TimeWindow::new(Duration::from_secs(14), 15),
289            ),
290            Evidence::current(Measurement::new("load1", MeasuredValue::Load(11.4))),
291        ]);
292
293        assert_eq!(finding.headline(), "WATCH: Sustained CPU saturation");
294        assert_eq!(
295            finding.render_evidence(ByteUnits::Iec),
296            "cpu busy 91% (15 samples over 14s); load1 11.40"
297        );
298        assert_eq!(finding.render_confidence(), "confidence: medium");
299        assert_eq!(finding.symbol(), '!', "§5.2 requires a non-color cue");
300    }
301
302    #[test]
303    fn a_finding_without_evidence_renders_an_empty_evidence_line_rather_than_panicking() {
304        let finding = Finding::new(
305            "test.rule",
306            Severity::Info,
307            "Title",
308            "Summary",
309            Confidence::Low,
310        );
311        assert_eq!(finding.render_evidence(ByteUnits::Iec), "");
312        assert_eq!(finding.headline(), "INFO: Title");
313    }
314}