Skip to main content

monitrs_core/diagnostics/
thresholds.rs

1//! The tunable numbers every rule and every radar signal reads (§11.3, §12).
2//!
3//! §11.3 requires thresholds to be *encoded in configuration* rather than
4//! scattered through the rules. This struct is that configuration: the five keys
5//! §12 names under `[diagnostics]` plus documented defaults for everything else
6//! the rules in §11.2 need. Nothing in this module reads a file — the config
7//! layer deserializes into this type and hands it to the engine.
8
9use core::time::Duration;
10
11/// §12 default for `diagnostics.cpu_watch_percent`.
12pub const DEFAULT_CPU_WATCH_PERCENT: f32 = 80.0;
13/// §12 default for `diagnostics.cpu_critical_percent`.
14pub const DEFAULT_CPU_CRITICAL_PERCENT: f32 = 95.0;
15/// §12 default for `diagnostics.memory_watch_available_percent`.
16pub const DEFAULT_MEMORY_WATCH_AVAILABLE_PERCENT: f32 = 15.0;
17/// §12 default for `diagnostics.memory_critical_available_percent`.
18pub const DEFAULT_MEMORY_CRITICAL_AVAILABLE_PERCENT: f32 = 5.0;
19/// §12 default for `diagnostics.sustained_samples`.
20pub const DEFAULT_SUSTAINED_SAMPLES: usize = 10;
21
22/// Default number of recent samples a sustained condition is counted over.
23///
24/// §11.3's worked example reads "CPU > 90% for 12 of the last 15 samples", so the
25/// window is a little larger than [`DEFAULT_SUSTAINED_SAMPLES`]: a condition may
26/// miss a sample or two and still be sustained, which is exactly the tolerance
27/// that stops one clean tick from clearing a real problem.
28pub const DEFAULT_SUSTAINED_WINDOW: usize = 15;
29
30/// Largest accepted `sustained_window`.
31///
32/// The window is a per-signal ring of observations, so it must be bounded for the
33/// same reason history is (§10.3). Six hundred one-second observations is ten
34/// minutes, far beyond any threshold a radar signal needs.
35pub const MAX_SUSTAINED_WINDOW: usize = 600;
36
37/// Smallest accepted value for any "how many intervals" multiplier.
38pub const MIN_INTERVAL_MULTIPLE: f32 = 1.0;
39/// Largest accepted value for any "how many intervals" multiplier.
40///
41/// Bounded so that a mistyped configuration cannot produce a threshold that
42/// overflows duration arithmetic.
43pub const MAX_INTERVAL_MULTIPLE: f32 = 1_000.0;
44
45/// One mebibyte, used by several byte-valued defaults below.
46const MIB: u64 = 1024 * 1024;
47
48/// Every threshold the diagnostic engine reads.
49///
50/// Deliberately `Copy` plain data: rules hold a copy so that evaluating a rule is
51/// a pure function of `(snapshot, history)` and needs no shared configuration
52/// handle (§11.1).
53///
54/// Values arriving from configuration are not trusted. [`Thresholds::sanitized`]
55/// repairs the combinations that would otherwise make a rule silently
56/// undecidable — a critical threshold below its watch threshold, a window
57/// narrower than the number of samples counted inside it, a non-finite percentage.
58#[derive(Clone, Copy, Debug, PartialEq)]
59#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
60#[cfg_attr(feature = "serde", serde(default))]
61pub struct Thresholds {
62    /// §12's `diagnostics.enabled`.
63    ///
64    /// When false the engine derives nothing at all: [`super::RuleSet`] produces
65    /// no findings and every radar signal reports [`crate::MetricState::Unsupported`].
66    /// It deliberately does *not* report `normal`, because "we did not look" and
67    /// "we looked and the system is fine" are different statements (§2.3).
68    pub enabled: bool,
69
70    /// Aggregate CPU busy percentage at which the CPU signal reaches `watch`.
71    pub cpu_watch_percent: f32,
72    /// Aggregate CPU busy percentage at which the CPU signal reaches `critical`.
73    pub cpu_critical_percent: f32,
74
75    /// Available-memory share at or below which the memory signal reaches `watch`.
76    ///
77    /// Expressed as *available* rather than used because that is the number that
78    /// predicts reclaim pressure, and because §8.4 forbids treating all non-free
79    /// memory as application use.
80    pub memory_watch_available_percent: f32,
81    /// Available-memory share at or below which the memory signal reaches
82    /// `critical`.
83    pub memory_critical_available_percent: f32,
84
85    /// How many samples inside the window must meet a condition before a
86    /// sustained finding is made, and before hysteresis escalates a signal.
87    ///
88    /// Also the minimum number of observations required before any sustained
89    /// claim is possible at all: with fewer than this, the signal is
90    /// [`crate::MetricState::WarmingUp`] (§11.3).
91    pub sustained_samples: usize,
92    /// How many recent samples `sustained_samples` is counted out of.
93    pub sustained_window: usize,
94
95    /// Combined swap-in plus swap-out throughput at which swap activity is
96    /// `watch`, in bytes per second.
97    ///
98    /// Default 1 MiB/s. Below that, paging is ordinary lazy loading and a large
99    /// but idle swap file is unremarkable; §11.2 cares about *activity*.
100    pub swap_watch_bytes_per_second: f64,
101    /// Combined swap throughput at which swap activity is `critical`, in bytes
102    /// per second.
103    ///
104    /// Default 16 MiB/s: sustained tens of mebibytes per second means the working
105    /// set does not fit and every fault is paid for in latency.
106    pub swap_critical_bytes_per_second: f64,
107
108    /// Linux PSI `some avg10` share at which a PSI signal reaches `watch`.
109    ///
110    /// Default 10%: a tenth of the last ten seconds with at least one task
111    /// stalled is measurable rather than incidental.
112    pub psi_watch_percent: f32,
113    /// Linux PSI `some avg10` share at which a PSI signal reaches `critical`.
114    ///
115    /// Default 40%. Note that a busy machine legitimately shows non-zero CPU PSI,
116    /// which is why this is generous rather than near zero.
117    pub psi_critical_percent: f32,
118
119    /// Block-device busy percentage at which the disk signal reaches `watch`.
120    ///
121    /// Only meaningful where the platform reports a semantically correct busy
122    /// figure (§7.3); elsewhere the signal is unsupported rather than zero.
123    pub disk_busy_watch_percent: f32,
124    /// Block-device busy percentage at which the disk signal reaches `critical`.
125    pub disk_busy_critical_percent: f32,
126
127    /// Link utilization at which the network signal reaches `watch`.
128    ///
129    /// Only computed when the link speed is known (§7.4).
130    pub network_watch_percent: f32,
131    /// Link utilization at which the network signal reaches `critical`.
132    pub network_critical_percent: f32,
133
134    /// One-minute load per logical CPU at which the load signal reaches `watch`.
135    ///
136    /// Default 1.0: a run queue as long as the CPU count means everything
137    /// runnable is waiting for a turn.
138    pub load_watch_per_cpu: f32,
139    /// One-minute load per logical CPU at which the load signal reaches
140    /// `critical`. Default 2.0.
141    pub load_critical_per_cpu: f32,
142
143    /// Core-normalized CPU percentage a process must reach before a rise in its
144    /// usage is reported at all. Default 100%, i.e. a full core.
145    pub process_cpu_spike_percent: f32,
146    /// Percentage points a process's CPU must rise between two samples to be
147    /// called a spike. Default 50 points.
148    pub process_cpu_spike_points: f32,
149
150    /// Resident set size a process must exceed before its growth is reported.
151    ///
152    /// Default 128 MiB. Small processes double their footprint routinely and
153    /// reporting them would bury the interesting cases.
154    pub process_rss_minimum_bytes: u64,
155    /// Resident-set growth rate that counts as rapidly increasing, in bytes per
156    /// minute.
157    ///
158    /// Default 64 MiB/min. §11.3 forbids concluding anything about *why* memory
159    /// is growing from this alone, so the rule reports the observation only.
160    pub process_rss_growth_bytes_per_minute: u64,
161
162    /// How many zombies must be present before the finding escalates from
163    /// informational to `watch`.
164    ///
165    /// Default 8. One zombie is a normal instant in a process's teardown; a
166    /// growing pile means a parent is not reaping (§11.2).
167    pub zombie_watch_count: usize,
168
169    /// Collector lag, in sample intervals, at which falling behind is `watch`.
170    pub collector_lag_watch_intervals: f32,
171    /// Collector lag, in sample intervals, at which falling behind is `critical`.
172    pub collector_lag_critical_intervals: f32,
173
174    /// Data age, in sample intervals, at which a snapshot counts as stale.
175    pub stale_watch_intervals: f32,
176    /// Data age, in sample intervals, at which staleness is `critical`.
177    pub stale_critical_intervals: f32,
178
179    /// Interval growth, in sample intervals, that is treated as a discontinuity
180    /// rather than as a measurement.
181    ///
182    /// A suspended laptop produces one enormous interval. §11.3 requires the
183    /// engine to reset cleanly across that rather than read the gap as an event,
184    /// so anything beyond this multiple of the normal interval clears hysteresis
185    /// state instead of feeding it (default 10 intervals).
186    pub discontinuity_intervals: f32,
187
188    /// Our own CPU budget, core-normalized (§16.1: p95 below 2%).
189    pub self_cpu_budget_percent: f32,
190    /// Our own resident memory budget (§16.1: below 50 MiB).
191    pub self_rss_budget_bytes: u64,
192    /// Our own fast-tier collection budget, in milliseconds (§16.1: p95 below
193    /// 200 ms at 200 processes).
194    ///
195    /// Stored as milliseconds rather than a [`Duration`] so that it round-trips
196    /// through a configuration file as a plain number; [`Self::self_sample_budget`]
197    /// converts it.
198    pub self_sample_budget_millis: u64,
199}
200
201impl Default for Thresholds {
202    /// The §12 defaults, plus the documented defaults for the rest.
203    fn default() -> Self {
204        Self {
205            enabled: true,
206            cpu_watch_percent: DEFAULT_CPU_WATCH_PERCENT,
207            cpu_critical_percent: DEFAULT_CPU_CRITICAL_PERCENT,
208            memory_watch_available_percent: DEFAULT_MEMORY_WATCH_AVAILABLE_PERCENT,
209            memory_critical_available_percent: DEFAULT_MEMORY_CRITICAL_AVAILABLE_PERCENT,
210            sustained_samples: DEFAULT_SUSTAINED_SAMPLES,
211            sustained_window: DEFAULT_SUSTAINED_WINDOW,
212            swap_watch_bytes_per_second: MIB as f64,
213            swap_critical_bytes_per_second: 16.0 * MIB as f64,
214            psi_watch_percent: 10.0,
215            psi_critical_percent: 40.0,
216            disk_busy_watch_percent: 80.0,
217            disk_busy_critical_percent: 95.0,
218            network_watch_percent: 70.0,
219            network_critical_percent: 90.0,
220            load_watch_per_cpu: 1.0,
221            load_critical_per_cpu: 2.0,
222            process_cpu_spike_percent: 100.0,
223            process_cpu_spike_points: 50.0,
224            process_rss_minimum_bytes: 128 * MIB,
225            process_rss_growth_bytes_per_minute: 64 * MIB,
226            zombie_watch_count: 8,
227            collector_lag_watch_intervals: 2.0,
228            collector_lag_critical_intervals: 5.0,
229            stale_watch_intervals: 3.0,
230            stale_critical_intervals: 10.0,
231            discontinuity_intervals: 10.0,
232            self_cpu_budget_percent: 2.0,
233            self_rss_budget_bytes: 50 * MIB,
234            self_sample_budget_millis: 200,
235        }
236    }
237}
238
239impl Thresholds {
240    /// Repairs values that would make a rule undecidable.
241    ///
242    /// Clamping rather than rejecting matches §8.5's treatment of history
243    /// configuration: a mistyped number must not stop monitrs from starting, and
244    /// it must not silently disable a documented signal either. Every constructor
245    /// in this module sanitizes, so no rule has to defend itself against
246    /// `sustained_window < sustained_samples` or a NaN percentage.
247    #[must_use]
248    pub fn sanitized(self) -> Self {
249        let defaults = Self::default();
250        let mut out = Self {
251            enabled: self.enabled,
252            cpu_watch_percent: non_negative(self.cpu_watch_percent, defaults.cpu_watch_percent),
253            cpu_critical_percent: non_negative(
254                self.cpu_critical_percent,
255                defaults.cpu_critical_percent,
256            ),
257            memory_watch_available_percent: non_negative(
258                self.memory_watch_available_percent,
259                defaults.memory_watch_available_percent,
260            ),
261            memory_critical_available_percent: non_negative(
262                self.memory_critical_available_percent,
263                defaults.memory_critical_available_percent,
264            ),
265            sustained_samples: self.sustained_samples.clamp(1, MAX_SUSTAINED_WINDOW),
266            sustained_window: self.sustained_window.clamp(1, MAX_SUSTAINED_WINDOW),
267            swap_watch_bytes_per_second: non_negative_f64(
268                self.swap_watch_bytes_per_second,
269                defaults.swap_watch_bytes_per_second,
270            ),
271            swap_critical_bytes_per_second: non_negative_f64(
272                self.swap_critical_bytes_per_second,
273                defaults.swap_critical_bytes_per_second,
274            ),
275            psi_watch_percent: non_negative(self.psi_watch_percent, defaults.psi_watch_percent),
276            psi_critical_percent: non_negative(
277                self.psi_critical_percent,
278                defaults.psi_critical_percent,
279            ),
280            disk_busy_watch_percent: non_negative(
281                self.disk_busy_watch_percent,
282                defaults.disk_busy_watch_percent,
283            ),
284            disk_busy_critical_percent: non_negative(
285                self.disk_busy_critical_percent,
286                defaults.disk_busy_critical_percent,
287            ),
288            network_watch_percent: non_negative(
289                self.network_watch_percent,
290                defaults.network_watch_percent,
291            ),
292            network_critical_percent: non_negative(
293                self.network_critical_percent,
294                defaults.network_critical_percent,
295            ),
296            load_watch_per_cpu: non_negative(self.load_watch_per_cpu, defaults.load_watch_per_cpu),
297            load_critical_per_cpu: non_negative(
298                self.load_critical_per_cpu,
299                defaults.load_critical_per_cpu,
300            ),
301            process_cpu_spike_percent: non_negative(
302                self.process_cpu_spike_percent,
303                defaults.process_cpu_spike_percent,
304            ),
305            process_cpu_spike_points: non_negative(
306                self.process_cpu_spike_points,
307                defaults.process_cpu_spike_points,
308            ),
309            process_rss_minimum_bytes: self.process_rss_minimum_bytes,
310            process_rss_growth_bytes_per_minute: self.process_rss_growth_bytes_per_minute.max(1),
311            zombie_watch_count: self.zombie_watch_count.max(1),
312            collector_lag_watch_intervals: multiple(
313                self.collector_lag_watch_intervals,
314                defaults.collector_lag_watch_intervals,
315            ),
316            collector_lag_critical_intervals: multiple(
317                self.collector_lag_critical_intervals,
318                defaults.collector_lag_critical_intervals,
319            ),
320            stale_watch_intervals: multiple(
321                self.stale_watch_intervals,
322                defaults.stale_watch_intervals,
323            ),
324            stale_critical_intervals: multiple(
325                self.stale_critical_intervals,
326                defaults.stale_critical_intervals,
327            ),
328            discontinuity_intervals: multiple(
329                self.discontinuity_intervals,
330                defaults.discontinuity_intervals,
331            ),
332            self_cpu_budget_percent: non_negative(
333                self.self_cpu_budget_percent,
334                defaults.self_cpu_budget_percent,
335            ),
336            self_rss_budget_bytes: self.self_rss_budget_bytes.max(1),
337            self_sample_budget_millis: self.self_sample_budget_millis.max(1),
338        };
339
340        // A critical threshold at or below its watch threshold would make the
341        // watch state unreachable, so the more severe bound always wins.
342        out.cpu_critical_percent = out.cpu_critical_percent.max(out.cpu_watch_percent);
343        out.psi_critical_percent = out.psi_critical_percent.max(out.psi_watch_percent);
344        out.disk_busy_critical_percent = out
345            .disk_busy_critical_percent
346            .max(out.disk_busy_watch_percent);
347        out.network_critical_percent = out.network_critical_percent.max(out.network_watch_percent);
348        out.load_critical_per_cpu = out.load_critical_per_cpu.max(out.load_watch_per_cpu);
349        out.swap_critical_bytes_per_second = out
350            .swap_critical_bytes_per_second
351            .max(out.swap_watch_bytes_per_second);
352        out.collector_lag_critical_intervals = out
353            .collector_lag_critical_intervals
354            .max(out.collector_lag_watch_intervals);
355        out.stale_critical_intervals = out.stale_critical_intervals.max(out.stale_watch_intervals);
356        // Memory thresholds are inverted: less available is worse, so the
357        // critical bound is the *lower* number.
358        out.memory_critical_available_percent = out
359            .memory_critical_available_percent
360            .min(out.memory_watch_available_percent);
361        // Counting ten samples out of a window of five is not a condition anyone
362        // can meet; widening the window keeps the requested severity.
363        out.sustained_window = out.sustained_window.max(out.sustained_samples);
364        out
365    }
366
367    /// How many observations must exist before any sustained claim is possible.
368    ///
369    /// Below this the signal is [`crate::MetricState::WarmingUp`] rather than
370    /// `normal`: a rule that needs ten samples has no opinion after three, and
371    /// §26 forbids dressing "no opinion" up as a measurement.
372    #[must_use]
373    pub const fn minimum_samples(&self) -> usize {
374        self.sustained_samples
375    }
376
377    /// The fast-tier collection budget as a duration (§16.1).
378    #[must_use]
379    pub const fn self_sample_budget(&self) -> Duration {
380        Duration::from_millis(self.self_sample_budget_millis)
381    }
382
383    /// The used-memory share equivalent to [`Self::memory_watch_available_percent`].
384    ///
385    /// History retains the *used* share (§8.5), so the available-share thresholds
386    /// have to be expressed in the same terms to be counted over a window.
387    #[must_use]
388    pub fn memory_watch_used_percent(&self) -> f32 {
389        (100.0 - self.memory_watch_available_percent).max(0.0)
390    }
391
392    /// The used-memory share equivalent to
393    /// [`Self::memory_critical_available_percent`].
394    #[must_use]
395    pub fn memory_critical_used_percent(&self) -> f32 {
396        (100.0 - self.memory_critical_available_percent).max(0.0)
397    }
398
399    /// Scales a sample interval by a multiplier without risking an overflow panic.
400    ///
401    /// [`Duration::mul_f32`] panics on overflow, which §14.3 forbids anywhere near
402    /// the render path, so the arithmetic is done in seconds and compared as such.
403    #[must_use]
404    pub fn intervals_as_seconds(interval: Duration, multiple: f32) -> f64 {
405        interval.as_secs_f64()
406            * f64::from(multiple.clamp(MIN_INTERVAL_MULTIPLE, MAX_INTERVAL_MULTIPLE))
407    }
408}
409
410/// Keeps a finite, non-negative percentage, falling back to the default.
411fn non_negative(value: f32, fallback: f32) -> f32 {
412    if value.is_finite() && value >= 0.0 {
413        value
414    } else {
415        fallback
416    }
417}
418
419/// Keeps a finite, non-negative rate, falling back to the default.
420fn non_negative_f64(value: f64, fallback: f64) -> f64 {
421    if value.is_finite() && value >= 0.0 {
422        value
423    } else {
424        fallback
425    }
426}
427
428/// Keeps an interval multiplier inside the range duration arithmetic tolerates.
429fn multiple(value: f32, fallback: f32) -> f32 {
430    if value.is_finite() {
431        value.clamp(MIN_INTERVAL_MULTIPLE, MAX_INTERVAL_MULTIPLE)
432    } else {
433        fallback
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    #[test]
442    fn the_defaults_are_the_numbers_section_twelve_documents() {
443        let thresholds = Thresholds::default();
444        assert!(thresholds.enabled);
445        assert!((thresholds.cpu_watch_percent - 80.0).abs() < f32::EPSILON);
446        assert!((thresholds.cpu_critical_percent - 95.0).abs() < f32::EPSILON);
447        assert!((thresholds.memory_watch_available_percent - 15.0).abs() < f32::EPSILON);
448        assert!((thresholds.memory_critical_available_percent - 5.0).abs() < f32::EPSILON);
449        assert_eq!(thresholds.sustained_samples, 10);
450    }
451
452    #[test]
453    fn the_defaults_are_already_sanitary() {
454        assert_eq!(Thresholds::default().sanitized(), Thresholds::default());
455    }
456
457    #[test]
458    fn a_window_narrower_than_the_sample_count_is_widened_not_ignored() {
459        // Counting "10 of the last 5" can never be satisfied, which would
460        // silently disable every sustained rule.
461        let thresholds = Thresholds {
462            sustained_samples: 10,
463            sustained_window: 5,
464            ..Thresholds::default()
465        }
466        .sanitized();
467        assert_eq!(thresholds.sustained_window, 10);
468        assert_eq!(thresholds.sustained_samples, 10);
469    }
470
471    #[test]
472    fn zero_samples_becomes_one_so_a_condition_still_has_to_be_observed() {
473        let thresholds = Thresholds {
474            sustained_samples: 0,
475            sustained_window: 0,
476            ..Thresholds::default()
477        }
478        .sanitized();
479        assert_eq!(thresholds.sustained_samples, 1);
480        assert_eq!(thresholds.sustained_window, 1);
481        assert_eq!(thresholds.minimum_samples(), 1);
482    }
483
484    #[test]
485    fn an_inverted_pair_of_thresholds_is_ordered_by_severity() {
486        let thresholds = Thresholds {
487            cpu_watch_percent: 95.0,
488            cpu_critical_percent: 40.0,
489            ..Thresholds::default()
490        }
491        .sanitized();
492        assert!(thresholds.cpu_critical_percent >= thresholds.cpu_watch_percent);
493    }
494
495    #[test]
496    fn memory_thresholds_are_inverted_so_critical_is_the_lower_share() {
497        let thresholds = Thresholds {
498            memory_watch_available_percent: 5.0,
499            memory_critical_available_percent: 15.0,
500            ..Thresholds::default()
501        }
502        .sanitized();
503        assert!(
504            thresholds.memory_critical_available_percent
505                <= thresholds.memory_watch_available_percent,
506            "less available memory must be the more severe state"
507        );
508    }
509
510    #[test]
511    fn non_finite_percentages_fall_back_to_the_documented_default() {
512        let thresholds = Thresholds {
513            cpu_watch_percent: f32::NAN,
514            psi_watch_percent: f32::INFINITY,
515            load_watch_per_cpu: -3.0,
516            swap_watch_bytes_per_second: f64::NAN,
517            ..Thresholds::default()
518        }
519        .sanitized();
520        assert!((thresholds.cpu_watch_percent - DEFAULT_CPU_WATCH_PERCENT).abs() < f32::EPSILON);
521        assert!(thresholds.psi_watch_percent.is_finite());
522        assert!(thresholds.load_watch_per_cpu >= 0.0);
523        assert!(thresholds.swap_watch_bytes_per_second.is_finite());
524    }
525
526    #[test]
527    fn interval_multiples_are_bounded_so_duration_arithmetic_cannot_overflow() {
528        let thresholds = Thresholds {
529            stale_watch_intervals: f32::MAX,
530            discontinuity_intervals: 0.0,
531            ..Thresholds::default()
532        }
533        .sanitized();
534        assert!(thresholds.stale_watch_intervals <= MAX_INTERVAL_MULTIPLE);
535        assert!(thresholds.discontinuity_intervals >= MIN_INTERVAL_MULTIPLE);
536
537        let seconds = Thresholds::intervals_as_seconds(Duration::from_secs(1), f32::MAX);
538        assert!(seconds.is_finite());
539    }
540
541    #[test]
542    fn memory_shares_convert_between_available_and_used() {
543        let thresholds = Thresholds::default();
544        assert!((thresholds.memory_watch_used_percent() - 85.0).abs() < f32::EPSILON);
545        assert!((thresholds.memory_critical_used_percent() - 95.0).abs() < f32::EPSILON);
546    }
547
548    #[test]
549    fn an_available_share_above_one_hundred_does_not_produce_a_negative_used_share() {
550        let thresholds = Thresholds {
551            memory_watch_available_percent: 400.0,
552            ..Thresholds::default()
553        };
554        assert!(thresholds.memory_watch_used_percent() >= 0.0);
555    }
556
557    #[test]
558    fn the_self_overhead_budgets_match_section_sixteen() {
559        let thresholds = Thresholds::default();
560        assert_eq!(thresholds.self_sample_budget(), Duration::from_millis(200));
561        assert_eq!(thresholds.self_rss_budget_bytes, 50 * 1024 * 1024);
562        assert!((thresholds.self_cpu_budget_percent - 2.0).abs() < f32::EPSILON);
563    }
564}