ktstr 0.25.0

Test harness for Linux process schedulers
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
//! schbench's fio-derived log2 latency histogram + percentile extraction,
//! ported bit-faithfully from `schbench.c` (`plat_val_to_idx` :438,
//! `plat_idx_to_val` :476, `calc_percentiles` :502, `add_lat` :743,
//! `combine_stats` :730). schbench's reported numbers ARE this exact
//! bucketing + percentile math, so it is reproduced here rather than
//! approximated with ktstr's sampling reservoir or a generic percentile
//! crate — fidelity is the whole point of schbench_rs.
//!
//! Per-phase use: [`PlatStats::take`] snapshots-and-resets the histogram at
//! a phase boundary so each ktstr phase (e.g. scx vs detached-EEVDF)
//! reports isolated percentiles.

/// Histogram tuning, identical to `schbench.c:34-37`.
const PLAT_BITS: u32 = 8;
/// `1 << PLAT_BITS` = 256 buckets per log group.
const PLAT_VAL: u32 = 1 << PLAT_BITS;
/// Number of log groups (`schbench.c:36`).
const PLAT_GROUP_NR: usize = 19;
/// Total bucket count: `19 * 256 = 4864` (`schbench.c:37`).
const PLAT_NR: usize = PLAT_GROUP_NR * PLAT_VAL as usize;

/// The percentiles schbench computes (`schbench.c:132`). Latency tables
/// print {50, 90, 99, 99.9}, RPS prints {20, 50, 90}; both are subsets
/// read back via [`Percentiles::value_at`].
pub(crate) const PLIST: [f64; 5] = [20.0, 50.0, 90.0, 99.0, 99.9];

/// Map a value to its histogram bucket index. Faithful port of
/// `schbench.c` `plat_val_to_idx` (:438): values whose MSB is within
/// `PLAT_BITS` index directly; larger values discard `error_bits` low bits
/// and land in a log-group bucket, clamped to the last bucket.
fn plat_val_to_idx(val: u32) -> usize {
    // C: `msb = 31 - __builtin_clz(val)`, with `clz(0)` UB-guarded to
    // `msb = 0`. Rust `u32::leading_zeros(0) == 32` would underflow, so
    // guard `val == 0` explicitly (the documented divergence point).
    let msb = if val == 0 {
        0
    } else {
        31 - val.leading_zeros()
    };

    // MSB within PLAT_BITS: every bit is significant, index == value.
    if msb <= PLAT_BITS {
        return val as usize;
    }

    let error_bits = msb - PLAT_BITS;
    let base = (error_bits + 1) << PLAT_BITS;
    let offset = (PLAT_VAL - 1) & (val >> error_bits);
    ((base + offset) as usize).min(PLAT_NR - 1)
}

/// Map a bucket index back to its representative (mean-of-range) value.
/// Faithful port of `schbench.c` `plat_idx_to_val` (:476), computing
/// `base + (k + 0.5) * (1 << error_bits)` in `f64`. For every index that
/// reaches this formula (`idx >= 2*PLAT_VAL`, so `error_bits >= 1`),
/// `(k + 0.5) * 2^error_bits = k*2^e + 2^(e-1)` is always integral and
/// exactly f64-representable — the cast truncates NOTHING, it only mirrors
/// C's `(unsigned int)` return cast. (The only `.5`-bearing case,
/// `error_bits == 0`, maps to indices 256..511, which the identity branch
/// returns before this point.)
fn plat_idx_to_val(idx: usize) -> u32 {
    debug_assert!(idx < PLAT_NR, "bucket index {idx} out of range");

    // idx < 2*PLAT_VAL: identity range (`schbench.c:487`).
    if idx < (PLAT_VAL as usize) << 1 {
        return idx as u32;
    }

    let error_bits = (idx >> PLAT_BITS) as u32 - 1;
    let base = 1u32 << (error_bits + PLAT_BITS);
    let k = (idx % PLAT_VAL as usize) as f64;
    // The f64 product is integral here (error_bits >= 1), so this cast
    // loses no precision; it mirrors C's `(unsigned int)` return cast.
    (base as f64 + (k + 0.5) * (1u32 << error_bits) as f64) as u32
}

