Skip to main content

monitrs_core/diagnostics/rules/
mod.rs

1//! The §11.2 rules, and the set that evaluates all of them.
2//!
3//! Each rule is a small, stateless struct holding a copy of the thresholds it
4//! compares against. That shape is what makes the rules deterministic: given the
5//! same snapshot and the same history they always produce the same finding, which
6//! is what §21 M5 means by "diagnostic rules have deterministic tests".
7//!
8//! # What a rule may and may not say
9//!
10//! §11.3 draws a hard line: **never** diagnose out-of-memory kills, a memory leak,
11//! a failing disk, malware, or thermal throttling from a single ambiguous metric.
12//! No rule here names any of those things, and
13//! [`crate::diagnostics`]'s own tests assert it. What the rules do instead is
14//! report the measurement, the count, and the window, and mark how much of a
15//! conclusion the evidence really supports:
16//!
17//! * [`Confidence::High`] — the finding *is* the measurement (a zombie count, our
18//!   own CPU usage, the collector's lag).
19//! * [`Confidence::Medium`] — a threshold judgement sustained across several samples.
20//! * [`Confidence::Low`] — an inference from one sample, which §11.3 requires to be
21//!   marked as such.
22//!
23//! # Bytes in summaries
24//!
25//! Summaries carry percentages, counts, durations, and ratios only. Byte-valued
26//! figures go into [`Evidence`](super::Evidence), because the IEC/SI choice is a
27//! display setting (§12) and the diagnostic engine does not get to decide how a byte
28//! count looks.
29
30mod collector;
31mod cpu;
32mod memory;
33mod process;
34mod psi;
35mod storage;
36
37use core::fmt;
38
39use crate::history::{ContributorMetric, ContributorSet};
40use crate::model::{Confidence, Severity, SystemSnapshot};
41use crate::units::Percent;
42
43use super::{DiagnosticRule, Finding, HistoryWindow, Thresholds};
44
45pub use collector::{
46    COLLECTOR_BEHIND, CollectorBehindRule, SELF_OVERHEAD, SNAPSHOT_STALE, SelfOverheadRule,
47    SnapshotStaleRule, budget_share,
48};
49pub use cpu::{CPU_SATURATION, LOAD_HIGH, LoadHighRule, SustainedCpuSaturationRule};
50pub use memory::{
51    MEMORY_AVAILABILITY_LOW, MemoryAvailabilityLowRule, SWAP_ACTIVITY, SwapActivityRule,
52};
53pub use process::{
54    PROCESS_CPU_SPIKE, PROCESS_RSS_GROWTH, ProcessCpuSpikeRule, ProcessRssGrowthRule,
55    ZOMBIE_PRESENT, ZombieProcessRule,
56};
57pub use psi::{IoPsiElevatedRule, MemoryPsiElevatedRule, PSI_IO_ELEVATED, PSI_MEMORY_ELEVATED};
58pub use storage::{DISK_SUSTAINED_BUSY, DiskBusyRule, disk_signal_ready};
59
60/// How many contributors a summary names.
61///
62/// Three is what the §11.3 example prints ("rustc 287%, postgres 54%") plus one:
63/// enough to recognise a pattern, few enough to fit a status line (§5.4).
64const SUMMARY_CONTRIBUTORS: usize = 3;
65
66/// Every §11.2 rule, in one evaluable set.
67pub struct RuleSet {
68    rules: Vec<Box<dyn DiagnosticRule>>,
69    enabled: bool,
70}
71
72impl RuleSet {
73    /// Builds the full §11.2 rule set from configuration.
74    #[must_use]
75    pub fn new(thresholds: Thresholds) -> Self {
76        let thresholds = thresholds.sanitized();
77        Self {
78            enabled: thresholds.enabled,
79            rules: vec![
80                Box::new(SustainedCpuSaturationRule::new(thresholds)),
81                Box::new(LoadHighRule::new(thresholds)),
82                Box::new(MemoryAvailabilityLowRule::new(thresholds)),
83                Box::new(SwapActivityRule::new(thresholds)),
84                Box::new(MemoryPsiElevatedRule::new(thresholds)),
85                Box::new(IoPsiElevatedRule::new(thresholds)),
86                Box::new(DiskBusyRule::new(thresholds)),
87                Box::new(ProcessRssGrowthRule::new(thresholds)),
88                Box::new(ZombieProcessRule::new(thresholds)),
89                Box::new(ProcessCpuSpikeRule::new(thresholds)),
90                Box::new(CollectorBehindRule::new(thresholds)),
91                Box::new(SnapshotStaleRule::new(thresholds)),
92                Box::new(SelfOverheadRule::new(thresholds)),
93            ],
94        }
95    }
96
97    /// Evaluates every rule, most severe first.
98    ///
99    /// The order is total and stable — severity descending, then rule id — so the
100    /// Inspect screen does not reshuffle between frames (§7.5). Returns nothing at
101    /// all when `diagnostics.enabled` is false (§12).
102    #[must_use]
103    pub fn evaluate(&self, current: &SystemSnapshot, history: &HistoryWindow<'_>) -> Vec<Finding> {
104        if !self.enabled {
105            return Vec::new();
106        }
107        let mut findings: Vec<Finding> = self
108            .rules
109            .iter()
110            .filter_map(|rule| rule.evaluate(current, history))
111            .collect();
112        findings.sort_by(|left, right| {
113            right
114                .severity
115                .cmp(&left.severity)
116                .then_with(|| left.rule_id.cmp(right.rule_id))
117        });
118        findings
119    }
120
121    /// The rules in the set.
122    #[must_use]
123    pub fn rules(&self) -> &[Box<dyn DiagnosticRule>] {
124        &self.rules
125    }
126
127    /// Every rule id, in registration order.
128    #[must_use]
129    pub fn ids(&self) -> Vec<&'static str> {
130        self.rules.iter().map(|rule| rule.id()).collect()
131    }
132
133    /// How many rules are registered.
134    #[must_use]
135    pub fn len(&self) -> usize {
136        self.rules.len()
137    }
138
139    /// Whether the set holds no rules.
140    #[must_use]
141    pub fn is_empty(&self) -> bool {
142        self.rules.is_empty()
143    }
144}
145
146impl Default for RuleSet {
147    /// The §11.2 rules with the §12 default thresholds.
148    fn default() -> Self {
149        Self::new(Thresholds::default())
150    }
151}
152
153impl fmt::Debug for RuleSet {
154    /// Prints the rule ids, since a `dyn DiagnosticRule` has no other shape.
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        f.debug_struct("RuleSet")
157            .field("enabled", &self.enabled)
158            .field("rules", &self.ids())
159            .finish()
160    }
161}
162
163/// A threshold expressed as a [`Percent`], falling back to zero.
164///
165/// Thresholds are sanitized at construction, so the fallback is unreachable in
166/// practice; it exists because §14.3 forbids a panicking conversion.
167pub(crate) fn as_percent(value: f32) -> Percent {
168    Percent::new(value).unwrap_or(Percent::ZERO)
169}
170
171/// A count as a `u64` for [`crate::model::MeasuredValue::Count`].
172pub(crate) fn as_count(value: usize) -> u64 {
173    u64::try_from(value).unwrap_or(u64::MAX)
174}
175
176/// How many times larger `value` is than `reference`, for unit-free summaries.
177///
178/// Returns `None` when the ratio is undefined, so a summary never prints `inf`.
179pub(crate) fn ratio(value: f64, reference: f64) -> Option<f64> {
180    if reference <= 0.0 {
181        return None;
182    }
183    let ratio = value / reference;
184    ratio.is_finite().then_some(ratio)
185}
186
187/// The top contributors for a percentage-valued metric, as `name value` pairs.
188///
189/// Reads the retained contributor evidence (§2.2) rather than re-sorting the
190/// process table: the list is already deduplicated by identity and bounded to the
191/// top `K`, so this costs nothing that scales with the process count (§8.5).
192pub(crate) fn percent_contributors(
193    contributors: &ContributorSet,
194    metric: ContributorMetric,
195) -> Option<String> {
196    let rendered: Vec<String> = contributors
197        .metric(metric)
198        .entries()
199        .iter()
200        .take(SUMMARY_CONTRIBUTORS)
201        .filter_map(|entry| match entry.value {
202            crate::model::MeasuredValue::Percent(percent) => {
203                Some(format!("{} {percent}", entry.name))
204            }
205            _ => None,
206        })
207        .collect();
208    (!rendered.is_empty()).then(|| rendered.join(", "))
209}
210
211/// The top contributors for a byte-valued metric, as shares of `whole`.
212///
213/// Shares rather than byte counts so the sentence does not have to pick an IEC or
214/// SI rendering (§12).
215pub(crate) fn share_contributors(
216    contributors: &ContributorSet,
217    metric: ContributorMetric,
218    whole: u64,
219) -> Option<String> {
220    let rendered: Vec<String> = contributors
221        .metric(metric)
222        .entries()
223        .iter()
224        .take(SUMMARY_CONTRIBUTORS)
225        .filter_map(|entry| match entry.value {
226            crate::model::MeasuredValue::Bytes(bytes) => {
227                Percent::ratio(bytes, whole).map(|share| format!("{} {share}", entry.name))
228            }
229            _ => None,
230        })
231        .collect();
232    (!rendered.is_empty()).then(|| rendered.join(", "))
233}
234
235/// The evidence-coverage sentence from §2.2, when coverage was measurable.
236///
237/// Worded as "account for" rather than "caused": §2.2 forbids claiming causation,
238/// and the wording is part of that promise.
239pub(crate) fn coverage_sentence(
240    contributors: &ContributorSet,
241    metric: ContributorMetric,
242    noun: &str,
243) -> Option<String> {
244    contributors
245        .metric(metric)
246        .coverage()
247        .fresh()
248        .map(|coverage| {
249            format!(" Retained top processes account for {coverage} of observed {noun}.")
250        })
251}
252
253/// The severity a sustained pair of counts resolves to.
254///
255/// Returns `None` when even the watch condition was not sustained, which is the
256/// normal answer for a healthy system.
257pub(crate) fn escalate(watch: bool, critical: bool) -> Option<Severity> {
258    match (critical, watch) {
259        (true, _) => Some(Severity::Critical),
260        (false, true) => Some(Severity::Watch),
261        (false, false) => None,
262    }
263}
264
265/// The confidence a multi-sample threshold judgement deserves (§11.3).
266pub(crate) const SUSTAINED_CONFIDENCE: Confidence = Confidence::Medium;
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use crate::diagnostics::fixtures::{Timeline, set_cpu};
272    use core::time::Duration;
273
274    #[test]
275    fn the_set_registers_every_rule_named_in_section_eleven_two() {
276        let set = RuleSet::default();
277        assert_eq!(set.len(), 13, "§11.2 lists thirteen rules");
278        assert!(!set.is_empty());
279        assert_eq!(set.rules().len(), set.len());
280
281        let mut ids = set.ids();
282        let count = ids.len();
283        ids.sort_unstable();
284        ids.dedup();
285        assert_eq!(ids.len(), count, "rule ids must be unique");
286    }
287
288    #[test]
289    fn rule_ids_are_stable_lower_case_dotted_names() {
290        for id in RuleSet::default().ids() {
291            assert!(id.is_ascii(), "{id} is not ASCII");
292            assert!(id.contains('.'), "{id} is not namespaced");
293            assert_eq!(id.to_lowercase(), id, "{id} is not lower case");
294        }
295    }
296
297    #[test]
298    fn a_healthy_system_produces_no_findings() {
299        let mut timeline = Timeline::new(Duration::from_secs(1));
300        let current = timeline.push_many(20, |snapshot| set_cpu(snapshot, 12.0));
301        let findings = RuleSet::default().evaluate(&current, &timeline.window());
302        assert!(findings.is_empty(), "{findings:#?}");
303    }
304
305    #[test]
306    fn disabling_diagnostics_produces_no_findings_at_all() {
307        let mut timeline = Timeline::new(Duration::from_secs(1));
308        let current = timeline.push_many(20, |snapshot| set_cpu(snapshot, 99.0));
309        let set = RuleSet::new(Thresholds {
310            enabled: false,
311            ..Thresholds::default()
312        });
313        assert!(set.evaluate(&current, &timeline.window()).is_empty());
314    }
315
316    #[test]
317    fn findings_are_ordered_most_severe_first_and_deterministically() {
318        let mut timeline = Timeline::new(Duration::from_secs(1));
319        let current = timeline.push_many(20, |snapshot| {
320            set_cpu(snapshot, 99.0);
321            crate::diagnostics::fixtures::set_load(snapshot, 24.0);
322        });
323        let set = RuleSet::default();
324        let first = set.evaluate(&current, &timeline.window());
325        let second = set.evaluate(&current, &timeline.window());
326
327        assert_eq!(first, second, "evaluation must be deterministic");
328        assert!(first.len() >= 2, "{first:#?}");
329        for pair in first.windows(2) {
330            let [left, right] = pair else { continue };
331            assert!(
332                left.severity >= right.severity,
333                "{} before {}",
334                left.rule_id,
335                right.rule_id
336            );
337        }
338    }
339
340    #[test]
341    fn a_ratio_is_none_when_it_would_be_undefined() {
342        assert!(ratio(1.0, 0.0).is_none());
343        assert!(ratio(f64::NAN, 1.0).is_none());
344        assert!(ratio(4.0, 2.0).is_some_and(|value| (value - 2.0).abs() < f64::EPSILON));
345    }
346
347    #[test]
348    fn escalation_prefers_the_more_severe_outcome() {
349        assert_eq!(escalate(false, false), None);
350        assert_eq!(escalate(true, false), Some(Severity::Watch));
351        assert_eq!(escalate(true, true), Some(Severity::Critical));
352        assert_eq!(
353            escalate(false, true),
354            Some(Severity::Critical),
355            "a critical condition is critical even if watch was not counted"
356        );
357    }
358
359    #[test]
360    fn the_debug_form_names_the_registered_rules() {
361        let printed = format!("{:?}", RuleSet::default());
362        assert!(printed.contains(CPU_SATURATION), "{printed}");
363        assert!(printed.contains("enabled: true"), "{printed}");
364    }
365}