Skip to main content

monitrs_core/diagnostics/rules/
process.rs

1//! Per-process rules: resident-set growth, zombies, and CPU spikes (§11.2).
2
3use core::time::Duration;
4
5use crate::history::{ContributorMetric, HistoricalSample};
6use crate::model::{
7    Confidence, MeasuredValue, Measurement, ProcessIdentity, ProcessSnapshot, ProcessState,
8    Severity, SystemSnapshot,
9};
10use crate::units::{Ellipsis, Percent, Rate, format_duration, truncate_tail};
11
12use super::super::{
13    DiagnosticRule, Evidence, Finding, HistoryWindow, Thresholds, TimeWindow, contributor_value,
14};
15use super::{SUSTAINED_CONFIDENCE, as_count, as_percent};
16
17/// Rule id for a process whose resident set is growing steadily.
18pub const PROCESS_RSS_GROWTH: &str = "process.rss_increasing";
19/// Rule id for the presence of unreaped processes.
20pub const ZOMBIE_PRESENT: &str = "process.zombie_present";
21/// Rule id for a sudden rise in one process's CPU usage.
22pub const PROCESS_CPU_SPIKE: &str = "process.cpu_spike";
23
24/// Display width process names are truncated to in summaries (§5.4).
25const NAME_WIDTH: usize = 24;
26
27/// How many zombie names a summary lists.
28const SUMMARY_ZOMBIES: usize = 3;
29
30/// Seconds per minute, for the growth rate.
31const SECONDS_PER_MINUTE: f64 = 60.0;
32
33/// A process whose resident set has been rising across the window (§11.2).
34///
35/// # What this rule does not say
36///
37/// It reports *growth*, with the samples and the span it was measured over, and
38/// nothing else. §11.3 forbids concluding why memory is growing from a size series:
39/// a cache filling to its configured bound, a JIT warming up, and a genuine bug all
40/// look identical here, and only the user knows which is expected.
41///
42/// Evidence comes from the retained contributor lists (§2.2), which are bounded to
43/// the top `K` per sample. A process that dropped out of the top `K` leaves a gap
44/// rather than a zero, and gaps do not count towards the minimum sample requirement.
45#[derive(Clone, Copy, Debug)]
46pub struct ProcessRssGrowthRule {
47    thresholds: Thresholds,
48}
49
50impl ProcessRssGrowthRule {
51    /// Builds the rule from sanitized thresholds.
52    #[must_use]
53    pub const fn new(thresholds: Thresholds) -> Self {
54        Self { thresholds }
55    }
56}
57
58/// One process's retained resident-set series.
59struct RssSeries {
60    first: f64,
61    last: f64,
62    span: Duration,
63    points: usize,
64    rises: usize,
65    comparisons: usize,
66}
67
68impl RssSeries {
69    /// Collects a process's retained RSS values from the window, oldest first.
70    fn collect(samples: &[&HistoricalSample], identity: ProcessIdentity) -> Option<Self> {
71        let mut values: Vec<(f64, Duration)> = Vec::new();
72        for sample in samples {
73            if let Some(value) =
74                contributor_value(sample, ContributorMetric::ResidentMemory, identity)
75            {
76                values.push((value, sample.monotonic_offset));
77            }
78        }
79        let (first, first_at) = *values.first()?;
80        let (last, last_at) = *values.last()?;
81        let rises = values
82            .iter()
83            .zip(values.iter().skip(1))
84            .filter(|(earlier, later)| later.0 > earlier.0)
85            .count();
86        Some(Self {
87            first,
88            last,
89            span: last_at.saturating_sub(first_at),
90            points: values.len(),
91            rises,
92            comparisons: values.len().saturating_sub(1),
93        })
94    }
95
96    /// Growth in bytes per minute across the series, or `None` if it did not grow.
97    fn per_minute(&self) -> Option<f64> {
98        let seconds = self.span.as_secs_f64();
99        if seconds <= 0.0 {
100            return None;
101        }
102        let growth = self.last - self.first;
103        (growth > 0.0).then(|| growth / seconds * SECONDS_PER_MINUTE)
104    }
105
106    /// Growth as a share of where the series started.
107    fn growth_percent(&self) -> Option<Percent> {
108        if self.first <= 0.0 {
109            return None;
110        }
111        // A calculated percentage is what `Percent` stores; anything the narrowing
112        // could not represent is rejected by `Percent::new` rather than displayed.
113        #[allow(clippy::cast_possible_truncation)]
114        let percent = ((self.last - self.first) / self.first * 100.0) as f32;
115        Percent::new(percent)
116    }
117}
118
119impl DiagnosticRule for ProcessRssGrowthRule {
120    fn id(&self) -> &'static str {
121        PROCESS_RSS_GROWTH
122    }
123
124    fn evaluate(&self, current: &SystemSnapshot, history: &HistoryWindow<'_>) -> Option<Finding> {
125        let thresholds = &self.thresholds;
126        let minimum = thresholds.minimum_samples();
127        let samples: Vec<&HistoricalSample> = history
128            .recent(thresholds.sustained_window)
129            .filter(|sample| sample.sequence <= current.sequence)
130            .collect();
131        if samples.len() < minimum {
132            return None;
133        }
134
135        let mut worst: Option<(&ProcessSnapshot, RssSeries, f64)> = None;
136        for process in &current.processes {
137            let Some(&rss) = process.memory.rss_bytes.fresh() else {
138                continue;
139            };
140            if rss < thresholds.process_rss_minimum_bytes {
141                continue;
142            }
143            let Some(series) = RssSeries::collect(&samples, process.identity) else {
144                continue;
145            };
146            if series.points < minimum {
147                continue;
148            }
149            // A sawtooth rises about half the time and ends where it started, so
150            // the end-to-end rate check below is what rejects it. This only filters
151            // series that mostly fall.
152            if series.rises * 2 < series.comparisons {
153                continue;
154            }
155            let Some(per_minute) = series.per_minute() else {
156                continue;
157            };
158            let threshold = thresholds.process_rss_growth_bytes_per_minute as f64;
159            if per_minute < threshold {
160                continue;
161            }
162            if worst
163                .as_ref()
164                .is_none_or(|(_, _, current_rate)| per_minute > *current_rate)
165            {
166                worst = Some((process, series, per_minute));
167            }
168        }
169
170        let (process, series, per_minute) = worst?;
171        let window = TimeWindow::new(series.span, series.points);
172        // The difference of two resident set sizes is a byte count (§10.4); it is
173        // only floating point because history stores comparable scalars, and the
174        // rule above only reaches here when the difference is positive.
175        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
176        let growth_bytes = (series.last - series.first).max(0.0) as u64;
177
178        let mut evidence = vec![
179            Evidence::new(
180                Measurement::new("resident growth", MeasuredValue::Bytes(growth_bytes)),
181                window,
182            ),
183            Evidence::new(
184                Measurement::new(
185                    "retained samples",
186                    MeasuredValue::Count(as_count(series.points)),
187                ),
188                window,
189            ),
190            Evidence::current(Measurement::new(
191                "pid",
192                MeasuredValue::Count(u64::from(process.identity.pid)),
193            )),
194        ];
195        if let Some(&rss) = process.memory.rss_bytes.fresh() {
196            evidence.push(Evidence::current(Measurement::new(
197                "resident set",
198                MeasuredValue::Bytes(rss),
199            )));
200        }
201        if let Some(rate) = Rate::new(per_minute / SECONDS_PER_MINUTE) {
202            evidence.push(Evidence::new(
203                Measurement::new("growth rate", MeasuredValue::ByteRate(rate)),
204                window,
205            ));
206        }
207        if let Some(share) = process.memory.share_of_total.fresh() {
208            evidence.push(Evidence::current(Measurement::new(
209                "share of total memory",
210                MeasuredValue::Percent(*share),
211            )));
212        }
213
214        let name = truncate_tail(&process.name, NAME_WIDTH, Ellipsis::Ascii);
215        let grown = series
216            .growth_percent()
217            .map_or_else(String::new, |percent| format!(" by {percent}"));
218        let summary = format!(
219            "{name} (pid {}) resident memory grew{grown} over {}, rising in {} of {} retained \
220             samples. This is an observed trend, not a diagnosis of its cause; a cache filling to \
221             its configured bound looks the same.",
222            process.identity.pid,
223            format_duration(series.span),
224            series.rises,
225            series.comparisons,
226        );
227
228        Some(
229            Finding::new(
230                PROCESS_RSS_GROWTH,
231                Severity::Watch,
232                "Process resident memory rising",
233                summary,
234                SUSTAINED_CONFIDENCE,
235            )
236            .with_evidence(evidence),
237        )
238    }
239}
240
241/// Processes that have exited and not been reaped (§11.2).
242///
243/// Directly measured, so the confidence is high — but what it *means* is left to
244/// the reader: a zombie is a normal instant in a process's teardown, and only a
245/// growing count suggests a parent that is not reaping. That is why the escalation
246/// to `watch` needs a count rather than a single occurrence.
247#[derive(Clone, Copy, Debug)]
248pub struct ZombieProcessRule {
249    thresholds: Thresholds,
250}
251
252impl ZombieProcessRule {
253    /// Builds the rule from sanitized thresholds.
254    #[must_use]
255    pub const fn new(thresholds: Thresholds) -> Self {
256        Self { thresholds }
257    }
258}
259
260impl DiagnosticRule for ZombieProcessRule {
261    fn id(&self) -> &'static str {
262        ZOMBIE_PRESENT
263    }
264
265    fn evaluate(&self, current: &SystemSnapshot, _history: &HistoryWindow<'_>) -> Option<Finding> {
266        let zombies: Vec<&ProcessSnapshot> = current
267            .processes
268            .iter()
269            .filter(|process| process.state == ProcessState::Zombie)
270            .collect();
271        if zombies.is_empty() {
272            return None;
273        }
274        let severity = if zombies.len() >= self.thresholds.zombie_watch_count {
275            Severity::Watch
276        } else {
277            Severity::Info
278        };
279
280        let evidence = vec![
281            Evidence::current(Measurement::new(
282                "zombie processes",
283                MeasuredValue::Count(as_count(zombies.len())),
284            )),
285            Evidence::current(Measurement::new(
286                "processes",
287                MeasuredValue::Count(as_count(current.process_count())),
288            )),
289            Evidence::current(Measurement::new(
290                "watch threshold",
291                MeasuredValue::Count(as_count(self.thresholds.zombie_watch_count)),
292            )),
293        ];
294
295        let named: Vec<String> = zombies
296            .iter()
297            .take(SUMMARY_ZOMBIES)
298            .map(|process| {
299                format!(
300                    "{} (pid {})",
301                    truncate_tail(&process.name, NAME_WIDTH, Ellipsis::Ascii),
302                    process.identity.pid
303                )
304            })
305            .collect();
306        let summary = format!(
307            "{} process(es) have exited without being reaped by their parent: {}. A zombie holds \
308             only a process table entry, and signalling one has no effect.",
309            zombies.len(),
310            named.join(", "),
311        );
312
313        Some(
314            Finding::new(
315                ZOMBIE_PRESENT,
316                severity,
317                "Unreaped (zombie) processes present",
318                summary,
319                Confidence::High,
320            )
321            .with_evidence(evidence),
322        )
323    }
324}
325
326/// One process's CPU usage rising sharply between two samples (§11.2).
327///
328/// Explicitly a one-sample inference, and therefore [`Confidence::Low`] as §11.3
329/// requires. The previous value comes from the retained contributor evidence, keyed
330/// on the full [`ProcessIdentity`], so a reused PID cannot be reported as a spike in
331/// the process that used to hold it (§26).
332#[derive(Clone, Copy, Debug)]
333pub struct ProcessCpuSpikeRule {
334    thresholds: Thresholds,
335}
336
337impl ProcessCpuSpikeRule {
338    /// Builds the rule from sanitized thresholds.
339    #[must_use]
340    pub const fn new(thresholds: Thresholds) -> Self {
341        Self { thresholds }
342    }
343}
344
345impl DiagnosticRule for ProcessCpuSpikeRule {
346    fn id(&self) -> &'static str {
347        PROCESS_CPU_SPIKE
348    }
349
350    fn evaluate(&self, current: &SystemSnapshot, history: &HistoryWindow<'_>) -> Option<Finding> {
351        let thresholds = &self.thresholds;
352        let previous = history.previous_sample(current.sequence)?;
353
354        let mut worst: Option<(&ProcessSnapshot, Percent, f32)> = None;
355        for process in &current.processes {
356            let Some(&cpu) = process.cpu.fresh() else {
357                continue;
358            };
359            if cpu.value() < thresholds.process_cpu_spike_percent {
360                continue;
361            }
362            let Some(earlier) =
363                contributor_value(previous, ContributorMetric::Cpu, process.identity)
364            else {
365                // Not in the previous retained set: §8.2 makes a first delta
366                // warming up, not a spike.
367                continue;
368            };
369            // The retained value was an f32 percentage before history widened it to
370            // a comparable scalar, so narrowing it back is lossless.
371            #[allow(clippy::cast_possible_truncation)]
372            let rise = cpu.value() - earlier as f32;
373            if rise < thresholds.process_cpu_spike_points {
374                continue;
375            }
376            if worst
377                .as_ref()
378                .is_none_or(|(_, _, current_rise)| rise > *current_rise)
379            {
380                #[allow(clippy::cast_possible_truncation)]
381                let earlier_percent = as_percent(earlier as f32);
382                worst = Some((process, earlier_percent, rise));
383            }
384        }
385
386        let (process, earlier, rise) = worst?;
387        let span = if current.elapsed.is_zero() {
388            history.expected_interval()
389        } else {
390            current.elapsed
391        };
392        let window = TimeWindow::new(span, 2);
393
394        let mut evidence = vec![
395            Evidence::current(Measurement::new(
396                "pid",
397                MeasuredValue::Count(u64::from(process.identity.pid)),
398            )),
399            Evidence::new(
400                Measurement::new("previous cpu", MeasuredValue::Percent(earlier)),
401                window,
402            ),
403            Evidence::new(
404                Measurement::new("rise", MeasuredValue::Percent(as_percent(rise))),
405                window,
406            ),
407        ];
408        if let Some(&cpu) = process.cpu.fresh() {
409            evidence.push(Evidence::current(Measurement::new(
410                "process cpu",
411                MeasuredValue::Percent(cpu),
412            )));
413        }
414        if let Some(usage) = current.cpu.total.fresh() {
415            evidence.push(Evidence::current(Measurement::new(
416                "cpu busy",
417                MeasuredValue::Percent(usage.busy),
418            )));
419        }
420
421        let name = truncate_tail(&process.name, NAME_WIDTH, Ellipsis::Ascii);
422        let now = process
423            .cpu
424            .fresh()
425            .map_or_else(String::new, |cpu| format!(" to {cpu}"));
426        let summary = format!(
427            "{name} (pid {}) rose {} points{now} between two samples ({}). One sample of \
428             correlation: it is not an explanation of what the system is doing.",
429            process.identity.pid,
430            as_percent(rise),
431            format_duration(span),
432        );
433
434        Some(
435            Finding::new(
436                PROCESS_CPU_SPIKE,
437                Severity::Info,
438                "Process CPU spike",
439                summary,
440                Confidence::Low,
441            )
442            .with_evidence(evidence),
443        )
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use crate::diagnostics::fixtures::{Timeline, add_process, set_cpu, set_memory, snapshot};
451    use crate::model::MetricState;
452
453    const TOTAL: u64 = 32 * 1024 * 1024 * 1024;
454    const MIB: u64 = 1024 * 1024;
455
456    fn growth_rule() -> ProcessRssGrowthRule {
457        ProcessRssGrowthRule::new(Thresholds::default().sanitized())
458    }
459
460    fn zombie_rule() -> ZombieProcessRule {
461        ZombieProcessRule::new(Thresholds::default().sanitized())
462    }
463
464    fn spike_rule() -> ProcessCpuSpikeRule {
465        ProcessCpuSpikeRule::new(Thresholds::default().sanitized())
466    }
467
468    fn timeline() -> Timeline {
469        Timeline::new(Duration::from_secs(1))
470    }
471
472    #[test]
473    fn a_process_with_a_steady_resident_set_produces_no_finding() {
474        let mut timeline = timeline();
475        let current = timeline.push_many(20, |snapshot| {
476            set_memory(snapshot, TOTAL, TOTAL / 2);
477            add_process(
478                snapshot,
479                4_242,
480                "server",
481                Some(5.0),
482                Some(512 * MIB),
483                ProcessState::Running,
484            );
485        });
486        assert!(
487            growth_rule()
488                .evaluate(&current, &timeline.window())
489                .is_none()
490        );
491    }
492
493    #[test]
494    fn a_steadily_growing_resident_set_is_reported_as_growth_not_as_a_cause() {
495        let mut timeline = timeline();
496        let mut rss = 512 * MIB;
497        let current = timeline.push_many(20, move |snapshot| {
498            rss = rss.saturating_add(64 * MIB);
499            set_memory(snapshot, TOTAL, TOTAL / 2);
500            add_process(
501                snapshot,
502                4_242,
503                "server",
504                Some(5.0),
505                Some(rss),
506                ProcessState::Running,
507            );
508        });
509
510        let finding = growth_rule()
511            .evaluate(&current, &timeline.window())
512            .expect("64 MiB per second is far above 64 MiB per minute");
513        assert_eq!(finding.rule_id, PROCESS_RSS_GROWTH);
514        assert_eq!(finding.severity, Severity::Watch);
515        assert_eq!(finding.confidence, Confidence::Medium);
516        assert!(
517            finding.summary.contains("server (pid 4242)"),
518            "{}",
519            finding.summary
520        );
521        assert!(
522            finding.summary.contains("not a diagnosis of its cause"),
523            "{}",
524            finding.summary
525        );
526
527        let labels: Vec<&str> = finding
528            .evidence
529            .iter()
530            .map(|item| item.measurement.label)
531            .collect();
532        assert!(labels.contains(&"resident growth"), "{labels:?}");
533        assert!(labels.contains(&"growth rate"), "{labels:?}");
534        assert!(labels.contains(&"retained samples"), "{labels:?}");
535    }
536
537    #[test]
538    fn a_sawtooth_resident_set_is_not_growth() {
539        let mut timeline = timeline();
540        let mut index = 0u64;
541        let current = timeline.push_many(20, move |snapshot| {
542            index += 1;
543            // The series ends on the same value the window begins on, so there is
544            // no end-to-end growth however often it rose.
545            let rss = if index.is_multiple_of(2) {
546                900 * MIB
547            } else {
548                500 * MIB
549            };
550            set_memory(snapshot, TOTAL, TOTAL / 2);
551            add_process(
552                snapshot,
553                4_242,
554                "server",
555                Some(5.0),
556                Some(rss),
557                ProcessState::Running,
558            );
559        });
560        assert!(
561            growth_rule()
562                .evaluate(&current, &timeline.window())
563                .is_none(),
564            "a process that returns to where it started has not grown"
565        );
566    }
567
568    #[test]
569    fn a_small_process_doubling_is_ignored() {
570        let mut timeline = timeline();
571        let mut rss = MIB;
572        let current = timeline.push_many(20, move |snapshot| {
573            rss = rss.saturating_mul(2).min(64 * MIB);
574            set_memory(snapshot, TOTAL, TOTAL / 2);
575            add_process(
576                snapshot,
577                4_242,
578                "tiny",
579                Some(1.0),
580                Some(rss),
581                ProcessState::Running,
582            );
583        });
584        assert!(
585            growth_rule()
586                .evaluate(&current, &timeline.window())
587                .is_none(),
588            "below the minimum resident set the noise is not worth reporting"
589        );
590    }
591
592    #[test]
593    fn growth_needs_the_minimum_number_of_retained_samples() {
594        let mut timeline = timeline();
595        let mut rss = 512 * MIB;
596        let current = timeline.push_many(5, move |snapshot| {
597            rss = rss.saturating_add(128 * MIB);
598            set_memory(snapshot, TOTAL, TOTAL / 2);
599            add_process(
600                snapshot,
601                4_242,
602                "server",
603                Some(5.0),
604                Some(rss),
605                ProcessState::Running,
606            );
607        });
608        assert!(
609            growth_rule()
610                .evaluate(&current, &timeline.window())
611                .is_none()
612        );
613    }
614
615    #[test]
616    fn a_reused_pid_does_not_inherit_the_previous_processes_growth() {
617        // The first process grows, exits, and its pid is reused by a new process
618        // whose resident set is large but new. Keyed on identity, the new process
619        // has no history at all (§26).
620        let mut timeline = timeline();
621        let mut rss = 512 * MIB;
622        timeline.push_many(15, move |snapshot| {
623            rss = rss.saturating_add(64 * MIB);
624            set_memory(snapshot, TOTAL, TOTAL / 2);
625            add_process(
626                snapshot,
627                4_242,
628                "server",
629                Some(5.0),
630                Some(rss),
631                ProcessState::Running,
632            );
633        });
634        let mut current = timeline.build(|snapshot| {
635            set_memory(snapshot, TOTAL, TOTAL / 2);
636            add_process(
637                snapshot,
638                4_242,
639                "server",
640                Some(5.0),
641                Some(4 * 1024 * MIB),
642                ProcessState::Running,
643            );
644        });
645        // Same pid, different start key: a different process.
646        if let Some(process) = current.processes.first_mut() {
647            process.identity = ProcessIdentity::new(4_242, 999_999);
648        }
649        assert!(
650            growth_rule()
651                .evaluate(&current, &timeline.window())
652                .is_none(),
653            "a reused pid must not inherit another process's series"
654        );
655    }
656
657    #[test]
658    fn an_unmeasured_resident_set_is_not_growth() {
659        let mut timeline = timeline();
660        let current = timeline.push_many(20, |snapshot| {
661            set_memory(snapshot, TOTAL, TOTAL / 2);
662            add_process(
663                snapshot,
664                4_242,
665                "server",
666                Some(5.0),
667                None,
668                ProcessState::Running,
669            );
670        });
671        assert!(
672            growth_rule()
673                .evaluate(&current, &timeline.window())
674                .is_none()
675        );
676    }
677
678    #[test]
679    fn no_zombies_means_no_finding() {
680        let timeline = timeline();
681        let mut current = snapshot();
682        add_process(
683            &mut current,
684            1,
685            "init",
686            Some(0.0),
687            None,
688            ProcessState::Sleeping,
689        );
690        assert!(
691            zombie_rule()
692                .evaluate(&current, &timeline.window())
693                .is_none()
694        );
695    }
696
697    #[test]
698    fn one_zombie_is_informational_rather_than_a_warning() {
699        let timeline = timeline();
700        let mut current = snapshot();
701        add_process(
702            &mut current,
703            1,
704            "init",
705            Some(0.0),
706            None,
707            ProcessState::Sleeping,
708        );
709        add_process(
710            &mut current,
711            9_182,
712            "node",
713            None,
714            None,
715            ProcessState::Zombie,
716        );
717
718        let finding = zombie_rule()
719            .evaluate(&current, &timeline.window())
720            .expect("presence is reported");
721        assert_eq!(finding.severity, Severity::Info);
722        assert_eq!(finding.confidence, Confidence::High);
723        assert!(
724            finding.summary.contains("node (pid 9182)"),
725            "{}",
726            finding.summary
727        );
728        assert!(
729            finding.summary.contains("no effect"),
730            "§15.1: signalling a zombie does nothing: {}",
731            finding.summary
732        );
733    }
734
735    #[test]
736    fn a_pile_of_zombies_escalates_to_watch() {
737        let timeline = timeline();
738        let mut current = snapshot();
739        for pid in 100..112 {
740            add_process(
741                &mut current,
742                pid,
743                "worker",
744                None,
745                None,
746                ProcessState::Zombie,
747            );
748        }
749        let finding = zombie_rule()
750            .evaluate(&current, &timeline.window())
751            .expect("twelve zombies is above the watch threshold");
752        assert_eq!(finding.severity, Severity::Watch);
753        let count = finding
754            .evidence
755            .iter()
756            .find(|item| item.measurement.label == "zombie processes")
757            .expect("the count is evidence");
758        assert_eq!(count.measurement.value, MeasuredValue::Count(12));
759    }
760
761    #[test]
762    fn a_spike_needs_a_previous_sample_to_compare_against() {
763        let mut timeline = timeline();
764        let current = timeline.push(|snapshot| {
765            set_cpu(snapshot, 90.0);
766            add_process(
767                snapshot,
768                31_842,
769                "rustc",
770                Some(287.0),
771                None,
772                ProcessState::Running,
773            );
774        });
775        assert!(
776            spike_rule()
777                .evaluate(&current, &timeline.window())
778                .is_none(),
779            "§8.2: the first delta sample is warming up, not a spike"
780        );
781    }
782
783    #[test]
784    fn a_sharp_rise_in_one_process_is_reported_with_low_confidence() {
785        let mut timeline = timeline();
786        timeline.push_many(3, |snapshot| {
787            set_cpu(snapshot, 20.0);
788            add_process(
789                snapshot,
790                31_842,
791                "rustc",
792                Some(12.0),
793                None,
794                ProcessState::Running,
795            );
796        });
797        let current = timeline.build(|snapshot| {
798            set_cpu(snapshot, 95.0);
799            add_process(
800                snapshot,
801                31_842,
802                "rustc",
803                Some(287.0),
804                None,
805                ProcessState::Running,
806            );
807        });
808
809        let finding = spike_rule()
810            .evaluate(&current, &timeline.window())
811            .expect("a 275 point rise is a spike");
812        assert_eq!(finding.severity, Severity::Info);
813        assert_eq!(
814            finding.confidence,
815            Confidence::Low,
816            "§11.3: one sample of evidence is low confidence"
817        );
818        assert!(
819            finding.summary.contains("rustc (pid 31842)"),
820            "{}",
821            finding.summary
822        );
823        assert!(
824            finding.summary.contains("not an explanation"),
825            "§2.2 forbids claiming causation: {}",
826            finding.summary
827        );
828
829        let labels: Vec<&str> = finding
830            .evidence
831            .iter()
832            .map(|item| item.measurement.label)
833            .collect();
834        assert!(labels.contains(&"previous cpu"), "{labels:?}");
835        assert!(labels.contains(&"rise"), "{labels:?}");
836    }
837
838    #[test]
839    fn a_process_below_the_spike_floor_is_ignored_however_fast_it_rose() {
840        let mut timeline = timeline();
841        timeline.push_many(3, |snapshot| {
842            add_process(
843                snapshot,
844                31_842,
845                "rustc",
846                Some(1.0),
847                None,
848                ProcessState::Running,
849            );
850        });
851        let current = timeline.build(|snapshot| {
852            add_process(
853                snapshot,
854                31_842,
855                "rustc",
856                Some(80.0),
857                None,
858                ProcessState::Running,
859            );
860        });
861        assert!(
862            spike_rule()
863                .evaluate(&current, &timeline.window())
864                .is_none(),
865            "80% of one core is not a spike worth reporting"
866        );
867    }
868
869    #[test]
870    fn a_new_process_at_high_cpu_is_not_a_spike() {
871        let mut timeline = timeline();
872        timeline.push_many(3, |snapshot| {
873            add_process(snapshot, 1, "init", Some(0.0), None, ProcessState::Sleeping);
874        });
875        let current = timeline.build(|snapshot| {
876            add_process(snapshot, 1, "init", Some(0.0), None, ProcessState::Sleeping);
877            add_process(
878                snapshot,
879                31_842,
880                "rustc",
881                Some(287.0),
882                None,
883                ProcessState::Running,
884            );
885        });
886        assert!(
887            spike_rule()
888                .evaluate(&current, &timeline.window())
889                .is_none(),
890            "a process with no previous retained value has no delta (§8.2)"
891        );
892    }
893
894    #[test]
895    fn an_unmeasured_process_cpu_is_not_a_spike() {
896        let mut timeline = timeline();
897        timeline.push_many(3, |snapshot| {
898            add_process(
899                snapshot,
900                31_842,
901                "rustc",
902                Some(12.0),
903                None,
904                ProcessState::Running,
905            );
906        });
907        let mut current = timeline.build(|snapshot| {
908            add_process(
909                snapshot,
910                31_842,
911                "rustc",
912                Some(287.0),
913                None,
914                ProcessState::Running,
915            );
916        });
917        if let Some(process) = current.processes.first_mut() {
918            process.cpu = MetricState::PermissionDenied;
919        }
920        assert!(
921            spike_rule()
922                .evaluate(&current, &timeline.window())
923                .is_none()
924        );
925    }
926
927    #[test]
928    fn the_largest_rise_is_the_one_reported() {
929        let mut timeline = timeline();
930        timeline.push_many(3, |snapshot| {
931            add_process(
932                snapshot,
933                1_221,
934                "postgres",
935                Some(10.0),
936                None,
937                ProcessState::Running,
938            );
939            add_process(
940                snapshot,
941                31_842,
942                "rustc",
943                Some(10.0),
944                None,
945                ProcessState::Running,
946            );
947        });
948        let current = timeline.build(|snapshot| {
949            add_process(
950                snapshot,
951                1_221,
952                "postgres",
953                Some(120.0),
954                None,
955                ProcessState::Running,
956            );
957            add_process(
958                snapshot,
959                31_842,
960                "rustc",
961                Some(287.0),
962                None,
963                ProcessState::Running,
964            );
965        });
966        let finding = spike_rule()
967            .evaluate(&current, &timeline.window())
968            .expect("both rose; the larger rise wins");
969        assert!(finding.summary.contains("rustc"), "{}", finding.summary);
970    }
971}