Skip to main content

navian_memcheck/
lib.rs

1//! # navian-memcheck
2//!
3//! **Resource-leak / soak testing as a `cargo test` assertion.**
4//!
5//! The bug that took down a 52 GB process in production was not a leak in the
6//! classic sense: every byte was *reachable*. A per-session map simply grew
7//! without bound because nothing ever evicted it. `LeakSanitizer` and Valgrind
8//! `memcheck` are blind to this — the memory is still referenced, so to them it
9//! is "in use", not "lost". A heap profiler would show it, but only if a human
10//! sat and eyeballed a flamegraph.
11//!
12//! `navian-memcheck` asserts the *property that was actually violated*: **after
13//! a warmup period, live memory PLATEAUS.** You drive a workload under sustained
14//! load; the crate samples live heap on a fixed cadence, fits a line through the
15//! back half of the run, and fails if the slope is still climbing or a hard cap
16//! is breached. It is a pass/fail check you drop into a test — no profiler, no
17//! flamegraph, no platform.
18//!
19//! ```no_run
20//! use navian_memcheck::{soak, SoakConfig};
21//!
22//! # fn process_one_event(_: u64) {}
23//! let report = soak(&SoakConfig::iterations(200_000), |i| {
24//!     process_one_event(i); // your real per-event work
25//! });
26//! report.assert(); // panics with a readable summary if memory kept growing
27//! ```
28//!
29//! ## Activation: one dependency, one test, zero code changes
30//!
31//! Add the crate and write one test. Nothing in your production code changes.
32//!
33//! ```toml
34//! [dev-dependencies]
35//! navian-memcheck = "0.1"
36//! ```
37//!
38//! The default [`RssSampler`] reads the OS, so there is no allocator to install
39//! and no global state to set up.
40//!
41//! ## Two surfaces
42//!
43//! - **In-process** ([`soak`], [`assert_bounded`], [`assert_linear_in`]) — drive a
44//!   workload closure and watch *this* process's memory. Runs in your test suite.
45//! - **Out-of-process** — the `navian-memcheck` CLI soaks *any* command's RSS over
46//!   a duration and gates CI on the same plateau property, with no code at all. See
47//!   the `navian-memcheck-cli` crate.
48//!
49//! ## Sampling precision (optional)
50//!
51//! The default reads process RSS — coarser (page-granular, includes allocator
52//! retention) but zero-setup. For a cleaner in-process signal, enable the
53//! `jemalloc` feature and pass `JemallocSampler` to [`soak_with`]; it reads
54//! jemalloc `stats.allocated` (live bytes, no page noise). That requires jemalloc
55//! to be the global allocator — one line, and free for services already on it:
56//!
57//! ```ignore
58//! #[global_allocator]
59//! static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
60//! ```
61//!
62//! ## Determinism
63//!
64//! Memory boundedness is a property, and like any property it is only trustworthy
65//! if the run that checks it is reproducible. Drive your workload from a seeded
66//! RNG (or under [`navian-dst`](https://github.com/TheFuturePlutus/navian-dst)) so
67//! that a soak which fails on seed *N* fails again, identically, on seed *N* — and
68//! so you can bisect the growth to the event that caused it.
69
70#![forbid(unsafe_code)]
71#![warn(missing_docs)]
72
73use std::fmt;
74
75// ─────────────────────────────────────────────────────────────────────────────
76// Sampler
77// ─────────────────────────────────────────────────────────────────────────────
78
79/// A source of "live memory, in bytes, right now".
80///
81/// Implementors return a monotonic-ish estimate of the memory the process is
82/// currently holding. Two implementations ship with the crate: [`RssSampler`]
83/// (always available) and `JemallocSampler` (with the `jemalloc` feature).
84pub trait Sampler {
85    /// Current live memory in bytes, or `None` if the reading FAILED (the platform
86    /// API was unavailable this call). `None` is NOT the same as `Some(0)`: a failed
87    /// read must not be recorded as zero bytes, which would depress peak/slope and
88    /// bias the run toward a false pass. A failed sample is skipped; if none ever
89    /// succeed the run is reported inconclusive. Should be cheap enough to call
90    /// thousands of times per run.
91    fn sample(&self) -> Option<u64>;
92    /// Short human label for what is being measured (shown in reports).
93    fn kind(&self) -> &'static str;
94}
95
96/// Process resident-set-size sampler. Always available, no allocator requirement,
97/// but coarser than jemalloc: page-granular and inflated by allocator retention.
98#[derive(Debug, Default, Clone, Copy)]
99pub struct RssSampler;
100
101impl Sampler for RssSampler {
102    fn sample(&self) -> Option<u64> {
103        // None (not 0) when the platform RSS API is unavailable, so a failed read
104        // is skipped rather than injected as a spurious zero.
105        memory_stats::memory_stats().map(|m| m.physical_mem as u64)
106    }
107    fn kind(&self) -> &'static str {
108        "rss"
109    }
110}
111
112/// In-process live-heap sampler backed by jemalloc `stats.allocated`.
113///
114/// Requires jemalloc to be the active global allocator (see the crate docs). The
115/// reading is the number of bytes currently handed out by the allocator — the
116/// cleanest signal for "did my data structures stop growing".
117#[cfg(feature = "jemalloc")]
118#[derive(Debug, Default, Clone, Copy)]
119pub struct JemallocSampler;
120
121#[cfg(feature = "jemalloc")]
122impl Sampler for JemallocSampler {
123    fn sample(&self) -> Option<u64> {
124        use tikv_jemalloc_ctl::{epoch, stats};
125        // `allocated` is only refreshed when the epoch is advanced.
126        let _ = epoch::advance();
127        // None (not 0) on a ctl read error, so a failed read is skipped.
128        stats::allocated::read().ok().map(|v| v as u64)
129    }
130    fn kind(&self) -> &'static str {
131        "jemalloc/allocated"
132    }
133}
134
135/// The sampler [`soak`] and friends use when you don't pass one explicitly.
136///
137/// This is [`RssSampler`] — it reads the OS and needs no allocator setup, so the
138/// zero-config path is just `cargo add navian-memcheck` plus one soak test. For a
139/// cleaner in-process signal, enable the `jemalloc` feature and pass
140/// `JemallocSampler` to [`soak_with`].
141pub type DefaultSampler = RssSampler;
142
143// ─────────────────────────────────────────────────────────────────────────────
144// Linear fit (ordinary least squares)
145// ─────────────────────────────────────────────────────────────────────────────
146
147/// Result of an ordinary-least-squares fit of `y = slope * x + intercept`.
148#[derive(Debug, Clone, Copy, PartialEq)]
149pub struct Fit {
150    /// Change in `y` per unit `x`.
151    pub slope: f64,
152    /// `y` at `x = 0`.
153    pub intercept: f64,
154    /// Coefficient of determination in `[0, 1]`; `1.0` is a perfect line.
155    pub r2: f64,
156}
157
158/// Least-squares fit of `ys` against `xs`. Returns a zero-slope fit when there
159/// are fewer than two points or `xs` has no spread.
160pub fn linear_fit(xs: &[f64], ys: &[f64]) -> Fit {
161    let n = xs.len().min(ys.len());
162    if n < 2 {
163        return Fit {
164            slope: 0.0,
165            intercept: ys.first().copied().unwrap_or(0.0),
166            r2: 1.0,
167        };
168    }
169    let nf = n as f64;
170    let mean_x = xs[..n].iter().sum::<f64>() / nf;
171    let mean_y = ys[..n].iter().sum::<f64>() / nf;
172    let mut sxx = 0.0;
173    let mut sxy = 0.0;
174    let mut syy = 0.0;
175    for i in 0..n {
176        let dx = xs[i] - mean_x;
177        let dy = ys[i] - mean_y;
178        sxx += dx * dx;
179        sxy += dx * dy;
180        syy += dy * dy;
181    }
182    if sxx == 0.0 {
183        return Fit {
184            slope: 0.0,
185            intercept: mean_y,
186            r2: 1.0,
187        };
188    }
189    let slope = sxy / sxx;
190    let intercept = mean_y - slope * mean_x;
191    // r2 = explained / total variance; guard the flat-y case.
192    let r2 = if syy == 0.0 {
193        1.0
194    } else {
195        (sxy * sxy) / (sxx * syy)
196    };
197    Fit {
198        slope,
199        intercept,
200        r2: r2.clamp(0.0, 1.0),
201    }
202}
203
204// ─────────────────────────────────────────────────────────────────────────────
205// Soak
206// ─────────────────────────────────────────────────────────────────────────────
207
208/// How to run a soak: how long, how often to sample, and what "still growing"
209/// and "too big" mean.
210#[derive(Debug, Clone, Copy)]
211pub struct SoakConfig {
212    /// Number of times the workload closure is invoked.
213    pub iterations: u64,
214    /// Sample live memory every `sample_every` iterations. Must be `>= 1`.
215    pub sample_every: u64,
216    /// Fraction of the run (by sample count) to *discard* as warmup before
217    /// measuring the plateau slope. `0.5` fits the line through the back half.
218    pub warmup_frac: f64,
219    /// Optional hard ceiling on peak live bytes. `None` disables the cap check.
220    pub max_bytes: Option<u64>,
221    /// Plateau budget: the largest back-half slope, in **bytes per sample**, that
222    /// still counts as "leveled off". Anything above this fails as `StillGrowing`.
223    pub slope_bytes_per_sample: f64,
224    /// Optional minimum r² of the back-half fit required before an over-budget
225    /// slope is treated as growth. **Off by default (`0.0`).**
226    ///
227    /// Raising it suppresses false `StillGrowing` on jittery RSS — but it is *not*
228    /// on by default and should be used with care: a slow leak buried in page-noise
229    /// also has a low r², so a high floor can mask the very bug this tool exists to
230    /// catch. Prefer tuning [`slope_bytes_per_sample`](Self::slope_bytes_per_sample)
231    /// above your platform's noise floor, or use the jemalloc sampler for a clean
232    /// signal, over relying on this.
233    pub min_r2_for_growth: f64,
234    /// Minimum bytes the live-memory reading must span (`peak - trough`) over the
235    /// run before a plateau is trusted. **Off by default (`0`).**
236    ///
237    /// A flat series is ambiguous: it is what a bounded workload looks like, but
238    /// also what a workload that never allocated — or a stuck sampler — looks like.
239    /// The tool can't tell which from the series, so by default it passes a flat
240    /// series and merely reports [`SoakReport::moved_bytes`]. Set this to the amount
241    /// you KNOW your workload should churn (domain knowledge the tool doesn't have),
242    /// and a run that moved less is reported [`Verdict::InsufficientSamples`] instead
243    /// of a hollow pass.
244    pub min_movement_bytes: u64,
245}
246
247impl SoakConfig {
248    /// A sensible default soak of `iterations` events: 200 samples, back-half
249    /// plateau check, ~4 KB/sample slope budget, no hard cap.
250    pub fn iterations(iterations: u64) -> Self {
251        let sample_every = (iterations / 200).max(1);
252        SoakConfig {
253            iterations,
254            sample_every,
255            warmup_frac: 0.5,
256            max_bytes: None,
257            slope_bytes_per_sample: 4096.0,
258            min_r2_for_growth: 0.0, // r² floor off by default — slope alone decides
259            min_movement_bytes: 0,  // movement enforcement off by default
260        }
261    }
262    /// Set the plateau slope budget in bytes per sample.
263    #[must_use]
264    pub fn slope_budget(mut self, bytes_per_sample: f64) -> Self {
265        self.slope_bytes_per_sample = bytes_per_sample;
266        self
267    }
268    /// Require the run to move at least `bytes` (`peak - trough`) before a plateau is
269    /// trusted; a run that moved less is reported [`Verdict::InsufficientSamples`].
270    /// See [`min_movement_bytes`](Self::min_movement_bytes).
271    #[must_use]
272    pub fn require_movement(mut self, bytes: u64) -> Self {
273        self.min_movement_bytes = bytes;
274        self
275    }
276    /// Set a hard ceiling on peak live bytes.
277    #[must_use]
278    pub fn max_bytes(mut self, cap: u64) -> Self {
279        self.max_bytes = Some(cap);
280        self
281    }
282    /// Set how many iterations pass between samples.
283    #[must_use]
284    pub fn sample_every(mut self, every: u64) -> Self {
285        self.sample_every = every.max(1);
286        self
287    }
288    /// Set the warmup fraction discarded before the plateau fit. The value is
289    /// clamped to `0.0..=0.95`.
290    #[must_use]
291    pub fn warmup_frac(mut self, frac: f64) -> Self {
292        self.warmup_frac = frac.clamp(0.0, 0.95);
293        self
294    }
295    /// Set the optional back-half r² floor required before an over-budget slope
296    /// counts as growth (clamped to `0.0..=1.0`; `0.0`, the default, disables it).
297    /// Advanced: see the caveat on [`SoakConfig::min_r2_for_growth`] — a high floor
298    /// can mask a slow, noisy leak.
299    #[must_use]
300    pub fn min_r2(mut self, r2: f64) -> Self {
301        self.min_r2_for_growth = r2.clamp(0.0, 1.0);
302        self
303    }
304}
305
306/// Why a soak passed or failed.
307#[derive(Debug, Clone, Copy, PartialEq)]
308pub enum Verdict {
309    /// Memory plateaued and stayed under any configured cap.
310    Pass,
311    /// Peak live bytes exceeded the configured hard cap.
312    ExceededCap {
313        /// Observed peak.
314        peak: u64,
315        /// Configured ceiling.
316        cap: u64,
317    },
318    /// Back-half slope exceeded the plateau budget — memory was still climbing.
319    StillGrowing {
320        /// Observed back-half slope, bytes per sample.
321        slope: f64,
322        /// Configured budget, bytes per sample.
323        budget: f64,
324    },
325    /// The run produced too little signal to judge — fewer than two samples, or
326    /// every sample read zero bytes (sampler unsupported on this platform). This
327    /// is **not** a pass; treat it as a failed run to investigate.
328    InsufficientSamples {
329        /// Human-readable reason the run was inconclusive.
330        reason: &'static str,
331    },
332}
333
334/// The outcome of a [`soak`] run: the raw samples plus the computed verdict.
335#[derive(Debug, Clone)]
336pub struct SoakReport {
337    /// `(iteration, live_bytes)` pairs, in order.
338    pub samples: Vec<(u64, u64)>,
339    /// Live bytes at the first sample (rough baseline).
340    pub baseline: u64,
341    /// Peak live bytes observed across the whole run.
342    pub peak: u64,
343    /// Total spread of live bytes observed: `peak - trough`. This is the evidence
344    /// that the run actually exercised memory. When it is `0` every sample read the
345    /// same value — the workload may never allocate (or never hit the suspected
346    /// path), or the sampler may be stuck — and a flat "plateau" then proves
347    /// nothing, so the verdict is [`Verdict::InsufficientSamples`], not a pass.
348    pub moved_bytes: u64,
349    /// Back-half slope in bytes per sample (the plateau signal).
350    pub back_half_slope: f64,
351    /// R² of the back-half fit (how line-like the tail was).
352    pub back_half_r2: f64,
353    /// What the sampler measured.
354    pub sampler_kind: &'static str,
355    /// Pass / fail and why.
356    pub verdict: Verdict,
357    /// Advisory warnings surfaced on a PASS that may be hollow — e.g. the run
358    /// barely moved, the slope budget is loose enough to hide a real leak, or the
359    /// plateau rests on too few samples. They do NOT change the verdict (defaults
360    /// keep the tool zero-config), but each names the knob to set so a green result
361    /// is earned, not assumed. Empty on a fail or a clearly-earned pass.
362    pub trust_warnings: Vec<String>,
363}
364
365impl SoakReport {
366    /// `true` if the verdict is [`Verdict::Pass`].
367    pub fn passed(&self) -> bool {
368        matches!(self.verdict, Verdict::Pass)
369    }
370
371    /// Panic with [`summary`](Self::summary) if the soak did not pass. The
372    /// idiomatic last line of a `#[test]`.
373    ///
374    /// # Panics
375    ///
376    /// Panics if the verdict is anything other than [`Verdict::Pass`] — i.e. the
377    /// memory kept growing, breached a cap, or the run was inconclusive.
378    pub fn assert(&self) {
379        assert!(self.passed(), "navian-memcheck: {}", self.summary());
380    }
381
382    /// One-line, human-readable result summary.
383    pub fn summary(&self) -> String {
384        let mb = |b: u64| b as f64 / (1024.0 * 1024.0);
385        let verdict = match self.verdict {
386            Verdict::Pass => "PASS — memory plateaued".to_string(),
387            Verdict::ExceededCap { peak, cap } => {
388                format!("FAIL — peak {:.1} MB exceeded cap {:.1} MB", mb(peak), mb(cap))
389            }
390            Verdict::StillGrowing { slope, budget } => format!(
391                "FAIL — still growing: back-half slope {slope:.0} B/sample > budget {budget:.0} B/sample"
392            ),
393            Verdict::InsufficientSamples { reason } => {
394                format!("INCONCLUSIVE — {reason}")
395            }
396        };
397        let mut out = format!(
398            "{verdict} [sampler={}, samples={}, baseline={:.1} MB, peak={:.1} MB, moved={:.1} MB, slope={:.0} B/sample, r2={:.2}]",
399            self.sampler_kind,
400            self.samples.len(),
401            mb(self.baseline),
402            mb(self.peak),
403            mb(self.moved_bytes),
404            self.back_half_slope,
405            self.back_half_r2,
406        );
407        for warn in &self.trust_warnings {
408            out.push_str("\n  ⚠ trust: ");
409            out.push_str(warn);
410        }
411        out
412    }
413}
414
415impl fmt::Display for SoakReport {
416    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
417        f.write_str(&self.summary())
418    }
419}
420
421/// Run `work` for `cfg.iterations`, sampling live heap with the [`DefaultSampler`],
422/// and return a [`SoakReport`]. See [`soak_with`] to supply a sampler.
423pub fn soak(cfg: &SoakConfig, work: impl FnMut(u64)) -> SoakReport {
424    soak_with(cfg, DefaultSampler::default(), work)
425}
426
427/// One-line convenience: soak `iterations` of `work` with default settings and
428/// assert the result plateaus. Equivalent to
429/// `soak(&SoakConfig::iterations(iterations), work).assert()`.
430///
431/// ```no_run
432/// # fn process_one_event(_: u64) {}
433/// navian_memcheck::assert_plateau(200_000, |i| process_one_event(i));
434/// ```
435///
436/// # Panics
437///
438/// Panics if memory did not plateau (see [`SoakReport::assert`]).
439pub fn assert_plateau(iterations: u64, work: impl FnMut(u64)) {
440    soak(&SoakConfig::iterations(iterations), work).assert();
441}
442
443/// Like [`soak`] but with an explicit [`Sampler`].
444// The sampler impls are zero-sized; taking one by value reads naturally at the
445// call site (`soak_with(cfg, RssSampler, work)`) and costs nothing.
446#[allow(clippy::needless_pass_by_value)]
447pub fn soak_with<S: Sampler>(
448    cfg: &SoakConfig,
449    sampler: S,
450    mut work: impl FnMut(u64),
451) -> SoakReport {
452    let every = cfg.sample_every.max(1);
453    let mut samples: Vec<(u64, u64)> = Vec::with_capacity((cfg.iterations / every) as usize + 1);
454
455    for i in 0..cfg.iterations {
456        work(i);
457        // Skip a FAILED read rather than record it as 0 — a spurious zero would
458        // depress peak/slope and bias the run toward a false pass.
459        if (i + 1) % every == 0 {
460            if let Some(bytes) = sampler.sample() {
461                samples.push((i + 1, bytes));
462            }
463        }
464    }
465    // Guarantee at least one tail sample even if iterations isn't a multiple of `every`
466    // (only when the tail read succeeds).
467    if cfg.iterations > 0 && samples.last().map(|(it, _)| *it) != Some(cfg.iterations) {
468        if let Some(bytes) = sampler.sample() {
469            samples.push((cfg.iterations, bytes));
470        }
471    }
472
473    finalize(cfg, samples, sampler.kind())
474}
475
476/// Build a [`SoakReport`] from samples collected *outside* this process — e.g.
477/// the CLI polling another process's RSS. Pass `(tick, bytes)` pairs with one
478/// tick per sample and set `cfg.sample_every == 1` so the slope is reported in
479/// bytes-per-sample.
480pub fn report_from_samples(
481    cfg: &SoakConfig,
482    samples: Vec<(u64, u64)>,
483    sampler_kind: &'static str,
484) -> SoakReport {
485    finalize(cfg, samples, sampler_kind)
486}
487
488/// Compute peak, back-half slope, and verdict from collected samples.
489fn finalize(cfg: &SoakConfig, samples: Vec<(u64, u64)>, sampler_kind: &'static str) -> SoakReport {
490    let baseline = samples.first().map_or(0, |(_, b)| *b);
491    let peak = samples.iter().map(|(_, b)| *b).max().unwrap_or(0);
492    let trough = samples.iter().map(|(_, b)| *b).min().unwrap_or(0);
493    // Spread over the WHOLE run — the evidence the workload exercised memory at all.
494    let moved_bytes = peak.saturating_sub(trough);
495
496    // Guard the DEGENERATE runs that can't be fit at all. Everything else (cap
497    // breach, growth, movement) is decided AFTER the line fit below, in priority
498    // order, so the fitted slope/r² is real on every non-degenerate report and the
499    // movement guard can only ever downgrade a would-be PASS — never a detected
500    // leak (`StillGrowing`) or a cap breach.
501    let inconclusive_reason = if samples.len() < 2 {
502        Some("fewer than 2 samples collected")
503    } else if peak == 0 {
504        Some("every sample read 0 bytes — is the sampler supported on this platform?")
505    } else {
506        None
507    };
508    if let Some(reason) = inconclusive_reason {
509        return SoakReport {
510            samples,
511            baseline,
512            peak,
513            moved_bytes,
514            back_half_slope: 0.0,
515            back_half_r2: 0.0,
516            sampler_kind,
517            verdict: Verdict::InsufficientSamples { reason },
518            trust_warnings: Vec::new(),
519        };
520    }
521
522    // Sanitize thresholds here (not just in the builders) so a struct-literal
523    // SoakConfig can never *disable* detection with a non-finite value.
524    let budget = if cfg.slope_bytes_per_sample.is_finite() {
525        cfg.slope_bytes_per_sample
526    } else {
527        0.0 // a non-finite budget errs toward FAIL, never toward a silent pass
528    };
529    let min_r2 = if cfg.min_r2_for_growth.is_finite() {
530        cfg.min_r2_for_growth.clamp(0.0, 1.0)
531    } else {
532        0.0
533    };
534    let warmup = if cfg.warmup_frac.is_finite() {
535        cfg.warmup_frac.clamp(0.0, 0.95)
536    } else {
537        0.5
538    };
539    // Fit a line through the back half (or configured tail) of the samples.
540    let n = samples.len();
541    let start = ((n as f64) * warmup).floor() as usize;
542    let start = start.min(n.saturating_sub(2)); // keep at least 2 points if we can
543    let tail = &samples[start..];
544    // Fit against sample INDEX (0,1,2,…), NOT the caller-supplied ticks. For the
545    // uniform cadence of an internal soak this is identical to fitting the ticks and
546    // rescaling to bytes-per-sample, but it is robust on the public sample-based API:
547    // duplicate ticks (a zero x-range) would fit slope 0 and FALSE-PASS growing
548    // memory, and non-monotonic ticks would underflow the unsigned offset. Index x
549    // sidesteps both — and bytes-per-sample is exactly the cadence-independent metric
550    // we want.
551    let xs: Vec<f64> = (0..tail.len()).map(|i| i as f64).collect();
552    let ys: Vec<f64> = tail.iter().map(|(_, b)| *b as f64).collect();
553    let fit = linear_fit(&xs, &ys);
554    let slope_per_sample = fit.slope;
555
556    // Verdict in PRIORITY order, so a definitive failure always beats "inconclusive":
557    //   1. cap breach — too much memory, a FAIL regardless of shape or movement;
558    //   2. growth — slope over budget is a detected leak (a FAIL), and must NOT be
559    //      downgraded to "inconclusive" just because total movement was small;
560    //   3. insufficient movement — only NOW, for a would-be PASS, does the opt-in
561    //      `require_movement` guard apply: a flat run that never moved enough proves
562    //      nothing, so it is inconclusive rather than a hollow pass;
563    //   4. otherwise the memory plateaued → PASS.
564    //
565    // Growth uses the optional r² floor (off by default): a slow leak buried in
566    // page-noise has a low r², so gating on r² by default would mask the exact bug
567    // this tool exists to catch. The budget is the noise knob; for a clean signal
568    // use the jemalloc sampler.
569    let verdict = if let Some(cap) = cfg.max_bytes.filter(|&c| peak > c) {
570        Verdict::ExceededCap { peak, cap }
571    } else if slope_per_sample > budget && fit.r2 >= min_r2 {
572        Verdict::StillGrowing {
573            slope: slope_per_sample,
574            budget,
575        }
576    } else if cfg.min_movement_bytes > 0 && moved_bytes < cfg.min_movement_bytes {
577        Verdict::InsufficientSamples {
578            reason: "memory moved less than the required minimum — the workload may \
579                     not allocate (or never hit the suspected path), or the sampler \
580                     may be stuck. A flat series that never moved proves nothing.",
581        }
582    } else {
583        Verdict::Pass
584    };
585
586    // On a PASS, flag the ways it might be hollow so a green is earned, not assumed.
587    // These are advisory (the verdict stands) but each names the knob to set.
588    let trust_warnings = if matches!(verdict, Verdict::Pass) {
589        pass_trust_warnings(peak, moved_bytes, cfg.min_movement_bytes, budget, tail.len())
590    } else {
591        Vec::new()
592    };
593
594    SoakReport {
595        samples,
596        baseline,
597        peak,
598        moved_bytes,
599        back_half_slope: slope_per_sample,
600        back_half_r2: fit.r2,
601        sampler_kind,
602        verdict,
603        trust_warnings,
604    }
605}
606
607/// Warnings for a PASS that may not be trustworthy. Each fires on a heuristic that
608/// a green could be hollow, and names the exact knob to make it meaningful. All are
609/// advisory — the defaults keep the tool zero-config; these push you to earn the
610/// pass. `back_half_len` is the number of samples the plateau slope was fit over.
611fn pass_trust_warnings(
612    peak: u64,
613    moved_bytes: u64,
614    min_movement_bytes: u64,
615    budget: f64,
616    back_half_len: usize,
617) -> Vec<String> {
618    let mb = |b: f64| b / (1024.0 * 1024.0);
619    let mut w = Vec::new();
620
621    // 1) Barely moved: a flat run can hide a workload that never allocated or a
622    //    stuck sampler. Only nag when the caller has NOT already set the hard knob.
623    if min_movement_bytes == 0 && moved_bytes.saturating_mul(20) < peak {
624        w.push(format!(
625            "memory moved only {:.1} MB across the run ({:.1} MB peak) — a nearly-flat \
626             run can hide a workload that never exercised the leak path, or a stuck \
627             sampler. Set require_movement / --min-movement to the churn you expect.",
628            mb(moved_bytes as f64),
629            mb(peak as f64),
630        ));
631    }
632
633    // 2) Loose budget: the tolerated cumulative growth over the fitted window is as
634    //    large as the whole peak, so a real leak up to that size would still pass.
635    //    (Threshold is the full peak, not half, to avoid crying wolf on every pass.)
636    let tolerated = budget * back_half_len as f64;
637    if peak > 0 && tolerated >= peak as f64 {
638        w.push(format!(
639            "slope budget tolerates ~{:.1} MB of growth over this window (~{:.0}% of the \
640             {:.1} MB peak) — a leak up to that size would still pass. Tighten \
641             slope_budget / --slope-budget toward your platform's noise floor.",
642            mb(tolerated),
643            (tolerated / peak as f64) * 100.0,
644            mb(peak as f64),
645        ));
646    }
647
648    // 3) Too few points: a short tail fits a line trivially (2 points → r²=1) and a
649    //    slow leak may not have ramped yet.
650    if back_half_len < 8 {
651        w.push(format!(
652            "plateau rests on only {back_half_len} back-half sample(s) — too few to \
653             trust; a slow leak may not have ramped yet. Soak longer or sample more \
654             (more iterations / smaller --interval) so the tail has >= 8 points.",
655        ));
656    }
657
658    // NOTE: a step/sawtooth leak that sits flat WITHIN the fitted tail between steps
659    // is not flagged here — a clean heuristic is elusive (a legitimate plateau also
660    // ends at its peak level, and ties make peak-position useless). The whole-run
661    // `moved_bytes` is reported so a large spread under a flat slope is at least
662    // visible for inspection; a robust monotonic-trend statistic is a future add.
663
664    w
665}
666
667// ─────────────────────────────────────────────────────────────────────────────
668// Bounded-state assertions
669// ─────────────────────────────────────────────────────────────────────────────
670
671/// Assert that a size metric never exceeds `cap` as a driver sweeps over
672/// `drivers`. Use this to prove a structure is bounded regardless of input scale
673/// — e.g. "no matter how many distinct sessions arrive, the session map holds at
674/// most `cap` entries."
675///
676/// # Panics
677///
678/// Panics on the first driver value whose measurement exceeds `cap`.
679pub fn assert_bounded<I>(cap: u64, drivers: I, mut measure: impl FnMut(u64) -> u64)
680where
681    I: IntoIterator<Item = u64>,
682{
683    for d in drivers {
684        let got = measure(d);
685        assert!(
686            got <= cap,
687            "navian-memcheck: bound violated at driver={d}: measured {got} > cap {cap}"
688        );
689    }
690}
691
692/// Fit `(driver, bytes)` growth points and return the [`Fit`]. `slope` is bytes
693/// per unit of driver; a bounded-per-item structure has a small, stable slope and
694/// high `r2`.
695pub fn fit_growth(points: &[(u64, u64)]) -> Fit {
696    // Offset x by the MINIMUM driver value (not the first) to preserve f64 precision
697    // on large inputs AND stay panic-free on unsorted/non-monotonic drivers — a first
698    // offset would underflow the unsigned subtraction (debug panic / release wrap to a
699    // huge x, corrupting the fit) whenever a later driver is smaller than the first.
700    let x0 = points.iter().map(|(d, _)| *d).min().unwrap_or(0);
701    let xs: Vec<f64> = points.iter().map(|(d, _)| (d - x0) as f64).collect();
702    let ys: Vec<f64> = points.iter().map(|(_, b)| *b as f64).collect();
703    linear_fit(&xs, &ys)
704}
705
706/// Assert that memory grows *at most linearly* in a driver and no faster than
707/// `max_bytes_per_unit`. Catches super-linear blowups (e.g. an accidental O(n²)
708/// retained buffer) that a single-point check would miss.
709///
710/// # Panics
711///
712/// Panics if the fitted slope exceeds `max_bytes_per_unit`.
713pub fn assert_linear_in(points: &[(u64, u64)], max_bytes_per_unit: f64) -> Fit {
714    // A line needs ≥2 points; fewer can't measure a slope, and a non-finite or
715    // negative budget would silently DISABLE the assertion. Reject both loudly rather
716    // than let a misuse turn into a false pass.
717    assert!(
718        points.len() >= 2,
719        "navian-memcheck: assert_linear_in needs at least 2 points, got {}",
720        points.len()
721    );
722    // Distinct driver values are required, not just ≥2 points: with no x-spread the
723    // least-squares slope collapses to 0 and would FALSE-PASS any growth. Reject it.
724    let x_min = points.iter().map(|(d, _)| *d).min().unwrap();
725    let x_max = points.iter().map(|(d, _)| *d).max().unwrap();
726    assert!(
727        x_max > x_min,
728        "navian-memcheck: assert_linear_in needs at least two distinct driver values (all were {x_min})"
729    );
730    assert!(
731        max_bytes_per_unit.is_finite() && max_bytes_per_unit >= 0.0,
732        "navian-memcheck: max_bytes_per_unit must be finite and non-negative, got {max_bytes_per_unit}"
733    );
734    let fit = fit_growth(points);
735    assert!(
736        fit.slope <= max_bytes_per_unit,
737        "navian-memcheck: growth too steep: {:.1} B/unit > budget {:.1} B/unit (r2={:.2})",
738        fit.slope,
739        max_bytes_per_unit,
740        fit.r2
741    );
742    fit
743}
744
745#[cfg(test)]
746mod tests {
747    use super::*;
748
749    #[test]
750    fn linear_fit_recovers_slope() {
751        let xs = [0.0, 1.0, 2.0, 3.0, 4.0];
752        let ys = [1.0, 3.0, 5.0, 7.0, 9.0]; // y = 2x + 1
753        let fit = linear_fit(&xs, &ys);
754        assert!((fit.slope - 2.0).abs() < 1e-9);
755        assert!((fit.intercept - 1.0).abs() < 1e-9);
756        assert!((fit.r2 - 1.0).abs() < 1e-9);
757    }
758
759    #[test]
760    fn duplicate_ticks_do_not_hide_growth() {
761        // Every sample carries the SAME tick (0). Fitting against ticks would give a
762        // zero x-range → slope 0 → FALSE PASS. Fitting against sample index catches it.
763        let samples: Vec<(u64, u64)> = (0..12)
764            .map(|i| (0u64, 100_000_000 + i * 10_000_000))
765            .collect();
766        let r = report_from_samples(&SoakConfig::iterations(200), samples, "test");
767        assert!(
768            matches!(r.verdict, Verdict::StillGrowing { .. }),
769            "duplicate ticks must not mask growth, got {:?}",
770            r.verdict
771        );
772    }
773
774    #[test]
775    fn non_monotonic_ticks_do_not_panic_and_still_detect() {
776        // Decreasing/unsorted ticks previously underflowed the unsigned offset
777        // (debug panic / release wrap). Index-based fit is immune; growth by index
778        // is still flagged.
779        let samples: Vec<(u64, u64)> = vec![
780            (100, 100_000_000),
781            (50, 110_000_000),
782            (200, 120_000_000),
783            (10, 130_000_000),
784            (150, 140_000_000),
785            (5, 150_000_000),
786        ];
787        let r = report_from_samples(&SoakConfig::iterations(200), samples, "test");
788        assert!(matches!(r.verdict, Verdict::StillGrowing { .. }));
789    }
790
791    #[test]
792    fn flat_after_warmup_passes() {
793        // Guard against a false FAIL: a series that RISES during warmup then levels
794        // off is a genuine plateau (moved > 0, back-half slope ~0) → PASS.
795        let mut samples: Vec<(u64, u64)> = vec![(0, 90_000_000)];
796        samples.extend((1..12).map(|i| (i, 100_000_000)));
797        let r = report_from_samples(&SoakConfig::iterations(200), samples, "test");
798        assert!(matches!(r.verdict, Verdict::Pass), "flat-after-warmup must pass, got {:?}", r.verdict);
799    }
800
801    #[test]
802    fn constant_series_passes_by_default_but_reports_zero_movement() {
803        // By default a flat constant series PASSES (a bounded workload looks flat),
804        // but its zero movement is visible so a green isn't blindly trusted.
805        let samples: Vec<(u64, u64)> = (0..50).map(|i| (i, 10_000_000)).collect();
806        let r = report_from_samples(&SoakConfig::iterations(50), samples.clone(), "test");
807        assert!(matches!(r.verdict, Verdict::Pass), "flat passes by default, got {:?}", r.verdict);
808        assert_eq!(r.moved_bytes, 0, "but zero movement is reported");
809    }
810
811    #[test]
812    fn hollow_pass_raises_trust_warnings() {
813        // A flat, near-zero-movement series over a short tail PASSES by default but
814        // must carry advisory warnings pointing at the knobs.
815        let samples: Vec<(u64, u64)> = (0..12).map(|i| (i, 10_000_000)).collect();
816        let r = report_from_samples(&SoakConfig::iterations(12).sample_every(1), samples, "test");
817        assert!(matches!(r.verdict, Verdict::Pass));
818        assert!(!r.trust_warnings.is_empty(), "hollow pass must warn");
819        // moved 0 → the movement warning fires.
820        assert!(r.trust_warnings.iter().any(|w| w.contains("moved only")));
821    }
822
823    #[test]
824    fn earned_pass_has_no_trust_warnings() {
825        // Real movement, tight budget, plenty of samples → a clean pass, no nags.
826        let mut samples: Vec<(u64, u64)> = (0..20).map(|i| (i, 5_000_000 + i * 250_000)).collect();
827        samples.extend((20..80).map(|i| (i, 10_000_000)));
828        let cfg = SoakConfig::iterations(80).sample_every(1).slope_budget(1024.0);
829        let r = report_from_samples(&cfg, samples, "test");
830        assert!(matches!(r.verdict, Verdict::Pass), "{}", r.summary());
831        assert!(
832            r.trust_warnings.is_empty(),
833            "earned pass should not warn, got: {:?}",
834            r.trust_warnings
835        );
836    }
837
838    #[test]
839    fn fail_carries_no_trust_warnings() {
840        // Warnings are a PASS concept; a growing series just fails.
841        let samples: Vec<(u64, u64)> = (0..80).map(|i| (i, 1_000_000 + i * 500_000)).collect();
842        let cfg = SoakConfig::iterations(80).sample_every(1).slope_budget(4096.0);
843        let r = report_from_samples(&cfg, samples, "test");
844        assert!(matches!(r.verdict, Verdict::StillGrowing { .. }));
845        assert!(r.trust_warnings.is_empty());
846    }
847
848    #[test]
849    fn a_detected_leak_is_not_downgraded_by_require_movement() {
850        // A clearly-growing series whose TOTAL spread is below the movement floor
851        // must still FAIL as StillGrowing — a detected leak is never downgraded to
852        // "inconclusive". (Growth is decided before the movement guard.)
853        let samples: Vec<(u64, u64)> = (0..200).map(|i| (i, 1_000_000 + i * 400_000)).collect();
854        let cfg = SoakConfig::iterations(200)
855            .sample_every(1)
856            .slope_budget(4096.0)
857            .require_movement(10_000_000_000); // absurdly high floor
858        let r = report_from_samples(&cfg, samples, "test");
859        assert!(
860            matches!(r.verdict, Verdict::StillGrowing { .. }),
861            "a leak must fail, not go inconclusive; got {:?}",
862            r.verdict
863        );
864    }
865
866    #[test]
867    fn cap_breach_carries_real_slope_and_beats_growth() {
868        // A rising, over-cap series → ExceededCap (cap beats growth), and the report
869        // now carries the real fitted slope (not a hard-coded 0).
870        let samples: Vec<(u64, u64)> = (0..50).map(|i| (i, 20_000_000 + i * 1_000_000)).collect();
871        let cfg = SoakConfig::iterations(50).sample_every(1).max_bytes(10_000_000);
872        let r = report_from_samples(&cfg, samples, "test");
873        assert!(matches!(r.verdict, Verdict::ExceededCap { .. }));
874        assert!(r.back_half_slope > 0.0, "cap report should carry the real slope");
875    }
876
877    #[test]
878    fn single_over_cap_sample_is_inconclusive_not_cap() {
879        // A 1-sample run can't be fit; the degenerate guard wins over the cap check
880        // (restores the pre-reorder behavior for sub-2-sample runs).
881        let samples = vec![(0u64, 50_000_000u64)];
882        let cfg = SoakConfig::iterations(1).max_bytes(10_000_000);
883        let r = report_from_samples(&cfg, samples, "test");
884        assert!(matches!(r.verdict, Verdict::InsufficientSamples { .. }), "got {:?}", r.verdict);
885    }
886
887    #[test]
888    fn require_movement_makes_a_flat_run_inconclusive() {
889        // Opt in: a run that moved less than required is inconclusive, not a hollow
890        // pass — the domain threshold is the caller's to set.
891        let samples: Vec<(u64, u64)> = (0..50).map(|i| (i, 10_000_000)).collect();
892        let cfg = SoakConfig::iterations(50).require_movement(1_000_000);
893        let r = report_from_samples(&cfg, samples, "test");
894        assert!(
895            matches!(r.verdict, Verdict::InsufficientSamples { .. }),
896            "require_movement must make a zero-movement run inconclusive, got {:?}",
897            r.verdict
898        );
899    }
900
901    #[test]
902    fn fit_growth_handles_unsorted_drivers() {
903        // Must not panic on unsorted/non-monotonic drivers (min-offset, not first).
904        let fit = fit_growth(&[(100, 1), (1, 2), (50, 3), (0, 4)]);
905        assert!(fit.slope.is_finite());
906    }
907
908    #[test]
909    #[should_panic(expected = "at least 2 points")]
910    fn assert_linear_in_rejects_too_few_points() {
911        assert_linear_in(&[(1, 100)], 1.0);
912    }
913
914    #[test]
915    #[should_panic(expected = "finite and non-negative")]
916    fn assert_linear_in_rejects_infinite_budget() {
917        assert_linear_in(&[(1, 100), (2, 200)], f64::INFINITY);
918    }
919
920    #[test]
921    #[should_panic(expected = "distinct driver")]
922    fn assert_linear_in_rejects_no_driver_spread() {
923        // Two points, same driver → zero x-spread → slope 0 → would false-pass.
924        assert_linear_in(&[(7, 0), (7, 1_000_000_000)], 0.0);
925    }
926
927    #[test]
928    fn flat_after_warmup_is_a_plateau() {
929        // Ramps for the first 10 samples, then flat for 90 — a real plateau with
930        // movement, back-half slope ~0.
931        let mut samples: Vec<(u64, u64)> = (0..10).map(|i| (i, 9_000_000 + i * 100_000)).collect();
932        samples.extend((10..100).map(|i| (i, 10_000_000)));
933        let cfg = SoakConfig::iterations(100);
934        let report = finalize(&cfg, samples, "test");
935        assert!(report.passed(), "{}", report.summary());
936        assert!(report.back_half_slope.abs() < 1.0);
937        assert!(report.moved_bytes > 0);
938    }
939
940    #[test]
941    fn rising_series_still_growing() {
942        // +100 KB per sample, forever.
943        let samples: Vec<(u64, u64)> = (0..100).map(|i| (i, 1_000_000 + i * 100_000)).collect();
944        let cfg = SoakConfig::iterations(100)
945            .sample_every(1)
946            .slope_budget(4096.0);
947        let report = finalize(&cfg, samples, "test");
948        assert!(!report.passed());
949        matches!(report.verdict, Verdict::StillGrowing { .. });
950    }
951
952    #[test]
953    fn cap_breach_beats_slope_check() {
954        let samples: Vec<(u64, u64)> = (0..50).map(|i| (i, 50_000_000)).collect();
955        let cfg = SoakConfig::iterations(50).max_bytes(10_000_000);
956        let report = finalize(&cfg, samples, "test");
957        assert!(matches!(report.verdict, Verdict::ExceededCap { .. }));
958    }
959
960    #[test]
961    fn assert_linear_accepts_bounded_growth() {
962        // 64 bytes per entry, exactly linear.
963        let pts: Vec<(u64, u64)> = (0..1000).step_by(50).map(|n| (n, n * 64)).collect();
964        let fit = assert_linear_in(&pts, 128.0);
965        assert!((fit.slope - 64.0).abs() < 1.0);
966    }
967
968    #[test]
969    #[should_panic(expected = "bound violated")]
970    fn assert_bounded_catches_unbounded() {
971        // measured == driver, so it blows past a fixed cap.
972        assert_bounded(500, (0..1000u64).step_by(100), |d| d);
973    }
974}