/// schbench's per-stat latency histogram (`schbench.c` `struct stats`
/// :112). The ~19 KiB `plat` array is HEAP-boxed (`Box<[u32; PLAT_NR]>`), not
/// inline: this type is the per-phase wire carrier and rides inside
/// `PhaseSlice` / `PhaseCgroupStats`, which serde constructs on the stack
/// during (de)serialization — an inline 19 KiB array (×3 in
/// `SchbenchPhaseStats`) overflows the bounded test/worker thread stack in
/// serde's recursive descent. Boxing keeps the struct pointer-sized; the
/// behavior is byte-identical to schbench's inline `unsigned int plat[]`.
///
/// `Serialize`/`Deserialize` + `Eq` make it the carrier. Every field is
/// integer, so the histogram re-pools across workers via [`PlatStats::combine`]
/// (bucket-count addition) host-side — percentiles are NEVER averaged, the only
/// correct cross-worker pooling. The `plat` array uses a `#[serde(with)]`
/// adapter because serde has no array impl past length 32.
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub(crate) struct PlatStats {
    #[serde(with = "plat_array_serde")]
    plat: Box<[u32; PLAT_NR]>,
    nr_samples: u64,
    max: u32,
    /// `0` is the unset sentinel (`schbench.c` `add_lat`/`combine_stats`),
    /// so a genuine 0-usec sample does NOT update `min`.
    min: u32,
}

/// serde adapter for [`PlatStats`]'s fixed `[u32; PLAT_NR]` histogram. serde
/// 1.x has no `Serialize`/`Deserialize` for arrays longer than 32 and this
/// crate does not depend on `serde_big_array`, so the array is carried as a
/// length-prefixed sequence. Deserialization is FAIL-LOUD on a wrong length: a
/// truncated or over-long payload is a corrupt carrier, never silently padded
/// or clipped (the no-silent-wrong-answer rule).
mod plat_array_serde {
    use super::PLAT_NR;
    use serde::Deserialize;
    use serde::de::Error as _;

    // `&Box<[u32; PLAT_NR]>` (not `&[u32; PLAT_NR]`) is mandated by serde's
    // `#[serde(with)]` contract: the field type is `Box<[u32; PLAT_NR]>`, so
    // `serialize` receives `&FieldType`. clippy's borrowed_box does not apply.
    #[allow(clippy::borrowed_box)]
    pub(super) fn serialize<S>(arr: &Box<[u32; PLAT_NR]>, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        serializer.collect_seq(arr.iter())
    }

    pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<Box<[u32; PLAT_NR]>, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let v: Vec<u32> = Vec::deserialize(deserializer)?;
        let len = v.len();
        // Convert the heap Vec directly to a heap-boxed array — no intermediate
        // stack array (the whole point of boxing): `Box<[u32]>` -> `Box<[u32;
        // PLAT_NR]>` is a heap-only length check. FAIL-LOUD on a wrong length.
        let boxed: Box<[u32]> = v.into_boxed_slice();
        boxed.try_into().map_err(|_| {
            D::Error::custom(format!(
                "PlatStats histogram length {len} != PLAT_NR {PLAT_NR}"
            ))
        })
    }
}

impl Default for PlatStats {
    fn default() -> Self {
        // Heap-allocate the zeroed histogram directly (a zeroed Vec → boxed
        // slice → boxed array): no `[0; PLAT_NR]` stack array, so even
        // `PlatStats::default()` never materializes 19 KiB on the stack.
        Self {
            plat: vec![0u32; PLAT_NR]
                .into_boxed_slice()
                .try_into()
                .expect("PLAT_NR-length zeroed histogram"),
            nr_samples: 0,
            max: 0,
            min: 0,
        }
    }
}

// Manual Debug: a derived one would dump all 4864 buckets, which is useless in
// a `PhaseSlice` diagnostic. Summarize by the non-array fields instead.
impl core::fmt::Debug for PlatStats {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        f.debug_struct("PlatStats")
            .field("nr_samples", &self.nr_samples)
            .field("min", &self.min)
            .field("max", &self.max)
            .finish_non_exhaustive()
    }
}

