Skip to main content

monitrs_core/rates/
keyed.rs

1//! A bounded, keyed set of delta trackers: one per disk, interface, or process.
2//!
3//! Two failure modes motivate this file, and both are named in the
4//! specification:
5//!
6//! * A device, interface, or PID that disappears and comes back must not produce
7//!   a delta spanning the gap (§8.2). The set forgets a key on request and can
8//!   also notice a suspicious gap on its own.
9//! * A tracker map keyed on processes grows forever as PIDs churn, which §10.3
10//!   forbids. The set has a hard size cap, explicit removal, and idle pruning.
11
12use core::fmt;
13use core::hash::Hash;
14use core::time::Duration;
15use std::collections::HashMap;
16use std::time::Instant;
17
18use crate::model::{MetricState, ProcessIdentity, UnavailableReason};
19use crate::rates::counter::CounterTracker;
20use crate::rates::cpu::ProcessCpuTracker;
21
22/// Default cap on the number of keys a single set will track.
23///
24/// §10.3 forbids unbounded growth, and the process-keyed set is the dangerous
25/// case: without a cap, every short-lived PID leaves a baseline behind forever.
26/// 16 384 comfortably exceeds the 10 000-process high-load case in §16.2 while
27/// bounding the set to well under a mebibyte.
28pub const DEFAULT_MAX_TRACKED: usize = 16_384;
29
30/// A single-key delta tracker that a [`KeyedTrackers`] set can manage.
31///
32/// The trait exists so the bounded-growth, gap-detection, and re-baselining
33/// rules are written once and shared by counter rates and per-process CPU rather
34/// than duplicated per metric. Every observation yields a
35/// [`MetricState`], which is what lets the set report a re-appearance after a gap
36/// without knowing what kind of value the tracker produces.
37pub trait DeltaTracker {
38    /// Construction parameters shared by every tracker in one set.
39    ///
40    /// `Copy` so the set can hand a fresh copy to each new tracker without
41    /// allocating, and `Debug` so the set itself can derive `Debug`.
42    type Config: Copy + fmt::Debug;
43    /// The cumulative reading folded in on each observation.
44    type Reading;
45    /// The value published when an observation succeeds.
46    type Value;
47
48    /// Builds a tracker with no baseline yet.
49    fn with_config(config: Self::Config) -> Self;
50
51    /// Folds one cumulative reading in, at monotonic time `at`.
52    fn observe_reading(&mut self, reading: Self::Reading, at: Instant) -> MetricState<Self::Value>;
53
54    /// When this tracker last accepted a reading, or `None` while warming up.
55    fn last_observed_at(&self) -> Option<Instant>;
56
57    /// Drops the baseline so the next reading warms up instead of producing a
58    /// delta across a gap (§8.2).
59    fn forget_baseline(&mut self);
60}
61
62/// A keyed set of delta trackers with a hard bound on its size.
63///
64/// # Per-cycle usage
65///
66/// A collector observes every key the OS still reports, then drops the rest:
67///
68/// ```
69/// use core::time::Duration;
70/// use std::time::Instant;
71///
72/// use monitrs_core::rates::{CounterWidth, KeyedRateTrackers};
73///
74/// let mut rx: KeyedRateTrackers<String> = KeyedRateTrackers::new(CounterWidth::Bits64);
75/// let t0 = Instant::now();
76///
77/// // First cycle: two interfaces, both warming up.
78/// assert!(rx.observe("eth0".to_owned(), 1_000, t0).is_warming_up());
79/// assert!(rx.observe("wlan0".to_owned(), 500, t0).is_warming_up());
80///
81/// // Second cycle: wlan0 is gone, so it is dropped rather than left to accrue.
82/// let t1 = t0 + Duration::from_secs(1);
83/// let eth0 = rx.observe("eth0".to_owned(), 3_000, t1);
84/// rx.retain(|name| name == "eth0");
85///
86/// assert_eq!(eth0.fresh().map(|rate| rate.per_second()), Some(2_000.0));
87/// assert_eq!(rx.len(), 1);
88///
89/// // wlan0 comes back with a counter that restarted: it re-baselines instead of
90/// // reporting the whole counter as one second of traffic.
91/// let t2 = t1 + Duration::from_secs(1);
92/// assert!(rx.observe("wlan0".to_owned(), 90_000, t2).is_warming_up());
93/// ```
94#[derive(Debug)]
95pub struct KeyedTrackers<K, T: DeltaTracker> {
96    config: T::Config,
97    max_tracked: usize,
98    max_gap: Option<Duration>,
99    evictions: u64,
100    entries: HashMap<K, T>,
101}
102
103impl<K, T> KeyedTrackers<K, T>
104where
105    K: Clone + Eq + Hash,
106    T: DeltaTracker,
107{
108    /// Builds an empty set with [`DEFAULT_MAX_TRACKED`] and no gap guard.
109    #[must_use]
110    pub fn new(config: T::Config) -> Self {
111        Self {
112            config,
113            max_tracked: DEFAULT_MAX_TRACKED,
114            max_gap: None,
115            evictions: 0,
116            entries: HashMap::new(),
117        }
118    }
119
120    /// Overrides the hard size cap (§10.3).
121    ///
122    /// A cap of zero tracks nothing and reports every key as skipped, which is a
123    /// branch-free way to disable an expensive metric under load (§16.2).
124    #[must_use]
125    pub fn with_max_tracked(mut self, max_tracked: usize) -> Self {
126        self.max_tracked = max_tracked;
127        self
128    }
129
130    /// Treats a gap longer than `max_gap` between two readings of one key as the
131    /// key having disappeared and come back (§8.2).
132    ///
133    /// This is a safety net, not the primary mechanism: a collector that calls
134    /// [`KeyedTrackers::retain`] or [`KeyedTrackers::forget`] each cycle never
135    /// needs it. Set it to a small multiple of the sampling interval so ordinary
136    /// jitter does not trip it, and remember that suspend/resume looks exactly
137    /// like a disappearance from in here — reporting it as one is the honest
138    /// answer, because the counter advanced during a period this sample cannot
139    /// account for.
140    #[must_use]
141    pub fn with_max_gap(mut self, max_gap: Duration) -> Self {
142        self.max_gap = Some(max_gap);
143        self
144    }
145
146    /// Folds one reading for `key` in and publishes the result.
147    ///
148    /// A key seen for the first time warms up rather than reporting zero (§8.2).
149    /// `at` must be monotonic.
150    pub fn observe(&mut self, key: K, reading: T::Reading, at: Instant) -> MetricState<T::Value> {
151        if let Some(tracker) = self.entries.get_mut(&key) {
152            let gapped = match (self.max_gap, DeltaTracker::last_observed_at(tracker)) {
153                (Some(max_gap), Some(previous)) => at.saturating_duration_since(previous) > max_gap,
154                _ => false,
155            };
156            if !gapped {
157                return tracker.observe_reading(reading, at);
158            }
159            // The key went unobserved for longer than sampling allows, so it was
160            // absent. Fold the reading in *after* dropping the baseline so this
161            // sample is honest and the next one is valid (§8.2).
162            tracker.forget_baseline();
163            let _ = tracker.observe_reading(reading, at);
164            return MetricState::TemporarilyUnavailable(UnavailableReason::DeviceDisappeared);
165        }
166
167        if self.entries.len() >= self.max_tracked && !self.evict_oldest() {
168            // Only reachable with a cap of zero: the caller has switched this
169            // metric off. Saying so beats reporting zero (§4).
170            return MetricState::TemporarilyUnavailable(UnavailableReason::SkippedUnderLoad);
171        }
172        let config = self.config;
173        self.entries
174            .entry(key)
175            .or_insert_with(|| T::with_config(config))
176            .observe_reading(reading, at)
177    }
178
179    /// Drops `key` entirely, so a later re-appearance re-baselines.
180    ///
181    /// This is the explicit answer to every identity change §8.2 lists: a device
182    /// that vanished, a renamed interface, an exited PID. Returns whether the key
183    /// was being tracked. The caller publishes the matching
184    /// [`UnavailableReason`] — `DeviceDisappeared`, `InterfaceRenamed`, or
185    /// `ProcessExited` — for the sample in which it noticed.
186    pub fn forget(&mut self, key: &K) -> bool {
187        self.entries.remove(key).is_some()
188    }
189
190    /// Keeps only the keys `keep` accepts, returning how many were dropped.
191    ///
192    /// The cheap per-cycle way to stay bounded: call it with the set of keys the
193    /// OS still reports. Deliberately not counted as an eviction, because it is
194    /// the caller acting on knowledge rather than the set defending its budget.
195    pub fn retain(&mut self, mut keep: impl FnMut(&K) -> bool) -> usize {
196        let before = self.entries.len();
197        self.entries.retain(|key, _| keep(key));
198        before.saturating_sub(self.entries.len())
199    }
200
201    /// Drops trackers that have not seen a reading within `max_idle`.
202    ///
203    /// The backstop for PID churn: a process that exits is never observed again,
204    /// so it ages out even if the collector never says it is gone (§10.3). A
205    /// tracker that never completed a reading holds no baseline worth keeping and
206    /// is dropped too.
207    pub fn prune_idle(&mut self, now: Instant, max_idle: Duration) -> usize {
208        let before = self.entries.len();
209        self.entries.retain(|_, tracker| {
210            DeltaTracker::last_observed_at(tracker)
211                .is_some_and(|at| now.saturating_duration_since(at) <= max_idle)
212        });
213        let dropped = before.saturating_sub(self.entries.len());
214        self.evictions = self
215            .evictions
216            .saturating_add(u64::try_from(dropped).unwrap_or(u64::MAX));
217        dropped
218    }
219
220    /// Drops every tracker.
221    pub fn clear(&mut self) {
222        self.entries.clear();
223    }
224
225    /// How many keys are currently tracked.
226    #[must_use]
227    pub fn len(&self) -> usize {
228        self.entries.len()
229    }
230
231    /// Whether nothing is currently tracked.
232    #[must_use]
233    pub fn is_empty(&self) -> bool {
234        self.entries.is_empty()
235    }
236
237    /// Whether `key` currently has a tracker.
238    #[must_use]
239    pub fn contains_key(&self, key: &K) -> bool {
240        self.entries.contains_key(key)
241    }
242
243    /// The tracker for `key`, for callers that need its raw baseline.
244    #[must_use]
245    pub fn tracker(&self, key: &K) -> Option<&T> {
246        self.entries.get(key)
247    }
248
249    /// The hard size cap this set enforces.
250    #[must_use]
251    pub const fn max_tracked(&self) -> usize {
252        self.max_tracked
253    }
254
255    /// How many trackers this set has dropped to stay inside its budget.
256    ///
257    /// Worth surfacing through [`crate::model::CollectorHealth`]: a non-zero and
258    /// rising count means the cap is too low for the workload, and rates for the
259    /// churning keys are being restarted rather than measured.
260    #[must_use]
261    pub const fn evictions(&self) -> u64 {
262        self.evictions
263    }
264
265    /// Drops the least-recently-observed tracker, returning whether one went.
266    ///
267    /// Linear in the number of keys, but only ever called on insertion while at
268    /// the cap — the situation where the set is already refusing to grow.
269    /// Trackers that never completed a reading sort first (`None` before `Some`)
270    /// because they hold no baseline to lose.
271    fn evict_oldest(&mut self) -> bool {
272        let victim = self
273            .entries
274            .iter()
275            .min_by_key(|(_, tracker)| DeltaTracker::last_observed_at(*tracker))
276            .map(|(key, _)| key.clone());
277        let Some(key) = victim else {
278            return false;
279        };
280        self.entries.remove(&key);
281        self.evictions = self.evictions.saturating_add(1);
282        true
283    }
284}
285
286impl<K, T> Default for KeyedTrackers<K, T>
287where
288    K: Clone + Eq + Hash,
289    T: DeltaTracker,
290    T::Config: Default,
291{
292    fn default() -> Self {
293        Self::new(T::Config::default())
294    }
295}
296
297/// A keyed set of cumulative-counter rate trackers: one per disk, interface, or
298/// mount point.
299///
300/// Every tracker in the set shares one [`CounterWidth`](super::CounterWidth),
301/// which is correct because a set holds counters of one kind from one source.
302pub type KeyedRateTrackers<K> = KeyedTrackers<K, CounterTracker>;
303
304/// A keyed set of per-process CPU trackers.
305///
306/// Keyed on [`ProcessIdentity`] rather than a bare PID, so a reused PID gets a
307/// fresh baseline instead of inheriting the dead process's CPU time (§26). That
308/// choice is baked into the alias precisely so it cannot be got wrong at a call
309/// site.
310pub type KeyedProcessCpuTrackers = KeyedTrackers<ProcessIdentity, ProcessCpuTracker>;
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::rates::counter::CounterWidth;
316    use crate::rates::cpu::{CpuTimeTotals, SystemCpuTracker};
317    use crate::units::{Percent, Rate};
318
319    fn origin() -> Instant {
320        Instant::now()
321    }
322
323    fn secs(seconds: u64) -> Duration {
324        Duration::from_secs(seconds)
325    }
326
327    /// See the note on `assert_rate` in `counter.rs`: an exact float comparison
328    /// is denied by `clippy::float_cmp` and unreliable for a derived rate.
329    fn assert_rate(state: &MetricState<Rate>, expected: f64) {
330        let actual = state
331            .fresh()
332            .expect("expected a measured rate")
333            .per_second();
334        assert!(
335            (actual - expected).abs() < 1e-6,
336            "expected {expected}/s, got {actual}/s"
337        );
338    }
339
340    fn bytes() -> KeyedRateTrackers<&'static str> {
341        KeyedRateTrackers::new(CounterWidth::Bits64)
342    }
343
344    #[test]
345    fn an_unseen_key_warms_up_instead_of_reporting_zero() {
346        let mut set = bytes();
347        let state = set.observe("eth0", 4_096, origin());
348        assert!(state.is_warming_up());
349        assert_eq!(set.len(), 1);
350        assert!(set.contains_key(&"eth0"));
351    }
352
353    #[test]
354    fn a_second_reading_for_a_key_yields_a_rate_over_the_real_interval() {
355        let t0 = origin();
356        let mut set = bytes();
357        set.observe("eth0", 1_000, t0);
358        let state = set.observe("eth0", 2_000, t0 + Duration::from_millis(500));
359        assert_rate(&state, 2_000.0);
360    }
361
362    #[test]
363    fn keys_keep_independent_baselines() {
364        let t0 = origin();
365        let mut set = bytes();
366        set.observe("eth0", 0, t0);
367        set.observe("wlan0", 1_000_000, t0);
368
369        let eth0 = set.observe("eth0", 100, t0 + secs(1));
370        let wlan0 = set.observe("wlan0", 1_000_300, t0 + secs(1));
371        assert_rate(&eth0, 100.0);
372        assert_rate(&wlan0, 300.0);
373    }
374
375    #[test]
376    fn a_forgotten_key_rebaselines_when_it_reappears() {
377        let t0 = origin();
378        let mut set = bytes();
379        set.observe("sdb", 900_000, t0);
380        assert!(set.forget(&"sdb"));
381        assert!(!set.forget(&"sdb"), "forgetting twice is not an error");
382        assert!(set.is_empty());
383
384        // The device came back with a counter that restarted from zero. Without
385        // the re-baseline this would announce 40 kB/s that never happened.
386        let state = set.observe("sdb", 40_000, t0 + secs(1));
387        assert!(state.is_warming_up());
388    }
389
390    #[test]
391    fn retain_drops_absent_keys_so_a_reappearance_cannot_produce_a_bogus_delta() {
392        let t0 = origin();
393        let mut set = bytes();
394        set.observe("eth0", 1_000, t0);
395        set.observe("tun0", 5_000, t0);
396
397        // tun0 went away; the interface list no longer contains it.
398        assert_eq!(set.retain(|key| *key == "eth0"), 1);
399        assert_eq!(set.len(), 1);
400        assert_eq!(set.evictions(), 0, "deliberate removal is not an eviction");
401
402        // It returns much later with a much larger counter.
403        let state = set.observe("tun0", 9_000_000, t0 + secs(300));
404        assert!(state.is_warming_up());
405        let recovered = set.observe("tun0", 9_000_100, t0 + secs(301));
406        assert_rate(&recovered, 100.0);
407    }
408
409    #[test]
410    fn a_gap_longer_than_the_guard_is_reported_as_a_disappearance() {
411        let t0 = origin();
412        let mut set = bytes().with_max_gap(secs(3));
413        set.observe("eth0", 1_000, t0);
414
415        // Ten seconds of silence: the caller never said the interface went away,
416        // but a delta across that gap would be attributed to this one sample.
417        let gapped = set.observe("eth0", 9_000_000, t0 + secs(10));
418        assert_eq!(
419            gapped,
420            MetricState::TemporarilyUnavailable(UnavailableReason::DeviceDisappeared)
421        );
422
423        // Re-baselined on the gapped reading, so the next sample is valid.
424        let recovered = set.observe("eth0", 9_000_500, t0 + secs(11));
425        assert_rate(&recovered, 500.0);
426    }
427
428    #[test]
429    fn a_gap_inside_the_guard_is_an_ordinary_sample() {
430        let t0 = origin();
431        let mut set = bytes().with_max_gap(secs(3));
432        set.observe("eth0", 1_000, t0);
433        let state = set.observe("eth0", 3_000, t0 + secs(2));
434        assert_rate(&state, 1_000.0);
435    }
436
437    #[test]
438    fn without_a_guard_no_gap_is_ever_treated_as_a_disappearance() {
439        // The guard is opt-in: the default set trusts the caller's own removal.
440        let t0 = origin();
441        let mut set = bytes();
442        set.observe("eth0", 1_000, t0);
443        let state = set.observe("eth0", 3_000, t0 + secs(600));
444        assert!(state.is_available());
445    }
446
447    #[test]
448    fn the_set_never_grows_past_its_cap_as_keys_churn() {
449        // §10.3: a process-keyed map must not grow without bound. Ten thousand
450        // distinct short-lived keys must leave the set at its cap, not at 10 000.
451        let t0 = origin();
452        let mut set: KeyedRateTrackers<u64> =
453            KeyedRateTrackers::new(CounterWidth::Bits64).with_max_tracked(64);
454        for pid in 0..10_000u64 {
455            set.observe(pid, pid, t0 + Duration::from_millis(pid));
456        }
457        assert_eq!(set.max_tracked(), 64);
458        assert!(set.len() <= 64, "len was {}", set.len());
459        assert!(set.evictions() > 0, "eviction must be reported");
460    }
461
462    #[test]
463    fn the_least_recently_observed_key_is_evicted_first() {
464        let t0 = origin();
465        let mut set = bytes().with_max_tracked(2);
466        set.observe("oldest", 1, t0);
467        set.observe("newer", 1, t0 + secs(5));
468
469        set.observe("newest", 1, t0 + secs(10));
470        assert!(!set.contains_key(&"oldest"));
471        assert!(set.contains_key(&"newer"));
472        assert!(set.contains_key(&"newest"));
473        assert_eq!(set.evictions(), 1);
474    }
475
476    #[test]
477    fn a_zero_cap_reports_skipped_rather_than_zero() {
478        let mut set = bytes().with_max_tracked(0);
479        let state = set.observe("eth0", 1_000, origin());
480        assert_eq!(
481            state,
482            MetricState::TemporarilyUnavailable(UnavailableReason::SkippedUnderLoad)
483        );
484        assert!(set.is_empty());
485    }
486
487    #[test]
488    fn prune_idle_drops_keys_that_stopped_reporting() {
489        let t0 = origin();
490        let mut set = bytes();
491        set.observe("alive", 0, t0);
492        set.observe("exited", 0, t0);
493
494        // "alive" keeps reporting; "exited" does not.
495        set.observe("alive", 100, t0 + secs(30));
496        assert_eq!(set.prune_idle(t0 + secs(30), secs(5)), 1);
497        assert!(set.contains_key(&"alive"));
498        assert!(!set.contains_key(&"exited"));
499        assert_eq!(set.evictions(), 1);
500    }
501
502    #[test]
503    fn pruning_an_empty_set_is_a_no_op() {
504        let mut set = bytes();
505        assert_eq!(set.prune_idle(origin(), secs(1)), 0);
506        assert!(set.is_empty());
507    }
508
509    #[test]
510    fn pruning_keeps_a_key_observed_exactly_at_the_idle_limit() {
511        // The boundary matters: at a 1 s interval and a 1 s limit, an on-time key
512        // must survive or every rate restarts every cycle.
513        let t0 = origin();
514        let mut set = bytes();
515        set.observe("eth0", 0, t0);
516        assert_eq!(set.prune_idle(t0 + secs(1), secs(1)), 0);
517        assert!(set.contains_key(&"eth0"));
518        assert_eq!(
519            set.prune_idle(t0 + secs(1) + Duration::from_nanos(1), secs(1)),
520            1
521        );
522    }
523
524    #[test]
525    fn clearing_drops_every_baseline() {
526        let t0 = origin();
527        let mut set = bytes();
528        set.observe("a", 1, t0);
529        set.observe("b", 1, t0);
530        set.clear();
531        assert!(set.is_empty());
532        assert!(set.observe("a", 1_000_000, t0 + secs(1)).is_warming_up());
533    }
534
535    #[test]
536    fn the_tracker_behind_a_key_is_inspectable() {
537        let t0 = origin();
538        let mut set = bytes();
539        set.observe("eth0", 4_096, t0);
540        let tracker = set.tracker(&"eth0").expect("tracked");
541        assert_eq!(tracker.last_value(), Some(4_096));
542        assert_eq!(tracker.width(), CounterWidth::Bits64);
543        assert!(set.tracker(&"missing").is_none());
544    }
545
546    #[test]
547    fn a_default_set_uses_the_default_counter_width_and_cap() {
548        let set: KeyedRateTrackers<&'static str> = KeyedRateTrackers::default();
549        assert_eq!(set.max_tracked(), DEFAULT_MAX_TRACKED);
550        assert!(set.is_empty());
551    }
552
553    #[test]
554    fn a_known_width_wrap_still_works_through_the_keyed_set() {
555        let t0 = origin();
556        let mut set: KeyedRateTrackers<&'static str> = KeyedRateTrackers::new(CounterWidth::Bits32);
557        let previous = u64::from(u32::MAX) - 99;
558        set.observe("eth0", previous, t0);
559        let state = set.observe("eth0", 400, t0 + secs(1));
560        assert_rate(&state, 500.0);
561    }
562
563    #[test]
564    fn process_cpu_trackers_are_keyed_on_identity_so_a_reused_pid_rebaselines() {
565        // §26 and rule 4: a PID alone is not an identity. The recycled PID must
566        // not inherit 30 s of CPU time from the process that exited.
567        let t0 = origin();
568        let original = ProcessIdentity::new(4_242, 900_100);
569        let recycled = ProcessIdentity::new(4_242, 977_400);
570
571        let mut set = KeyedProcessCpuTrackers::default();
572        set.observe(original, secs(30), t0);
573        let measured = set.observe(original, secs(31), t0 + secs(1));
574        assert!(
575            (measured
576                .fresh()
577                .copied()
578                .map(Percent::value)
579                .expect("measured")
580                - 100.0)
581                .abs()
582                < f32::EPSILON
583        );
584
585        let reused = set.observe(recycled, Duration::from_millis(10), t0 + secs(2));
586        assert!(
587            reused.is_warming_up(),
588            "a recycled PID must warm up, not report a negative or reset delta"
589        );
590        assert_eq!(set.len(), 2, "the two identities are distinct keys");
591    }
592
593    #[test]
594    fn exited_processes_are_prunable_so_the_set_stays_bounded() {
595        let t0 = origin();
596        let mut set = KeyedProcessCpuTrackers::default();
597        for pid in 0..500u32 {
598            set.observe(ProcessIdentity::new(pid, 1), Duration::ZERO, t0);
599        }
600        let survivor = ProcessIdentity::new(1, 1);
601        set.observe(survivor, Duration::from_millis(1), t0 + secs(10));
602
603        assert_eq!(set.prune_idle(t0 + secs(10), secs(2)), 499);
604        assert_eq!(set.len(), 1);
605        assert!(set.contains_key(&survivor));
606    }
607
608    #[test]
609    fn per_core_cpu_trackers_share_the_set_without_a_counter_width() {
610        // The `()` configuration path: per-core trackers keyed on the core index,
611        // which change when a CPU is hotplugged.
612        let t0 = origin();
613        let mut set: KeyedTrackers<u16, SystemCpuTracker> = KeyedTrackers::default();
614        for core in 0..4u16 {
615            assert!(
616                set.observe(core, CpuTimeTotals::new(secs(0), secs(0)), t0)
617                    .is_warming_up()
618            );
619        }
620        let busy = set.observe(0, CpuTimeTotals::new(secs(1), secs(3)), t0 + secs(4));
621        assert!(
622            (busy.fresh().copied().map(Percent::value).expect("measured") - 25.0).abs()
623                < f32::EPSILON
624        );
625        assert_eq!(set.len(), 4);
626    }
627}