Skip to main content

monitrs_core/history/
ring.rs

1//! The bounded ring buffer, its configuration bounds, and its memory budget
2//! (§8.5).
3
4use core::mem::size_of;
5use core::time::Duration;
6use std::collections::VecDeque;
7use std::time::Instant;
8
9use crate::model::SystemSnapshot;
10use crate::units::{ByteUnits, format_bytes, format_duration};
11
12use super::contributors::MAX_RETAINED_TEXT_BYTES;
13use super::{
14    Contributor, ContributorMetric, ContributorSet, HistoricalSample, HistoricalSystemMetrics,
15};
16
17/// Default sample interval (§8.5).
18pub const DEFAULT_SAMPLE_INTERVAL: Duration = Duration::from_secs(1);
19/// Smallest accepted sample interval (§8.5).
20pub const MIN_SAMPLE_INTERVAL: Duration = Duration::from_millis(250);
21/// Largest accepted sample interval (§8.5).
22pub const MAX_SAMPLE_INTERVAL: Duration = Duration::from_secs(60);
23
24/// Default history duration (§8.5).
25pub const DEFAULT_HISTORY_DURATION: Duration = Duration::from_secs(5 * 60);
26/// Shortest accepted history duration (§8.5).
27pub const MIN_HISTORY_DURATION: Duration = Duration::from_secs(30);
28/// Longest accepted history duration in v1 (§8.5).
29pub const MAX_HISTORY_DURATION: Duration = Duration::from_secs(60 * 60);
30
31/// Default contributors retained per metric per sample (§8.5, §12).
32pub const DEFAULT_TOP_CONTRIBUTORS_PER_METRIC: usize = 10;
33/// Largest accepted `top_contributors_per_metric`.
34///
35/// Not in §8.5, but implied by it: `K` multiplies the size of every sample four
36/// times over, so an unbounded `K` would defeat the memory budget the same
37/// section requires. Fifty rows is already more than any panel in §5 can show.
38pub const MAX_TOP_CONTRIBUTORS_PER_METRIC: usize = 50;
39
40/// Default history memory budget (§12's `max_history_memory = "32MiB"`).
41pub const DEFAULT_MEMORY_BUDGET_BYTES: u64 = 32 * 1024 * 1024;
42/// Smallest accepted history memory budget.
43///
44/// Below this the ring could not hold enough samples for the 30-second
45/// comparison §2.5 requires, so accepting the value would silently disable a
46/// documented feature.
47pub const MIN_MEMORY_BUDGET_BYTES: u64 = 1024 * 1024;
48/// Largest accepted history memory budget.
49///
50/// §16.1 budgets the whole process below 50 MiB resident in the default
51/// configuration; a history allocation beyond this cannot be reconciled with that
52/// and is almost certainly a mistyped unit.
53pub const MAX_MEMORY_BUDGET_BYTES: u64 = 512 * 1024 * 1024;
54
55/// The requested history configuration, before validation.
56///
57/// Mirrors the `[sampling]` and `[processes]` keys in §12. Out-of-range values
58/// are clamped rather than rejected, and every clamp is reported so the UI can
59/// warn (§8.5); a configuration mistake must not stop monitrs from starting.
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub struct HistoryConfig {
62    /// Requested interval between retained samples.
63    pub interval: Duration,
64    /// Requested span of history to keep.
65    pub duration: Duration,
66    /// Requested contributors retained per metric per sample.
67    pub top_contributors_per_metric: usize,
68    /// Requested ceiling on history memory use.
69    pub memory_budget_bytes: u64,
70}
71
72impl Default for HistoryConfig {
73    /// The defaults §8.5 specifies: 1 s, 5 min, 300 samples, top 10 per metric.
74    fn default() -> Self {
75        Self {
76            interval: DEFAULT_SAMPLE_INTERVAL,
77            duration: DEFAULT_HISTORY_DURATION,
78            top_contributors_per_metric: DEFAULT_TOP_CONTRIBUTORS_PER_METRIC,
79            memory_budget_bytes: DEFAULT_MEMORY_BUDGET_BYTES,
80        }
81    }
82}
83
84/// Which configuration value was clamped.
85#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
86pub enum HistoryField {
87    /// The sample interval.
88    SampleInterval,
89    /// The history duration.
90    HistoryDuration,
91    /// The contributors retained per metric.
92    TopContributorsPerMetric,
93    /// The history memory budget.
94    MemoryBudget,
95}
96
97impl HistoryField {
98    /// The configuration key this field comes from.
99    ///
100    /// §12 requires an invalid value to point at the exact key rather than at a
101    /// generic message.
102    #[must_use]
103    pub const fn config_key(self) -> &'static str {
104        match self {
105            Self::SampleInterval => "sampling.interval",
106            Self::HistoryDuration => "sampling.history",
107            Self::TopContributorsPerMetric => "processes.top_contributors_per_metric",
108            Self::MemoryBudget => "sampling.max_history_memory",
109        }
110    }
111}
112
113/// Why a value was clamped.
114#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
115pub enum ClampReason {
116    /// The value was below the supported minimum.
117    BelowMinimum,
118    /// The value was above the supported maximum.
119    AboveMaximum,
120    /// The value was in range but would not fit the memory budget (§8.5).
121    ExceedsMemoryBudget,
122}
123
124impl ClampReason {
125    /// A short explanation for the warning line.
126    #[must_use]
127    pub const fn explanation(self) -> &'static str {
128        match self {
129            Self::BelowMinimum => "below the supported minimum",
130            Self::AboveMaximum => "above the supported maximum",
131            Self::ExceedsMemoryBudget => "would exceed the history memory budget",
132        }
133    }
134}
135
136/// A clamped value, tagged so it can be rendered in the unit it was written in.
137#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
138pub enum ClampedValue {
139    /// A duration, rendered in the form §12's configuration accepts.
140    Duration(Duration),
141    /// A plain count.
142    Count(usize),
143    /// A byte size.
144    Bytes(u64),
145}
146
147impl ClampedValue {
148    /// Renders the value so it can be pasted back into a configuration file.
149    #[must_use]
150    pub fn render(self) -> String {
151        match self {
152            Self::Duration(duration) => format_duration(duration),
153            Self::Count(count) => count.to_string(),
154            // IEC regardless of the display setting: `max_history_memory` is
155            // written as `32MiB` in §12's example, so echoing SI units back would
156            // not round-trip.
157            Self::Bytes(bytes) => format_bytes(bytes, ByteUnits::Iec),
158        }
159    }
160}
161
162/// One reported adjustment to the requested configuration (§8.5).
163#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
164pub struct HistoryClamp {
165    /// Which value was adjusted.
166    pub field: HistoryField,
167    /// What the configuration asked for.
168    pub requested: ClampedValue,
169    /// What is actually in effect.
170    pub applied: ClampedValue,
171    /// Why the adjustment was necessary.
172    pub reason: ClampReason,
173}
174
175impl HistoryClamp {
176    /// A complete warning line naming the key, both values, and the reason.
177    #[must_use]
178    pub fn message(&self) -> String {
179        format!(
180            "{} {} clamped to {}: {}",
181            self.field.config_key(),
182            self.requested.render(),
183            self.applied.render(),
184            self.reason.explanation()
185        )
186    }
187}
188
189/// A validated history configuration: what the ring will actually do.
190///
191/// Construction never fails. §8.5 asks for clamping plus a warning, not a hard
192/// error, so every adjustment is recorded in [`Self::clamps`] and the ring starts
193/// regardless.
194#[derive(Clone, Debug, Eq, PartialEq)]
195pub struct HistoryLimits {
196    interval: Duration,
197    requested_duration: Duration,
198    capacity: usize,
199    top_contributors_per_metric: usize,
200    memory_budget_bytes: u64,
201    bytes_per_sample: usize,
202    clamps: Vec<HistoryClamp>,
203}
204
205impl HistoryLimits {
206    /// Validates a requested configuration, clamping out-of-range values.
207    #[must_use]
208    pub fn resolve(config: HistoryConfig) -> Self {
209        let mut clamps = Vec::new();
210
211        let interval = clamp_duration(
212            config.interval,
213            MIN_SAMPLE_INTERVAL,
214            MAX_SAMPLE_INTERVAL,
215            HistoryField::SampleInterval,
216            &mut clamps,
217        );
218        let top_contributors_per_metric = clamp_count(
219            config.top_contributors_per_metric,
220            1,
221            MAX_TOP_CONTRIBUTORS_PER_METRIC,
222            HistoryField::TopContributorsPerMetric,
223            &mut clamps,
224        );
225        let memory_budget_bytes = clamp_bytes(
226            config.memory_budget_bytes,
227            MIN_MEMORY_BUDGET_BYTES,
228            MAX_MEMORY_BUDGET_BYTES,
229            HistoryField::MemoryBudget,
230            &mut clamps,
231        );
232        let duration = clamp_duration(
233            config.duration,
234            MIN_HISTORY_DURATION,
235            MAX_HISTORY_DURATION,
236            HistoryField::HistoryDuration,
237            &mut clamps,
238        );
239
240        let bytes_per_sample = estimated_bytes_per_sample(top_contributors_per_metric);
241        let requested_capacity = derive_capacity(duration, interval);
242        let affordable = affordable_capacity(memory_budget_bytes, bytes_per_sample);
243        let capacity = requested_capacity.min(affordable);
244
245        if capacity < requested_capacity {
246            // Report the shrink against the history *duration*, because that is
247            // the key the user set and the number the header will show.
248            clamps.push(HistoryClamp {
249                field: HistoryField::HistoryDuration,
250                requested: ClampedValue::Duration(duration),
251                applied: ClampedValue::Duration(interval.saturating_mul(capacity_as_u32(capacity))),
252                reason: ClampReason::ExceedsMemoryBudget,
253            });
254        }
255
256        Self {
257            interval,
258            requested_duration: duration,
259            capacity,
260            top_contributors_per_metric,
261            memory_budget_bytes,
262            bytes_per_sample,
263            clamps,
264        }
265    }
266
267    /// The interval between retained samples.
268    #[must_use]
269    pub const fn interval(&self) -> Duration {
270        self.interval
271    }
272
273    /// How many samples the ring holds.
274    ///
275    /// Derived as `ceil(duration / interval)`, so §8.5's default 1 s over 5 min
276    /// gives exactly 300.
277    #[must_use]
278    pub const fn capacity(&self) -> usize {
279        self.capacity
280    }
281
282    /// The history span the ring can actually cover: `capacity * interval`.
283    ///
284    /// Differs from [`Self::requested_duration`] when the memory budget forced a
285    /// smaller ring, which is exactly the case §8.5 requires a warning for.
286    #[must_use]
287    pub fn effective_duration(&self) -> Duration {
288        self.interval.saturating_mul(capacity_as_u32(self.capacity))
289    }
290
291    /// The history duration in effect after range clamping, before the memory
292    /// budget was applied.
293    #[must_use]
294    pub const fn requested_duration(&self) -> Duration {
295        self.requested_duration
296    }
297
298    /// Contributors retained per metric per sample.
299    #[must_use]
300    pub const fn top_contributors_per_metric(&self) -> usize {
301        self.top_contributors_per_metric
302    }
303
304    /// The memory budget in effect.
305    #[must_use]
306    pub const fn memory_budget_bytes(&self) -> u64 {
307        self.memory_budget_bytes
308    }
309
310    /// The worst-case bytes one sample can occupy.
311    ///
312    /// Worst case, not typical: a budget that is only respected for average input
313    /// is not a budget. [`HistoryRing::estimated_bytes`] reports what is actually
314    /// retained.
315    #[must_use]
316    pub const fn estimated_bytes_per_sample(&self) -> usize {
317        self.bytes_per_sample
318    }
319
320    /// The worst-case bytes a full ring can occupy.
321    #[must_use]
322    pub const fn estimated_capacity_bytes(&self) -> usize {
323        self.capacity.saturating_mul(self.bytes_per_sample)
324    }
325
326    /// Every adjustment made to the requested configuration.
327    ///
328    /// §8.5 requires the user to be warned when configuration was clamped, which
329    /// is only possible if the clamps survive validation as data.
330    #[must_use]
331    pub fn clamps(&self) -> &[HistoryClamp] {
332        &self.clamps
333    }
334
335    /// Whether anything was clamped.
336    #[must_use]
337    pub fn was_clamped(&self) -> bool {
338        !self.clamps.is_empty()
339    }
340}
341
342impl Default for HistoryLimits {
343    /// The validated form of [`HistoryConfig::default`], which clamps nothing.
344    fn default() -> Self {
345        Self::resolve(HistoryConfig::default())
346    }
347}
348
349/// Clamps a duration into range, recording the adjustment.
350fn clamp_duration(
351    value: Duration,
352    min: Duration,
353    max: Duration,
354    field: HistoryField,
355    clamps: &mut Vec<HistoryClamp>,
356) -> Duration {
357    let (applied, reason) = if value < min {
358        (min, Some(ClampReason::BelowMinimum))
359    } else if value > max {
360        (max, Some(ClampReason::AboveMaximum))
361    } else {
362        (value, None)
363    };
364    if let Some(reason) = reason {
365        clamps.push(HistoryClamp {
366            field,
367            requested: ClampedValue::Duration(value),
368            applied: ClampedValue::Duration(applied),
369            reason,
370        });
371    }
372    applied
373}
374
375/// Clamps a count into range, recording the adjustment.
376fn clamp_count(
377    value: usize,
378    min: usize,
379    max: usize,
380    field: HistoryField,
381    clamps: &mut Vec<HistoryClamp>,
382) -> usize {
383    let (applied, reason) = if value < min {
384        (min, Some(ClampReason::BelowMinimum))
385    } else if value > max {
386        (max, Some(ClampReason::AboveMaximum))
387    } else {
388        (value, None)
389    };
390    if let Some(reason) = reason {
391        clamps.push(HistoryClamp {
392            field,
393            requested: ClampedValue::Count(value),
394            applied: ClampedValue::Count(applied),
395            reason,
396        });
397    }
398    applied
399}
400
401/// Clamps a byte size into range, recording the adjustment.
402fn clamp_bytes(
403    value: u64,
404    min: u64,
405    max: u64,
406    field: HistoryField,
407    clamps: &mut Vec<HistoryClamp>,
408) -> u64 {
409    let (applied, reason) = if value < min {
410        (min, Some(ClampReason::BelowMinimum))
411    } else if value > max {
412        (max, Some(ClampReason::AboveMaximum))
413    } else {
414        (value, None)
415    };
416    if let Some(reason) = reason {
417        clamps.push(HistoryClamp {
418            field,
419            requested: ClampedValue::Bytes(value),
420            applied: ClampedValue::Bytes(applied),
421            reason,
422        });
423    }
424    applied
425}
426
427/// `ceil(duration / interval)`, never below one sample.
428///
429/// Rounds up so the configured span is covered rather than fractionally missed:
430/// §8.5's 5 min at 1 s must be 300 samples, not 299.
431fn derive_capacity(duration: Duration, interval: Duration) -> usize {
432    let interval_nanos = interval.as_nanos();
433    if interval_nanos == 0 {
434        return 1;
435    }
436    let samples = duration.as_nanos().div_ceil(interval_nanos).max(1);
437    usize::try_from(samples).unwrap_or(usize::MAX)
438}
439
440/// How many samples of `bytes_per_sample` fit in `budget`, never below one.
441fn affordable_capacity(budget: u64, bytes_per_sample: usize) -> usize {
442    let per_sample = u64::try_from(bytes_per_sample.max(1)).unwrap_or(u64::MAX);
443    let samples = (budget / per_sample).max(1);
444    usize::try_from(samples).unwrap_or(usize::MAX)
445}
446
447/// A capacity as the multiplier `Duration::saturating_mul` accepts.
448///
449/// A capacity above `u32::MAX` cannot be allocated on any supported platform;
450/// saturating keeps the arithmetic panic-free either way.
451fn capacity_as_u32(capacity: usize) -> u32 {
452    u32::try_from(capacity).unwrap_or(u32::MAX)
453}
454
455/// The worst-case bytes one sample occupies at a given `K`.
456///
457/// Counts the struct itself plus, for each of the four metrics, `K` contributors
458/// and their truncated text at the pessimistic four-bytes-per-cell bound.
459const fn estimated_bytes_per_sample(top_contributors_per_metric: usize) -> usize {
460    let per_contributor = size_of::<Contributor>() + MAX_RETAINED_TEXT_BYTES;
461    let contributors = top_contributors_per_metric
462        .saturating_mul(ContributorMetric::COUNT)
463        .saturating_mul(per_contributor);
464    size_of::<HistoricalSample>().saturating_add(contributors)
465}
466
467/// What happened to a snapshot offered to the ring.
468#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
469pub enum RecordOutcome {
470    /// The snapshot was appended.
471    Recorded {
472        /// Whether the oldest sample had to be evicted to make room.
473        evicted: bool,
474    },
475    /// The snapshot was not newer than the newest retained sample.
476    ///
477    /// Coalesced or re-delivered snapshots (§16.2) and a wall clock that moved
478    /// (§8.1) must not be able to push history backwards, so they are dropped
479    /// rather than appended out of order.
480    NotNewer,
481}
482
483impl RecordOutcome {
484    /// Whether a sample was appended.
485    #[must_use]
486    pub const fn is_recorded(self) -> bool {
487        matches!(self, Self::Recorded { .. })
488    }
489}
490
491/// The bounded history ring (§8.5).
492///
493/// Fixed capacity, oldest-first eviction, and no allocation growth after the
494/// first `capacity` samples — §16.1 requires no unbounded memory growth over a
495/// twelve-hour run, and a ring is how that is guaranteed rather than hoped for.
496#[derive(Debug)]
497pub struct HistoryRing {
498    limits: HistoryLimits,
499    start: Instant,
500    samples: VecDeque<HistoricalSample>,
501    total_recorded: u64,
502    evicted: u64,
503    retained_heap_bytes: usize,
504}
505
506impl HistoryRing {
507    /// Builds a ring from validated limits.
508    ///
509    /// `start` is the monotonic origin every sample's offset is measured from
510    /// (§8.1). Pass the instant the collector started so the first sample's
511    /// offset is near zero.
512    #[must_use]
513    pub fn new(limits: HistoryLimits, start: Instant) -> Self {
514        Self {
515            samples: VecDeque::with_capacity(limits.capacity()),
516            limits,
517            start,
518            total_recorded: 0,
519            evicted: 0,
520            retained_heap_bytes: 0,
521        }
522    }
523
524    /// Builds a ring from a requested configuration, clamping as needed.
525    ///
526    /// Inspect [`Self::clamps`] afterwards: §8.5 requires the user to be warned.
527    #[must_use]
528    pub fn with_config(config: HistoryConfig, start: Instant) -> Self {
529        Self::new(HistoryLimits::resolve(config), start)
530    }
531
532    /// Reduces a snapshot to a sample and appends it.
533    ///
534    /// Only the aggregate and the top contributors are retained; the process
535    /// table is never cloned (§8.5, §26).
536    pub fn record(&mut self, snapshot: &SystemSnapshot) -> RecordOutcome {
537        let offset = snapshot.captured_at.saturating_duration_since(self.start);
538        if let Some(newest) = self.samples.back()
539            && (snapshot.sequence <= newest.sequence || offset < newest.monotonic_offset)
540        {
541            return RecordOutcome::NotNewer;
542        }
543
544        let contributors = {
545            let previous = self.samples.back().map(|sample| &sample.contributors);
546            ContributorSet::from_processes(
547                &snapshot.processes,
548                previous,
549                self.limits.top_contributors_per_metric(),
550            )
551        };
552        let sample = HistoricalSample {
553            sequence: snapshot.sequence,
554            monotonic_offset: offset,
555            wall_time: snapshot.wall_time,
556            system: HistoricalSystemMetrics::from_snapshot(snapshot),
557            contributors,
558        };
559
560        let mut evicted = false;
561        while self.samples.len() >= self.limits.capacity() {
562            let Some(oldest) = self.samples.pop_front() else {
563                break;
564            };
565            self.retained_heap_bytes = self
566                .retained_heap_bytes
567                .saturating_sub(oldest.contributors.heap_bytes());
568            self.evicted = self.evicted.saturating_add(1);
569            evicted = true;
570        }
571
572        self.retained_heap_bytes = self
573            .retained_heap_bytes
574            .saturating_add(sample.contributors.heap_bytes());
575        self.samples.push_back(sample);
576        self.total_recorded = self.total_recorded.saturating_add(1);
577        RecordOutcome::Recorded { evicted }
578    }
579
580    /// The limits in effect.
581    #[must_use]
582    pub const fn limits(&self) -> &HistoryLimits {
583        &self.limits
584    }
585
586    /// Configuration adjustments the UI should warn about (§8.5).
587    #[must_use]
588    pub fn clamps(&self) -> &[HistoryClamp] {
589        self.limits.clamps()
590    }
591
592    /// The monotonic origin sample offsets are measured from.
593    #[must_use]
594    pub const fn start(&self) -> Instant {
595        self.start
596    }
597
598    /// How many samples the ring holds when full.
599    #[must_use]
600    pub const fn capacity(&self) -> usize {
601        self.limits.capacity()
602    }
603
604    /// How many samples are retained right now.
605    #[must_use]
606    pub fn len(&self) -> usize {
607        self.samples.len()
608    }
609
610    /// Whether nothing has been recorded yet.
611    #[must_use]
612    pub fn is_empty(&self) -> bool {
613        self.samples.is_empty()
614    }
615
616    /// Every retained sample, oldest first.
617    #[must_use]
618    pub fn samples(&self) -> impl DoubleEndedIterator<Item = &HistoricalSample> {
619        self.samples.iter()
620    }
621
622    /// The `index`th retained sample, counting from the oldest.
623    ///
624    /// Constant time: the backing store is a deque, not a linked list (§21 M4).
625    #[must_use]
626    pub fn get(&self, index: usize) -> Option<&HistoricalSample> {
627        self.samples.get(index)
628    }
629
630    /// The newest retained sample.
631    #[must_use]
632    pub fn newest(&self) -> Option<&HistoricalSample> {
633        self.samples.back()
634    }
635
636    /// The oldest retained sample.
637    #[must_use]
638    pub fn oldest(&self) -> Option<&HistoricalSample> {
639        self.samples.front()
640    }
641
642    /// How many samples have ever been recorded, including evicted ones.
643    ///
644    /// This is what makes selection stable across eviction: an *absolute* index
645    /// keeps naming the same sample as newer ones arrive, so a paused view does
646    /// not drift (§2.1).
647    #[must_use]
648    pub const fn total_recorded(&self) -> u64 {
649        self.total_recorded
650    }
651
652    /// How many samples have been evicted.
653    #[must_use]
654    pub const fn evicted(&self) -> u64 {
655        self.evicted
656    }
657
658    /// The absolute index of the oldest retained sample.
659    #[must_use]
660    pub fn first_absolute(&self) -> u64 {
661        let len = u64::try_from(self.samples.len()).unwrap_or(u64::MAX);
662        self.total_recorded.saturating_sub(len)
663    }
664
665    /// The absolute index of the newest retained sample.
666    #[must_use]
667    pub fn newest_absolute(&self) -> Option<u64> {
668        if self.samples.is_empty() {
669            None
670        } else {
671            self.total_recorded.checked_sub(1)
672        }
673    }
674
675    /// The sample at an absolute index, or `None` if it was evicted.
676    ///
677    /// Constant time: index arithmetic plus one deque lookup (§21 M4).
678    #[must_use]
679    pub fn get_absolute(&self, absolute: u64) -> Option<&HistoricalSample> {
680        let relative = absolute.checked_sub(self.first_absolute())?;
681        self.samples.get(usize::try_from(relative).ok()?)
682    }
683
684    /// The monotonic span between the oldest and newest retained samples.
685    #[must_use]
686    pub fn span(&self) -> Duration {
687        match (self.oldest(), self.newest()) {
688            (Some(oldest), Some(newest)) => newest
689                .monotonic_offset
690                .saturating_sub(oldest.monotonic_offset),
691            _ => Duration::ZERO,
692        }
693    }
694
695    /// The newest sample whose offset is at or before `offset`.
696    ///
697    /// Sample offsets increase monotonically (§8.1), so this is a binary search
698    /// rather than a scan — the "effectively constant time" seeking §21 M4
699    /// requires.
700    #[must_use]
701    pub fn index_at_or_before_offset(&self, offset: Duration) -> Option<usize> {
702        let past_target = partition_point(self.samples.len(), |index| {
703            self.samples
704                .get(index)
705                .is_some_and(|sample| sample.monotonic_offset <= offset)
706        });
707        past_target.checked_sub(1)
708    }
709
710    /// Bytes the retained samples actually occupy, struct plus heap.
711    ///
712    /// Maintained incrementally so the self-overhead panel §16.1 requires does
713    /// not walk the whole ring on every frame.
714    #[must_use]
715    pub fn estimated_bytes(&self) -> usize {
716        size_of::<Self>()
717            .saturating_add(self.samples.capacity() * size_of::<HistoricalSample>())
718            .saturating_add(self.retained_heap_bytes)
719    }
720}
721
722/// The first index in `0..len` for which `predicate` is false.
723///
724/// `predicate` must be true for a prefix and false for the remainder. Written out
725/// rather than borrowed from `slice::partition_point` because a `VecDeque` is not
726/// contiguous and making it so would need `&mut self`.
727fn partition_point(len: usize, mut predicate: impl FnMut(usize) -> bool) -> usize {
728    let mut low = 0usize;
729    let mut high = len;
730    while low < high {
731        let middle = low + (high - low) / 2;
732        if predicate(middle) {
733            low = middle + 1;
734        } else {
735            high = middle;
736        }
737    }
738    low
739}
740
741#[cfg(test)]
742mod tests {
743    use super::*;
744    use crate::history::HistoryMetric;
745    use crate::model::{
746        CpuUsage, DiskSnapshot, MetricState, ProcessIdentity, ProcessIo, ProcessMemory,
747        ProcessSnapshot, ProcessState, UnavailableReason,
748    };
749    use crate::units::{Percent, Rate};
750    use std::time::SystemTime;
751
752    /// Builds a snapshot `sequence` intervals after `start`.
753    fn snapshot(start: Instant, sequence: u64, interval: Duration) -> SystemSnapshot {
754        let captured_at = start + interval.saturating_mul(u32::try_from(sequence).unwrap_or(0));
755        let mut snapshot = SystemSnapshot::warming_up(
756            captured_at,
757            SystemTime::UNIX_EPOCH + Duration::from_secs(sequence),
758            8,
759        );
760        snapshot.sequence = sequence;
761        snapshot.elapsed = interval;
762        snapshot
763    }
764
765    fn with_cpu(mut snapshot: SystemSnapshot, busy: f32) -> SystemSnapshot {
766        snapshot.cpu.total =
767            MetricState::Available(CpuUsage::plain(Percent::new(busy).expect("valid percent")));
768        snapshot
769    }
770
771    fn process(pid: u32, cpu: f32) -> ProcessSnapshot {
772        ProcessSnapshot {
773            identity: ProcessIdentity::new(pid, u64::from(pid) * 31),
774            parent_pid: Some(1),
775            name: "proc".into(),
776            command: "proc --flag".into(),
777            exe: None,
778            user: MetricState::Unsupported,
779            state: ProcessState::Running,
780            cpu: MetricState::Available(Percent::new(cpu).expect("valid percent")),
781            memory: ProcessMemory {
782                rss_bytes: MetricState::Available(u64::from(pid) * 1024),
783                virtual_bytes: MetricState::Unsupported,
784                share_of_total: MetricState::Unsupported,
785            },
786            io: ProcessIo {
787                read: MetricState::Available(Rate::new(f64::from(pid)).expect("valid rate")),
788                write: MetricState::Available(Rate::new(f64::from(pid)).expect("valid rate")),
789                read_total_bytes: MetricState::Unsupported,
790                write_total_bytes: MetricState::Unsupported,
791            },
792            threads: MetricState::Unsupported,
793            age: MetricState::Unsupported,
794            started_at: MetricState::Unsupported,
795            is_kernel_thread: false,
796        }
797    }
798
799    #[test]
800    fn the_default_configuration_holds_three_hundred_one_second_samples() {
801        // §21 M4 acceptance: "default history contains 300 one-second samples".
802        let limits = HistoryLimits::default();
803        assert_eq!(limits.capacity(), 300);
804        assert_eq!(limits.interval(), Duration::from_secs(1));
805        assert_eq!(limits.effective_duration(), Duration::from_secs(300));
806        assert_eq!(limits.top_contributors_per_metric(), 10);
807        assert!(!limits.was_clamped(), "{:?}", limits.clamps());
808    }
809
810    #[test]
811    fn capacity_is_derived_from_interval_and_duration() {
812        let cases = [
813            (Duration::from_millis(250), Duration::from_secs(60), 240),
814            (Duration::from_secs(1), Duration::from_secs(30), 30),
815            (Duration::from_secs(2), Duration::from_secs(300), 150),
816            (Duration::from_secs(60), Duration::from_secs(3_600), 60),
817        ];
818        for (interval, duration, expected) in cases {
819            let limits = HistoryLimits::resolve(HistoryConfig {
820                interval,
821                duration,
822                ..HistoryConfig::default()
823            });
824            assert_eq!(
825                limits.capacity(),
826                expected,
827                "{interval:?} over {duration:?}"
828            );
829        }
830    }
831
832    #[test]
833    fn a_duration_that_is_not_a_whole_number_of_intervals_rounds_up() {
834        // Rounding down would silently cover less history than configured.
835        let limits = HistoryLimits::resolve(HistoryConfig {
836            interval: Duration::from_millis(400),
837            duration: Duration::from_secs(31),
838            ..HistoryConfig::default()
839        });
840        assert_eq!(limits.capacity(), 78, "ceil(31000 / 400) is 78");
841    }
842
843    #[test]
844    fn an_interval_below_the_minimum_is_clamped_and_reported() {
845        let limits = HistoryLimits::resolve(HistoryConfig {
846            interval: Duration::from_millis(10),
847            ..HistoryConfig::default()
848        });
849        assert_eq!(limits.interval(), MIN_SAMPLE_INTERVAL);
850
851        let clamp = limits
852            .clamps()
853            .iter()
854            .find(|clamp| clamp.field == HistoryField::SampleInterval)
855            .expect("the clamp is reported so the UI can warn");
856        assert_eq!(clamp.reason, ClampReason::BelowMinimum);
857        assert_eq!(
858            clamp.requested,
859            ClampedValue::Duration(Duration::from_millis(10))
860        );
861        assert_eq!(clamp.applied, ClampedValue::Duration(MIN_SAMPLE_INTERVAL));
862        assert_eq!(
863            clamp.message(),
864            "sampling.interval 10ms clamped to 250ms: below the supported minimum"
865        );
866    }
867
868    #[test]
869    fn every_out_of_range_value_is_clamped_and_reported() {
870        let limits = HistoryLimits::resolve(HistoryConfig {
871            interval: Duration::from_secs(600),
872            duration: Duration::from_secs(1),
873            top_contributors_per_metric: 0,
874            memory_budget_bytes: 1,
875        });
876
877        assert_eq!(limits.interval(), MAX_SAMPLE_INTERVAL);
878        assert_eq!(limits.requested_duration(), MIN_HISTORY_DURATION);
879        assert_eq!(limits.top_contributors_per_metric(), 1);
880        assert_eq!(limits.memory_budget_bytes(), MIN_MEMORY_BUDGET_BYTES);
881
882        let fields: Vec<HistoryField> = limits.clamps().iter().map(|c| c.field).collect();
883        for field in [
884            HistoryField::SampleInterval,
885            HistoryField::HistoryDuration,
886            HistoryField::TopContributorsPerMetric,
887            HistoryField::MemoryBudget,
888        ] {
889            assert!(fields.contains(&field), "{field:?} was not reported");
890        }
891        for clamp in limits.clamps() {
892            assert!(clamp.message().contains(clamp.field.config_key()));
893        }
894    }
895
896    #[test]
897    fn an_in_range_configuration_reports_no_clamp() {
898        let limits = HistoryLimits::resolve(HistoryConfig {
899            interval: Duration::from_secs(2),
900            duration: Duration::from_secs(600),
901            top_contributors_per_metric: 5,
902            memory_budget_bytes: 8 * 1024 * 1024,
903        });
904        assert!(!limits.was_clamped(), "{:?}", limits.clamps());
905        assert_eq!(limits.capacity(), 300);
906    }
907
908    #[test]
909    fn the_memory_budget_shrinks_capacity_and_reports_the_shorter_history() {
910        // The maximum history at the minimum interval cannot fit a small budget.
911        let limits = HistoryLimits::resolve(HistoryConfig {
912            interval: MIN_SAMPLE_INTERVAL,
913            duration: MAX_HISTORY_DURATION,
914            top_contributors_per_metric: MAX_TOP_CONTRIBUTORS_PER_METRIC,
915            memory_budget_bytes: MIN_MEMORY_BUDGET_BYTES,
916        });
917
918        assert!(
919            limits.capacity() < derive_capacity(MAX_HISTORY_DURATION, MIN_SAMPLE_INTERVAL),
920            "capacity should have been reduced"
921        );
922        assert!(limits.capacity() >= 1, "at least one sample must fit");
923        assert!(
924            u64::try_from(limits.estimated_capacity_bytes()).unwrap_or(u64::MAX)
925                <= limits.memory_budget_bytes(),
926            "worst case {} exceeds budget {}",
927            limits.estimated_capacity_bytes(),
928            limits.memory_budget_bytes()
929        );
930
931        let clamp = limits
932            .clamps()
933            .iter()
934            .find(|clamp| clamp.reason == ClampReason::ExceedsMemoryBudget)
935            .expect("the budget clamp is reported");
936        assert_eq!(clamp.field, HistoryField::HistoryDuration);
937        assert!(
938            clamp.message().contains("memory budget"),
939            "{}",
940            clamp.message()
941        );
942        assert!(limits.effective_duration() < MAX_HISTORY_DURATION);
943    }
944
945    #[test]
946    fn the_worst_case_estimate_grows_with_the_contributor_count() {
947        let small = estimated_bytes_per_sample(1);
948        let large = estimated_bytes_per_sample(10);
949        assert!(small < large);
950        assert!(estimated_bytes_per_sample(0) >= size_of::<HistoricalSample>());
951    }
952
953    #[test]
954    fn a_full_ring_evicts_the_oldest_sample() {
955        let start = Instant::now();
956        let interval = Duration::from_secs(1);
957        let mut ring = HistoryRing::new(
958            HistoryLimits::resolve(HistoryConfig {
959                interval,
960                duration: Duration::from_secs(30),
961                ..HistoryConfig::default()
962            }),
963            start,
964        );
965        assert_eq!(ring.capacity(), 30);
966        assert!(ring.is_empty());
967
968        for sequence in 0..30 {
969            let outcome = ring.record(&snapshot(start, sequence, interval));
970            assert_eq!(outcome, RecordOutcome::Recorded { evicted: false });
971        }
972        assert_eq!(ring.len(), 30);
973        assert_eq!(ring.evicted(), 0);
974        assert_eq!(ring.oldest().map(|s| s.sequence), Some(0));
975
976        let outcome = ring.record(&snapshot(start, 30, interval));
977        assert_eq!(outcome, RecordOutcome::Recorded { evicted: true });
978        assert_eq!(ring.len(), 30, "capacity is never exceeded");
979        assert_eq!(ring.evicted(), 1);
980        assert_eq!(ring.oldest().map(|s| s.sequence), Some(1));
981        assert_eq!(ring.newest().map(|s| s.sequence), Some(30));
982        assert_eq!(ring.total_recorded(), 31);
983    }
984
985    #[test]
986    fn eviction_keeps_absolute_indexing_stable() {
987        let start = Instant::now();
988        let interval = Duration::from_secs(1);
989        let mut ring = HistoryRing::new(
990            HistoryLimits::resolve(HistoryConfig {
991                interval,
992                duration: Duration::from_secs(30),
993                ..HistoryConfig::default()
994            }),
995            start,
996        );
997        for sequence in 0..40 {
998            ring.record(&snapshot(start, sequence, interval));
999        }
1000
1001        assert_eq!(ring.first_absolute(), 10);
1002        assert_eq!(ring.newest_absolute(), Some(39));
1003        assert_eq!(ring.get_absolute(10).map(|s| s.sequence), Some(10));
1004        assert_eq!(ring.get_absolute(39).map(|s| s.sequence), Some(39));
1005        assert!(ring.get_absolute(9).is_none(), "evicted samples are gone");
1006        assert!(ring.get_absolute(40).is_none(), "the future does not exist");
1007    }
1008
1009    #[test]
1010    fn an_empty_ring_has_no_newest_index() {
1011        let ring = HistoryRing::new(HistoryLimits::default(), Instant::now());
1012        assert_eq!(ring.newest_absolute(), None);
1013        assert_eq!(ring.first_absolute(), 0);
1014        assert!(ring.get_absolute(0).is_none());
1015        assert_eq!(ring.span(), Duration::ZERO);
1016        assert!(ring.index_at_or_before_offset(Duration::ZERO).is_none());
1017    }
1018
1019    #[test]
1020    fn a_resent_or_coalesced_snapshot_does_not_push_history_backwards() {
1021        let start = Instant::now();
1022        let interval = Duration::from_secs(1);
1023        let mut ring = HistoryRing::new(HistoryLimits::default(), start);
1024
1025        assert!(ring.record(&snapshot(start, 5, interval)).is_recorded());
1026        assert_eq!(
1027            ring.record(&snapshot(start, 5, interval)),
1028            RecordOutcome::NotNewer,
1029            "the same sequence must not be recorded twice"
1030        );
1031        assert_eq!(
1032            ring.record(&snapshot(start, 4, interval)),
1033            RecordOutcome::NotNewer,
1034            "an older sequence must not be appended"
1035        );
1036        assert_eq!(ring.len(), 1);
1037        assert_eq!(ring.total_recorded(), 1);
1038    }
1039
1040    #[test]
1041    fn offsets_are_measured_from_the_rings_start_instant() {
1042        let start = Instant::now();
1043        let interval = Duration::from_secs(1);
1044        let mut ring = HistoryRing::new(HistoryLimits::default(), start);
1045        for sequence in 0..5 {
1046            ring.record(&snapshot(start, sequence, interval));
1047        }
1048
1049        let offsets: Vec<Duration> = ring.samples().map(|s| s.monotonic_offset).collect();
1050        assert_eq!(
1051            offsets,
1052            vec![
1053                Duration::ZERO,
1054                Duration::from_secs(1),
1055                Duration::from_secs(2),
1056                Duration::from_secs(3),
1057                Duration::from_secs(4),
1058            ]
1059        );
1060        assert_eq!(ring.span(), Duration::from_secs(4));
1061    }
1062
1063    #[test]
1064    fn a_snapshot_captured_before_the_ring_started_gets_a_zero_offset() {
1065        // Defensive: a snapshot from before the ring existed must not underflow.
1066        let start = Instant::now();
1067        let Some(earlier) = start.checked_sub(Duration::from_secs(5)) else {
1068            return;
1069        };
1070        let mut ring = HistoryRing::new(HistoryLimits::default(), start);
1071        let mut source = SystemSnapshot::warming_up(earlier, SystemTime::UNIX_EPOCH, 8);
1072        source.sequence = 1;
1073
1074        assert!(ring.record(&source).is_recorded());
1075        assert_eq!(
1076            ring.newest().map(|s| s.monotonic_offset),
1077            Some(Duration::ZERO)
1078        );
1079    }
1080
1081    #[test]
1082    fn the_process_table_is_never_cloned_into_a_sample() {
1083        // §21 M4 acceptance and §26: the retained contributor count is bounded by
1084        // K per metric no matter how many processes were observed.
1085        let start = Instant::now();
1086        let interval = Duration::from_secs(1);
1087        let mut ring = HistoryRing::new(HistoryLimits::default(), start);
1088
1089        let mut source = snapshot(start, 1, interval);
1090        source
1091            .processes
1092            .extend((1..=10_000u32).map(|pid| process(pid, 1.0)));
1093        assert_eq!(source.process_count(), 10_000);
1094
1095        assert!(ring.record(&source).is_recorded());
1096        let sample = ring.newest().expect("recorded");
1097        let top_k = ring.limits().top_contributors_per_metric();
1098        assert_eq!(
1099            sample.contributors.retained_count(),
1100            ContributorSet::max_retained(top_k)
1101        );
1102        assert!(sample.contributors.retained_count() <= top_k * 4);
1103        assert!(
1104            sample.estimated_bytes() <= ring.limits().estimated_bytes_per_sample(),
1105            "{} exceeded the budgeted {}",
1106            sample.estimated_bytes(),
1107            ring.limits().estimated_bytes_per_sample()
1108        );
1109    }
1110
1111    #[test]
1112    fn retained_bytes_stay_bounded_by_the_budget_over_a_long_run() {
1113        // §16.1: no unbounded memory growth. The ring is refilled several times
1114        // over and its accounting must not drift upward.
1115        let start = Instant::now();
1116        let interval = Duration::from_secs(1);
1117        let mut ring = HistoryRing::new(
1118            HistoryLimits::resolve(HistoryConfig {
1119                interval,
1120                duration: Duration::from_secs(30),
1121                ..HistoryConfig::default()
1122            }),
1123            start,
1124        );
1125
1126        let mut peak = 0usize;
1127        for sequence in 0..300 {
1128            let mut source = snapshot(start, sequence, interval);
1129            source
1130                .processes
1131                .extend((1..=200u32).map(|pid| process(pid, 1.0)));
1132            ring.record(&source);
1133            peak = peak.max(ring.estimated_bytes());
1134        }
1135        assert_eq!(ring.len(), 30);
1136        assert_eq!(ring.estimated_bytes(), peak, "accounting must not drift");
1137        assert!(
1138            u64::try_from(ring.estimated_bytes()).unwrap_or(u64::MAX)
1139                <= ring.limits().memory_budget_bytes()
1140        );
1141    }
1142
1143    #[test]
1144    fn binary_search_finds_the_newest_sample_at_or_before_an_offset() {
1145        let start = Instant::now();
1146        let interval = Duration::from_secs(1);
1147        let mut ring = HistoryRing::new(HistoryLimits::default(), start);
1148        for sequence in 0..10 {
1149            ring.record(&snapshot(start, sequence, interval));
1150        }
1151
1152        assert_eq!(ring.index_at_or_before_offset(Duration::ZERO), Some(0));
1153        assert_eq!(
1154            ring.index_at_or_before_offset(Duration::from_secs(4)),
1155            Some(4)
1156        );
1157        assert_eq!(
1158            ring.index_at_or_before_offset(Duration::from_millis(4_500)),
1159            Some(4),
1160            "an offset between samples resolves to the older one"
1161        );
1162        assert_eq!(
1163            ring.index_at_or_before_offset(Duration::from_secs(99)),
1164            Some(9)
1165        );
1166    }
1167
1168    #[test]
1169    fn an_offset_older_than_the_whole_ring_has_no_sample() {
1170        let start = Instant::now();
1171        let interval = Duration::from_secs(1);
1172        let mut ring = HistoryRing::new(
1173            HistoryLimits::resolve(HistoryConfig {
1174                interval,
1175                duration: Duration::from_secs(30),
1176                ..HistoryConfig::default()
1177            }),
1178            start,
1179        );
1180        for sequence in 0..40 {
1181            ring.record(&snapshot(start, sequence, interval));
1182        }
1183        // The oldest retained sample sits at offset 10s.
1184        assert_eq!(ring.index_at_or_before_offset(Duration::from_secs(5)), None);
1185        assert_eq!(
1186            ring.index_at_or_before_offset(Duration::from_secs(10)),
1187            Some(0)
1188        );
1189    }
1190
1191    #[test]
1192    fn seeking_probes_a_logarithmic_number_of_samples() {
1193        // §21 M4 requires seeking to be constant or effectively constant time.
1194        // Counting probes pins that structurally, without a flaky timing test.
1195        for len in [1usize, 10, 300, 10_000, 1_000_000] {
1196            let mut probes = 0usize;
1197            let found = partition_point(len, |_| {
1198                probes += 1;
1199                true
1200            });
1201            assert_eq!(found, len);
1202            let bound =
1203                usize::try_from(usize::BITS - len.leading_zeros() + 1).unwrap_or(usize::MAX);
1204            assert!(
1205                probes <= bound,
1206                "len {len} took {probes} probes, expected at most {bound}"
1207            );
1208        }
1209    }
1210
1211    #[test]
1212    fn a_counter_reset_is_retained_as_unavailable_rather_than_a_spike() {
1213        // §21 M4 acceptance: counter resets do not create false spikes.
1214        let start = Instant::now();
1215        let interval = Duration::from_secs(1);
1216        let mut ring = HistoryRing::new(HistoryLimits::default(), start);
1217
1218        let mut busy = with_cpu(snapshot(start, 1, interval), 20.0);
1219        let mut disk = DiskSnapshot::warming_up("nvme0n1".into());
1220        disk.read = MetricState::Available(Rate::new(1_000_000.0).expect("valid rate"));
1221        busy.disks.push(disk);
1222        ring.record(&busy);
1223
1224        let mut reset = with_cpu(snapshot(start, 2, interval), 22.0);
1225        let mut disk = DiskSnapshot::warming_up("nvme0n1".into());
1226        disk.read = MetricState::TemporarilyUnavailable(UnavailableReason::CounterReset);
1227        reset.disks.push(disk);
1228        ring.record(&reset);
1229
1230        let sample = ring.newest().expect("recorded");
1231        assert_eq!(
1232            sample.system.disk_read,
1233            MetricState::TemporarilyUnavailable(UnavailableReason::CounterReset)
1234        );
1235        assert!(
1236            sample.system.scalar(HistoryMetric::DiskRead).is_none(),
1237            "an unavailable input must not produce a number to spike with"
1238        );
1239    }
1240
1241    #[test]
1242    fn a_capacity_of_one_still_records_and_evicts() {
1243        let start = Instant::now();
1244        let interval = Duration::from_secs(60);
1245        let mut ring = HistoryRing::new(
1246            HistoryLimits::resolve(HistoryConfig {
1247                interval,
1248                duration: Duration::from_secs(30),
1249                ..HistoryConfig::default()
1250            }),
1251            start,
1252        );
1253        assert_eq!(ring.capacity(), 1);
1254        assert!(ring.record(&snapshot(start, 1, interval)).is_recorded());
1255        assert_eq!(
1256            ring.record(&snapshot(start, 2, interval)),
1257            RecordOutcome::Recorded { evicted: true }
1258        );
1259        assert_eq!(ring.len(), 1);
1260        assert_eq!(ring.newest().map(|s| s.sequence), Some(2));
1261    }
1262
1263    #[test]
1264    fn the_clamped_value_renderer_round_trips_configuration_syntax() {
1265        assert_eq!(
1266            ClampedValue::Duration(Duration::from_millis(250)).render(),
1267            "250ms"
1268        );
1269        assert_eq!(ClampedValue::Count(10).render(), "10");
1270        assert_eq!(
1271            ClampedValue::Bytes(32 * 1024 * 1024).render(),
1272            "32 MiB",
1273            "the key is written as 32MiB in configuration"
1274        );
1275    }
1276
1277    #[test]
1278    fn a_ring_built_from_a_config_reports_its_own_clamps() {
1279        let ring = HistoryRing::with_config(
1280            HistoryConfig {
1281                interval: Duration::from_millis(1),
1282                ..HistoryConfig::default()
1283            },
1284            Instant::now(),
1285        );
1286        assert!(!ring.clamps().is_empty());
1287        assert_eq!(ring.limits().interval(), MIN_SAMPLE_INTERVAL);
1288    }
1289}