impl PlatStats {
    /// Record one sample. Faithful port of `schbench.c` `add_lat` (:743):
    /// updates max/min (with the `min == 0` sentinel) and increments the
    /// bucket + sample count. Does NOT gate `us == 0` — the caller decides
    /// whether a zero is a real sample. schbench's latency callers GATE on
    /// `delta > 0` (a non-positive delta is dropped, `schbench.c:1035-1037,
    /// 1481-1483`); schbench_rs mirrors that in `msg_and_wait` / `rps_wait`.
    /// schbench's RPS caller instead substitutes 0 for a non-finite rate
    /// (`schbench.c:1782`); schbench_rs's RPS caller (`control_loop`) instead
    /// DROPS a sub-quantum window (`dt < STOP_POLL_QUANTUM_NS`) via
    /// `control_loop_rps_sample` — the bounded teardown sleep can return on stop
    /// with a tiny dt, where a count/dt rate is unbounded as dt -> 0, so dropping
    /// avoids both schbench's synthetic bucket-0 sample and an unbounded boundary
    /// spike.
    ///
    /// The `u32` bucket increments with `+= 1`: in a debug build this
    /// panics if a single bucket exceeds 2^32 samples; in release it wraps,
    /// matching C's `__sync_fetch_and_add` on `unsigned int`. 2^32 samples
    /// in one bucket is not a realistic phase, and the debug overflow catch
    /// is worth keeping over a silent wrap.
    pub(crate) fn add_lat(&mut self, us: u32) {
        if us > self.max {
            self.max = us;
        }
        if self.min == 0 || us < self.min {
            self.min = us;
        }
        self.plat[plat_val_to_idx(us)] += 1;
        self.nr_samples += 1;
    }

    /// Fold `other` into `self`: per-bucket add, sample-count add,
    /// max-of-max, and a min-with-sentinel.
    ///
    /// The `min` fold GUARDS against an empty operand — a DELIBERATE
    /// divergence from schbench that fixes a schbench bug. `schbench.c:738`
    /// is literally `if (d->min == 0 || s->min < d->min) d->min = s->min`.
    /// schbench's `combine_message_thread_stats` (`schbench.c:1690-1699`)
    /// folds EVERY worker UNCONDITIONALLY, and the per-interval accumulator
    /// is re-folded over all workers each interval (`schbench.c:1785-1792`),
    /// so an idle/no-progress worker — whose stat is empty (`min == 0`, e.g.
    /// every delta dropped by the `delta > 0` gate) — gets folded after a
    /// non-empty one, where `0 < d->min` fires and ZEROS the accumulated
    /// `min`. That is a real schbench bug: a single idle worker corrupts
    /// schbench's reported `min` to 0, and starvation is exactly what
    /// schbench exists to surface. schbench_rs's `other.min != 0` guard
    /// skips an empty operand and keeps the true smallest sample, because an
    /// idle / late-spawn / early-detach per-phase worker is expected in the
    /// per-phase model and a spurious `min == 0` would corrupt the phase
    /// result. The two agree whenever no empty operand is folded (the
    /// common, no-idle case).
    pub(crate) fn combine(&mut self, other: &PlatStats) {
        for i in 0..PLAT_NR {
            self.plat[i] += other.plat[i];
        }
        self.nr_samples += other.nr_samples;
        if other.max > self.max {
            self.max = other.max;
        }
        if other.min != 0 && (self.min == 0 || other.min < self.min) {
            self.min = other.min;
        }
    }

    /// Extract percentiles. Faithful port of `schbench.c`
    /// `calc_percentiles` (:502): cumulative bucket sum, the threshold test
    /// `sum >= plist[j]/100 * nr` (`>=`, and a `while` so several
    /// percentiles can fall in one bucket), then the cumulative counts are
    /// differenced into per-bucket counts. With zero samples every value is
    /// 0 (the `0 >= 0` test fires for each percentile).
    pub(crate) fn percentiles(&self) -> Percentiles {
        let nr = self.nr_samples;
        let len = PLIST.len();
        let mut values = [0u32; 5];
        let mut counts = [0u64; 5];

        let mut sum: u64 = 0;
        let mut j = 0usize;
        let mut is_last = false;
        let mut i = 0usize;
        while i < PLAT_NR && !is_last {
            sum += self.plat[i] as u64;
            while (sum as f64) >= PLIST[j] / 100.0 * nr as f64 {
                values[j] = plat_idx_to_val(i);
                counts[j] = sum;
                is_last = j == len - 1;
                if is_last {
                    break;
                }
                j += 1;
            }
            i += 1;
        }

        // Difference cumulative counts into per-bucket counts
        // (`schbench.c:543-546`). Cumulative ⇒ each delta is >= 0.
        let mut last = 0u64;
        for idx in 1..len {
            last += counts[idx - 1];
            counts[idx] -= last;
        }

        Percentiles {
            values,
            counts,
            min: self.min,
            max: self.max,
            nr_samples: nr,
        }
    }

