Skip to main content

monitrs_core/diagnostics/
window.rs

1//! The history a rule is allowed to look at, and how it counts over it.
2//!
3//! # Why this is not just a [`HistoryView`]
4//!
5//! §11.1 sketches the rule interface as `evaluate(&SystemSnapshot, &HistoryView)`.
6//! In this codebase a [`HistoryView`] is deliberately only a *cursor*: it holds no
7//! reference to a ring so that it can be copied around application state (see
8//! [`crate::history::view`]). A rule therefore needs the cursor **and** the ring
9//! it indexes, and [`HistoryWindow`] is that pair. [`HistoryWindow::view`] exposes
10//! the cursor unchanged.
11//!
12//! Evaluating against the cursor rather than always against live data is what lets
13//! the Inspect screen explain a *selected* historical sample (§2.1, §7.5) with the
14//! same rules that produced the live radar.
15//!
16//! # Counting rules
17//!
18//! Every count here reports three numbers: how many samples met the condition, how
19//! many were readable at all, and how many were unavailable. A sample whose input
20//! was withheld is **not** counted as failing the condition and **not** counted
21//! towards the minimum sample requirement — §26's "unavailable is not zero" applies
22//! to counting as much as to display, and §11.3 requires a counter reset not to be
23//! read as an event.
24
25use core::time::Duration;
26
27use crate::history::{
28    ContributorMetric, HistoricalSample, HistoryMetric, HistoryRing, HistoryView,
29};
30use crate::model::{MeasuredValue, ProcessIdentity};
31
32use super::TimeWindow;
33
34/// The outcome of counting a condition over a window of samples.
35#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
36pub struct Counted {
37    /// Samples whose reading met the condition.
38    pub matched: usize,
39    /// Samples that produced a fresh reading at all.
40    pub considered: usize,
41    /// Samples whose reading was unavailable, stale, or warming up.
42    pub unavailable: usize,
43    /// Monotonic span between the oldest and newest sample visited.
44    pub span: Duration,
45}
46
47impl Counted {
48    /// How many samples were visited, readable or not.
49    #[must_use]
50    pub const fn visited(&self) -> usize {
51        self.considered.saturating_add(self.unavailable)
52    }
53
54    /// The evidence window these counts cover.
55    #[must_use]
56    pub const fn window(&self) -> TimeWindow {
57        TimeWindow::new(self.span, self.considered)
58    }
59
60    /// Whether the condition held often enough, over enough readings, to support
61    /// a sustained claim (§11.3).
62    ///
63    /// Both halves matter: `matched >= required` is the sustained condition, and
64    /// `considered >= minimum` is the minimum-sample rule that stops a rule from
65    /// firing on the second tick after launch.
66    #[must_use]
67    pub const fn sustained(&self, required: usize, minimum: usize) -> bool {
68        self.considered >= minimum && self.matched >= required
69    }
70}
71
72/// A ring plus the cursor into it that a rule evaluates against.
73#[derive(Clone, Copy, Debug)]
74pub struct HistoryWindow<'a> {
75    ring: &'a HistoryRing,
76    view: HistoryView,
77}
78
79impl<'a> HistoryWindow<'a> {
80    /// Pairs a ring with an explicit cursor.
81    #[must_use]
82    pub const fn new(ring: &'a HistoryRing, view: HistoryView) -> Self {
83        Self { ring, view }
84    }
85
86    /// Pairs a ring with a cursor following the newest sample.
87    #[must_use]
88    pub const fn live(ring: &'a HistoryRing) -> Self {
89        Self::new(ring, HistoryView::live())
90    }
91
92    /// The cursor, which is the `HistoryView` §11.1's sketch passes.
93    #[must_use]
94    pub const fn view(&self) -> HistoryView {
95        self.view
96    }
97
98    /// The ring being read.
99    #[must_use]
100    pub const fn ring(&self) -> &'a HistoryRing {
101        self.ring
102    }
103
104    /// The interval history is configured to retain samples at (§8.5).
105    ///
106    /// Rules that need a reference interval use this rather than assuming one
107    /// second (§8.1).
108    #[must_use]
109    pub fn expected_interval(&self) -> Duration {
110        self.ring.limits().interval()
111    }
112
113    /// The sample the cursor selects, or the newest one when live.
114    #[must_use]
115    pub fn selected(&self) -> Option<&'a HistoricalSample> {
116        self.view.selected(self.ring)
117    }
118
119    /// How many samples are retained in total.
120    #[must_use]
121    pub fn len(&self) -> usize {
122        self.ring.len()
123    }
124
125    /// Whether no sample has been recorded yet.
126    #[must_use]
127    pub fn is_empty(&self) -> bool {
128        self.ring.is_empty()
129    }
130
131    /// The `count` samples ending at the cursor, oldest first.
132    ///
133    /// Resolved by absolute index, so it costs one deque lookup per sample and
134    /// never scans the ring (§21 M4).
135    pub fn recent(&self, count: usize) -> impl DoubleEndedIterator<Item = &'a HistoricalSample> {
136        let ring = self.ring;
137        self.bounds(count)
138            .into_iter()
139            .flat_map(move |(first, last)| {
140                (first..=last).filter_map(move |at| ring.get_absolute(at))
141            })
142    }
143
144    /// The newest retained sample strictly older than `sequence`.
145    ///
146    /// Rules that compare "now" against "the previous sample" use this so they
147    /// work whether or not the snapshot under evaluation has already been recorded
148    /// into the ring.
149    #[must_use]
150    pub fn previous_sample(&self, sequence: u64) -> Option<&'a HistoricalSample> {
151        self.recent(2).rfind(|sample| sample.sequence < sequence)
152    }
153
154    /// Counts how many of the `count` most recent samples satisfy `predicate`.
155    ///
156    /// `predicate` sees only *freshly measured* values: a sample whose metric was
157    /// unavailable is tallied in [`Counted::unavailable`] and never passed to the
158    /// predicate, so no rule can accidentally treat a missing reading as a zero
159    /// (§26).
160    #[must_use]
161    pub fn count_where(
162        &self,
163        metric: HistoryMetric,
164        count: usize,
165        predicate: impl Fn(f64) -> bool,
166    ) -> Counted {
167        let mut counted = Counted::default();
168        let mut oldest: Option<Duration> = None;
169        let mut newest = Duration::ZERO;
170
171        for sample in self.recent(count) {
172            if oldest.is_none() {
173                oldest = Some(sample.monotonic_offset);
174            }
175            newest = sample.monotonic_offset;
176            match sample.system.scalar(metric) {
177                Some(value) => {
178                    counted.considered = counted.considered.saturating_add(1);
179                    if predicate(value) {
180                        counted.matched = counted.matched.saturating_add(1);
181                    }
182                }
183                None => counted.unavailable = counted.unavailable.saturating_add(1),
184            }
185        }
186
187        counted.span = newest.saturating_sub(oldest.unwrap_or(newest));
188        counted
189    }
190
191    /// Counts how many of the `count` most recent samples are at or above
192    /// `threshold`.
193    #[must_use]
194    pub fn count_at_least(&self, metric: HistoryMetric, count: usize, threshold: f64) -> Counted {
195        self.count_where(metric, count, |value| value >= threshold)
196    }
197
198    /// The oldest and newest freshly measured values of `metric` in the window.
199    ///
200    /// Returns `None` unless *both* ends were measured, because a trend computed
201    /// against a missing endpoint is a fabrication (§26). The returned duration is
202    /// the real span between the two samples, never an assumed one (§8.1).
203    #[must_use]
204    pub fn trend(&self, metric: HistoryMetric, count: usize) -> Option<(f64, f64, Duration)> {
205        let mut first: Option<(f64, Duration)> = None;
206        let mut last: Option<(f64, Duration)> = None;
207        for sample in self.recent(count) {
208            if let Some(value) = sample.system.scalar(metric) {
209                if first.is_none() {
210                    first = Some((value, sample.monotonic_offset));
211                }
212                last = Some((value, sample.monotonic_offset));
213            }
214        }
215        let (start, start_at) = first?;
216        let (end, end_at) = last?;
217        Some((start, end, end_at.saturating_sub(start_at)))
218    }
219
220    /// The oldest and newest absolute indices of the `count` samples ending at the
221    /// cursor.
222    fn bounds(&self, count: usize) -> Option<(u64, u64)> {
223        if count == 0 {
224            return None;
225        }
226        let last = self.view.selected_absolute(self.ring)?;
227        let span = u64::try_from(count).unwrap_or(u64::MAX).saturating_sub(1);
228        let first = last.saturating_sub(span).max(self.ring.first_absolute());
229        Some((first, last))
230    }
231}
232
233/// The retained value of a contributor metric for one process in one sample.
234///
235/// Keyed on the full [`ProcessIdentity`], so a reused PID never resolves to the
236/// series of the process that used to hold it (§26).
237///
238/// Contributor lists are bounded to the top `K` per metric (§8.5), so a process that
239/// dropped out of the top `K` returns `None` — a gap, not a zero.
240#[must_use]
241pub fn contributor_value(
242    sample: &HistoricalSample,
243    metric: ContributorMetric,
244    identity: ProcessIdentity,
245) -> Option<f64> {
246    sample
247        .contributors
248        .metric(metric)
249        .entries()
250        .iter()
251        .find(|entry| entry.identity == identity)
252        .map(|entry| measured_scalar(entry.value))
253}
254
255/// A measured value as a comparable number.
256///
257/// Byte counts and event counts are integral in the model (§10.4); widening them
258/// here is only ever done to compare or difference them, never to store them.
259pub(crate) fn measured_scalar(value: MeasuredValue) -> f64 {
260    match value {
261        MeasuredValue::Bytes(bytes) | MeasuredValue::Count(bytes) => bytes as f64,
262        MeasuredValue::ByteRate(rate) | MeasuredValue::EventRate(rate) => rate.per_second(),
263        MeasuredValue::Percent(percent) => f64::from(percent.value()),
264        MeasuredValue::Duration(duration) => duration.as_secs_f64(),
265        MeasuredValue::Load(load) => f64::from(load),
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use crate::diagnostics::fixtures::{Timeline, set_cpu, set_memory};
273    use crate::history::HistoryMetric;
274    use crate::model::{MetricState, UnavailableReason};
275
276    #[test]
277    fn counting_an_empty_ring_reports_nothing_considered() {
278        let timeline = Timeline::new(Duration::from_secs(1));
279        let window = timeline.window();
280        let counted = window.count_at_least(HistoryMetric::CpuBusy, 15, 80.0);
281
282        assert!(window.is_empty());
283        assert_eq!(counted, Counted::default());
284        assert!(!counted.sustained(1, 1));
285        assert_eq!(counted.window().samples, 0);
286    }
287
288    #[test]
289    fn the_expected_interval_comes_from_history_rather_than_an_assumption() {
290        // §8.1 forbids assuming one second; the rules that need a reference
291        // interval read the configured one.
292        for interval in [Duration::from_millis(500), Duration::from_secs(5)] {
293            let timeline = Timeline::new(interval);
294            assert_eq!(timeline.window().expected_interval(), timeline.interval());
295            assert_eq!(timeline.window().expected_interval(), interval);
296        }
297    }
298
299    #[test]
300    fn counting_is_limited_to_the_requested_window() {
301        let mut timeline = Timeline::new(Duration::from_secs(1));
302        for _ in 0..10 {
303            timeline.push(|snapshot| set_cpu(snapshot, 10.0));
304        }
305        for _ in 0..5 {
306            timeline.push(|snapshot| set_cpu(snapshot, 90.0));
307        }
308
309        let window = timeline.window();
310        let counted = window.count_at_least(HistoryMetric::CpuBusy, 5, 80.0);
311        assert_eq!(counted.matched, 5);
312        assert_eq!(counted.considered, 5);
313        assert_eq!(counted.span, Duration::from_secs(4));
314
315        let wider = window.count_at_least(HistoryMetric::CpuBusy, 15, 80.0);
316        assert_eq!(wider.matched, 5);
317        assert_eq!(wider.considered, 15);
318    }
319
320    #[test]
321    fn a_window_larger_than_the_ring_counts_only_what_exists() {
322        let mut timeline = Timeline::new(Duration::from_secs(1));
323        for _ in 0..3 {
324            timeline.push(|snapshot| set_cpu(snapshot, 99.0));
325        }
326        let counted = timeline
327            .window()
328            .count_at_least(HistoryMetric::CpuBusy, 100, 80.0);
329        assert_eq!(counted.visited(), 3);
330        assert_eq!(counted.matched, 3);
331    }
332
333    #[test]
334    fn an_unavailable_sample_is_neither_a_match_nor_a_considered_reading() {
335        let mut timeline = Timeline::new(Duration::from_secs(1));
336        for _ in 0..5 {
337            timeline.push(|snapshot| set_cpu(snapshot, 99.0));
338        }
339        for _ in 0..5 {
340            timeline.push(|snapshot| {
341                snapshot.cpu.total =
342                    MetricState::TemporarilyUnavailable(UnavailableReason::CounterReset);
343            });
344        }
345
346        let counted = timeline
347            .window()
348            .count_at_least(HistoryMetric::CpuBusy, 10, 80.0);
349        assert_eq!(counted.matched, 5);
350        assert_eq!(counted.considered, 5);
351        assert_eq!(counted.unavailable, 5);
352        assert_eq!(counted.visited(), 10);
353        assert!(
354            !counted.sustained(10, 10),
355            "five readings cannot support a ten-sample claim"
356        );
357    }
358
359    #[test]
360    fn the_cursor_decides_which_window_is_counted() {
361        let mut timeline = Timeline::new(Duration::from_secs(1));
362        for _ in 0..10 {
363            timeline.push(|snapshot| set_cpu(snapshot, 95.0));
364        }
365        for _ in 0..10 {
366            timeline.push(|snapshot| set_cpu(snapshot, 1.0));
367        }
368
369        let live = timeline.window();
370        assert_eq!(
371            live.count_at_least(HistoryMetric::CpuBusy, 10, 80.0)
372                .matched,
373            0
374        );
375
376        let mut view = HistoryView::live();
377        view.step_back(timeline.ring(), 10);
378        let historical = HistoryWindow::new(timeline.ring(), view);
379        assert_eq!(
380            historical
381                .count_at_least(HistoryMetric::CpuBusy, 10, 80.0)
382                .matched,
383            10,
384            "a rule evaluated over a selected sample must see that sample's past"
385        );
386        assert_eq!(historical.view(), view);
387    }
388
389    #[test]
390    fn a_trend_needs_both_endpoints_measured() {
391        let mut timeline = Timeline::new(Duration::from_secs(1));
392        timeline.push(|snapshot| set_memory(snapshot, 1_000, 800));
393        timeline.push(|snapshot| {
394            snapshot.memory.usage = MetricState::PermissionDenied;
395        });
396        assert!(
397            timeline
398                .window()
399                .trend(HistoryMetric::MemoryUsedShare, 2)
400                .is_some(),
401            "the older endpoint is still measured, so the trend spans one sample"
402        );
403
404        let mut only_unavailable = Timeline::new(Duration::from_secs(1));
405        only_unavailable.push(|snapshot| {
406            snapshot.memory.usage = MetricState::PermissionDenied;
407        });
408        assert!(
409            only_unavailable
410                .window()
411                .trend(HistoryMetric::MemoryUsedShare, 2)
412                .is_none()
413        );
414    }
415
416    #[test]
417    fn a_trend_reports_the_real_span_between_the_endpoints() {
418        let mut timeline = Timeline::new(Duration::from_millis(500));
419        for used_share in [10u64, 20, 30] {
420            // History retains the *used* share, so the fixture sets availability to
421            // its complement.
422            timeline.push(move |snapshot| set_memory(snapshot, 1_000, 1_000 - used_share * 10));
423        }
424        let (start, end, span) = timeline
425            .window()
426            .trend(HistoryMetric::MemoryUsedShare, 3)
427            .expect("three measured samples");
428        assert!((start - 10.0).abs() < 0.01, "{start}");
429        assert!((end - 30.0).abs() < 0.01, "{end}");
430        assert_eq!(span, Duration::from_secs(1), "two 500ms intervals");
431    }
432
433    #[test]
434    fn the_previous_sample_is_the_newest_one_older_than_the_snapshot() {
435        let mut timeline = Timeline::new(Duration::from_secs(1));
436        timeline.push(|snapshot| set_cpu(snapshot, 1.0));
437        timeline.push(|snapshot| set_cpu(snapshot, 2.0));
438        let current = timeline.push(|snapshot| set_cpu(snapshot, 3.0));
439
440        let window = timeline.window();
441        let previous = window
442            .previous_sample(current.sequence)
443            .expect("a previous sample exists");
444        assert_eq!(previous.sequence, current.sequence - 1);
445        assert!(
446            window.previous_sample(0).is_none(),
447            "nothing precedes the first sample"
448        );
449    }
450
451    #[test]
452    fn a_zero_length_window_reads_nothing_instead_of_panicking() {
453        let mut timeline = Timeline::new(Duration::from_secs(1));
454        timeline.push(|snapshot| set_cpu(snapshot, 50.0));
455        assert_eq!(timeline.window().recent(0).count(), 0);
456        assert_eq!(
457            timeline
458                .window()
459                .count_at_least(HistoryMetric::CpuBusy, 0, 1.0)
460                .visited(),
461            0
462        );
463    }
464
465    #[test]
466    fn every_measured_value_kind_has_a_comparable_scalar() {
467        use crate::units::{Percent, Rate};
468        let rate = Rate::new(1_024.0).expect("valid rate");
469        let cases = [
470            (MeasuredValue::Bytes(4_096), 4_096.0),
471            (MeasuredValue::Count(7), 7.0),
472            (MeasuredValue::ByteRate(rate), 1_024.0),
473            (MeasuredValue::EventRate(rate), 1_024.0),
474            (
475                MeasuredValue::Percent(Percent::new(37.5).expect("valid")),
476                37.5,
477            ),
478            (MeasuredValue::Duration(Duration::from_secs(2)), 2.0),
479            (MeasuredValue::Load(4.25), 4.25),
480        ];
481        for (value, expected) in cases {
482            let scalar = measured_scalar(value);
483            assert!((scalar - expected).abs() < 0.001, "{value:?} -> {scalar}");
484        }
485    }
486}