    /// Snapshot the accumulated stats and reset to empty — the per-phase
    /// boundary primitive (ruling: each ktstr phase reports isolated
    /// percentiles, e.g. scx vs detached-EEVDF in one run).
    pub(crate) fn take(&mut self) -> PlatStats {
        std::mem::take(self)
    }

    /// Number of samples recorded (`schbench.c` `nr_samples`). O(1) — reads the
    /// running count without scanning the histogram, so callers can gate an
    /// empty histogram (no metric emitted) or tally dropped samples without the
    /// `percentiles()` scan.
    pub(crate) fn sample_count(&self) -> u64 {
        self.nr_samples
    }
}

/// Extracted percentile values + min/max for a [`PlatStats`].
pub(crate) struct Percentiles {
    /// Value at each [`PLIST`] percentile, same order (20, 50, 90, 99,
    /// 99.9).
    pub(crate) values: [u32; 5],
    /// Per-bucket (differenced) sample counts, parallel to `values`.
    pub(crate) counts: [u64; 5],
    pub(crate) min: u32,
    pub(crate) max: u32,
    pub(crate) nr_samples: u64,
}

/// The percentiles schbench reports, as an index enum. Reading by enum
/// (not `f64`) avoids float-equality on the read path — a percentile
/// computed arithmetically (e.g. `999.0/10.0`) need not bit-match the
/// `99.9` literal. Each variant maps to a [`PLIST`] slot, in order.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Pct {
    /// `PLIST[0]` = 20.0 — schbench's RPS-distribution percentile
    /// (`PLIST_FOR_RPS`, `schbench.c:130`). Read by the per-phase RPS derivation
    /// (`run_metrics.rs` `derive_phase_metrics`, `rps_p20`); the latency
    /// metrics use P50-P999.
    P20,
    P50,
    P90,
    P99,
    P999,
}

impl Pct {
    fn index(self) -> usize {
        match self {
            Pct::P20 => 0,
            Pct::P50 => 1,
            Pct::P90 => 2,
            Pct::P99 => 3,
            Pct::P999 => 4,
        }
    }
}

impl Percentiles {
    /// The value at a given percentile. Every [`Pct`] is a valid [`PLIST`]
    /// slot, so this is total (no `Option`).
    pub(crate) fn value_at(&self, p: Pct) -> u32 {
        self.values[p.index()]
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn plat_val_to_idx_identity_range() {
        // MSB within PLAT_BITS (val <= 511) indexes directly.
        assert_eq!(plat_val_to_idx(0), 0);
        assert_eq!(plat_val_to_idx(1), 1);
        assert_eq!(plat_val_to_idx(255), 255);
        assert_eq!(plat_val_to_idx(256), 256); // msb == 8 == PLAT_BITS
        assert_eq!(plat_val_to_idx(511), 511); // msb == 8
    }

    #[test]
    fn plat_val_to_idx_log_group_and_clamp() {
        // msb == 9 > PLAT_BITS: error_bits=1, base=512, offset=255&(512>>1).
        // 512>>1 = 256; 255 & 256 == 0 -> idx 512.
        assert_eq!(plat_val_to_idx(512), 512);
        // 513>>1 = 256; 255 & 256 == 0 -> still 512.
        assert_eq!(plat_val_to_idx(513), 512);
        // Huge value clamps to the last bucket.
        assert_eq!(plat_val_to_idx(u32::MAX), PLAT_NR - 1);
    }

    #[test]
    fn plat_idx_round_trips_identity_range() {
        for idx in [0usize, 1, 255, 256, 511] {
            assert_eq!(plat_idx_to_val(idx), idx as u32);
        }
    }

    #[test]
    fn plat_idx_to_val_mean_of_range() {
        // Mean-of-range arithmetic — NOT truncation: for every reachable
        // idx >= 512, error_bits >= 1, so the f64 product is integral and
        // no `.5` fraction survives to be truncated.
        // idx 512: error_bits=(512>>8)-1=1, base=1<<9=512, k=0,
        // val = 512 + (0.5 * 2) = 513.
        assert_eq!(plat_idx_to_val(512), 513);
        // idx 513: k=1, val = 512 + (1.5 * 2) = 515.
        assert_eq!(plat_idx_to_val(513), 515);
    }

    #[test]
    fn empty_histogram_yields_zero_percentiles_len5() {
        let s = PlatStats::default();
        let p = s.percentiles();
        assert_eq!(p.nr_samples, 0);
        assert_eq!(p.values, [0, 0, 0, 0, 0]);
        assert_eq!(p.min, 0);
        assert_eq!(p.max, 0);
        assert_eq!(p.value_at(Pct::P99), 0);
    }

    #[test]
    fn single_sample_all_percentiles_equal_that_value() {
        let mut s = PlatStats::default();
        s.add_lat(100);
        let p = s.percentiles();
        assert_eq!(p.nr_samples, 1);
        assert_eq!(p.values, [100, 100, 100, 100, 100]);
        assert_eq!(p.min, 100);
        assert_eq!(p.max, 100);
        // Cumulative 1 at p20, differenced to [1,0,0,0,0].
        assert_eq!(p.counts, [1, 0, 0, 0, 0]);
    }

    #[test]
    fn zero_sample_records_bucket_but_not_min() {
        // add_lat(0): bucket 0 increments, but min stays at the 0 sentinel
        // and max stays 0 (faithful to schbench add_lat).
        let mut s = PlatStats::default();
        s.add_lat(0);
        let p = s.percentiles();
        assert_eq!(p.nr_samples, 1);
        assert_eq!(p.min, 0);
        assert_eq!(p.max, 0);
        assert_eq!(p.values, [0, 0, 0, 0, 0]);
    }

    #[test]
    fn percentiles_split_across_buckets() {
        // 99 samples at 10us, 1 sample at 10000us. p50/p90/p99 land in the
        // 10us bucket; p99.9 in the tail. min=10, max=10000.
        let mut s = PlatStats::default();
        for _ in 0..99 {
            s.add_lat(10);
        }
        s.add_lat(10000);
        let p = s.percentiles();
        assert_eq!(p.nr_samples, 100);
        assert_eq!(p.min, 10);
        assert_eq!(p.max, 10000);
        assert_eq!(p.value_at(Pct::P50), 10);
        assert_eq!(p.value_at(Pct::P90), 10);
        assert_eq!(p.value_at(Pct::P99), 10);
        // p99.9 of 100 samples (>= 99.9% reached at the 100th sample) lands
        // on the 10000us tail bucket.
        let p999 = p.value_at(Pct::P999);
        assert!(p999 >= 10000, "p99.9 should be the tail bucket: {p999}");
    }

    #[test]
    fn value_at_pins_each_pct_to_distinct_bucket() {
        // Each percentile lands in a DISTINCT bucket, so value_at(Pct) pins
        // the whole Pct -> PLIST-slot correspondence — including P20, which no
        // other test exercises. A PLIST reorder or a wrong Pct::index arm
        // would read the wrong slot and fail here.
        //
        // percentiles() compares the per-bucket cumulative sum against the
        // float threshold `PLIST[j]/100 * nr`. With nr=1000 the thresholds are
        // ~200/500/900/990/999 — but e.g. `99.9/100*1000` rounds to just ABOVE
        // 999.0 in f64, so a threshold sitting exactly on a bucket boundary
        // can spill into the next bucket. Sizing the blocks so the running
        // cumsum (250/550/920/995/1000) brackets each threshold strictly
        // INSIDE one bucket keeps the mapping robust to that rounding:
        //   P20  ~200 in (0,250]    -> bucket 10
        //   P50  ~500 in (250,550]  -> bucket 20
        //   P90  ~900 in (550,920]  -> bucket 30
        //   P99  ~990 in (920,995]  -> bucket 40
        //   P999 ~999 in (995,1000] -> bucket 50
        let mut s = PlatStats::default();
        for _ in 0..250 {
            s.add_lat(10); // cumsum 250
        }
        for _ in 0..300 {
            s.add_lat(20); // cumsum 550
        }
        for _ in 0..370 {
            s.add_lat(30); // cumsum 920
        }
        for _ in 0..75 {
            s.add_lat(40); // cumsum 995
        }
        for _ in 0..5 {
            s.add_lat(50); // cumsum 1000
        }
        let p = s.percentiles();
        assert_eq!(p.nr_samples, 1000);
        assert_eq!(p.value_at(Pct::P20), 10);
        assert_eq!(p.value_at(Pct::P50), 20);
        assert_eq!(p.value_at(Pct::P90), 30);
        assert_eq!(p.value_at(Pct::P99), 40);
        assert_eq!(p.value_at(Pct::P999), 50);
    }

    #[test]
    fn combine_folds_buckets_counts_and_extremes() {
        let mut a = PlatStats::default();
        a.add_lat(5);
        a.add_lat(50);
        let mut b = PlatStats::default();
        b.add_lat(7);
        b.add_lat(9000);
        a.combine(&b);
        let p = a.percentiles();
        assert_eq!(p.nr_samples, 4);
        assert_eq!(p.min, 5); // min of {5,50,7,9000}
        assert_eq!(p.max, 9000);
        // Percentile VALUES pin the per-bucket fold itself (nr/min/max above
        // come from non-bucket fields and would survive dropping the bucket
        // merge). Post-combine buckets are {5, 7, 50, 1561} over nr=4, so the
        // cumulative thresholds land: P20 (0.8) on bucket 5, P50 (2.0) on
        // bucket 7 — present ONLY because b's buckets were folded in — and
        // P90/P99/P999 (3.6/3.96/3.996) on b's 9000 sample
        // (plat_idx_to_val(1561) = 9008). Dropping the `self.plat[i] +=
        // other.plat[i]` loop leaves only a's {5, 50}: P50 would read 50, not 7.
        assert_eq!(p.value_at(Pct::P20), 5);
        assert_eq!(p.value_at(Pct::P50), 7);
        assert_eq!(p.value_at(Pct::P90), 9008);
        assert_eq!(p.value_at(Pct::P99), 9008);
        assert_eq!(p.value_at(Pct::P999), 9008);
    }

    #[test]
    fn combine_empty_does_not_zero_min() {
        // The min fold guards against an empty operand: schbench DOES fold
        // empty worker stats (combine_message_thread_stats schbench.c:
        // 1690-1699, unconditional), so its literal min fold (schbench.c:
        // 738) lets an idle worker zero schbench's own reported min.
        // schbench_rs diverges to keep the real min for the per-phase
        // consumer (which CAN have an empty per-phase worker).
        let mut a = PlatStats::default();
        a.add_lat(42);
        assert_eq!(a.percentiles().min, 42);
        let empty = PlatStats::default();
        a.combine(&empty);
        assert_eq!(
            a.percentiles().min,
            42,
            "guarded: folding an empty operand must not zero min",
        );
        assert_eq!(a.percentiles().nr_samples, 1, "empty fold adds no samples");
    }

    #[test]
    fn take_snapshots_and_resets_for_per_phase() {
        let mut s = PlatStats::default();
        s.add_lat(100);
        s.add_lat(200);
        let phase1 = s.take();
        assert_eq!(phase1.percentiles().nr_samples, 2);
        // After take, the live stats are empty (new phase starts clean).
        assert_eq!(s.percentiles().nr_samples, 0);
        s.add_lat(300);
        assert_eq!(s.percentiles().nr_samples, 1);
        assert_eq!(s.percentiles().min, 300);
    }

    #[test]
    fn platstats_serde_roundtrips_json_and_postcard() {
        // The per-phase carrier rides PlatStats over postcard (guest->host) and
        // is also JSON-serializable for the worker report pipe. Both must
        // preserve the full 4864-bucket histogram + nr_samples/max/min, since
        // the host re-derives percentiles from the deserialized buckets.
        let mut s = PlatStats::default();
        s.add_lat(5);
        s.add_lat(9000);
        s.add_lat(0); // bucket 0, min stays sentinel
        let json = serde_json::to_string(&s).expect("serialize json");
        let back: PlatStats = serde_json::from_str(&json).expect("deserialize json");
        assert_eq!(s, back, "json roundtrip preserves histogram + extremes");
        let bytes = postcard::to_stdvec(&s).expect("serialize postcard");
        let back2: PlatStats = postcard::from_bytes(&bytes).expect("deserialize postcard");
        assert_eq!(
            s, back2,
            "postcard roundtrip preserves histogram + extremes"
        );
        // The deserialized histogram yields identical percentiles (the host path).
        assert_eq!(back2.percentiles().values, s.percentiles().values);
    }

    #[test]
    fn platstats_deserialize_wrong_len_is_fail_loud() {
        // A short plat array is a corrupt carrier: the adapter rejects it rather
        // than padding/truncating (the no-silent-wrong-answer rule).
        let bad = r#"{"plat":[1,2,3],"nr_samples":0,"max":0,"min":0}"#;
        let err = serde_json::from_str::<PlatStats>(bad).expect_err("wrong-len must fail");
        assert!(
            err.to_string().contains("PLAT_NR"),
            "error names the length mismatch: {err}"
        );
    }
}