ktstr 0.5.2

Test harness for Linux process schedulers
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
//! ThreadState defaults, Mode tie-break, wire-format identity, type pins.
//!
//! Co-located with `super::mod.rs`; one of the topic-grouped
//! split files that replace the monolithic `tests.rs`.

#![cfg(test)]

use super::*;

/// `ThreadState::default()` produces `'~'` (not `'\0'`) for
/// the `state` char so the absent-value sentinel matches the
/// capture-time `unwrap_or_else(default_state_char)`
/// discipline. The bare `char` Default of `'\0'` (U+0000)
/// lex-compares SMALLER than every real kernel state letter
/// (`R`/`S`/`D`/`T`/`t`/`X`/`Z`/`P`/`I`); a Mode-tie-break
/// that picks the lex-smallest would silently elect `'\0'`
/// whenever a default-built thread sat alongside a real one
/// in a group, dragging the cell from a meaningful state
/// letter down to the absent sentinel. The manual
/// [`Default`] impl on [`ThreadState`] pairs with the
/// `serde(default = "default_state_char")` attribute on the
/// field so both construction paths land on `'~'`.
#[test]
fn default_threadstate_state_is_sentinel_tilde() {
    let t = ThreadState::default();
    assert_eq!(
        t.state, '~',
        "ThreadState::default().state must be '~' (the \
         absent-value sentinel chosen to lex-sort AFTER \
         every real kernel state letter), not '\\0' (the \
         bare char Default); see field doc on \
         ThreadState::state"
    );
}

/// Mode tie-break regression: a default-constructed
/// `ThreadState` must NOT lex-beat a real kernel state
/// letter when both contribute to the same Mode aggregation
/// at equal frequency. The kernel's
/// [`crate::ctprof_compare::aggregate`] closure
/// `a.1.cmp(&b.1).then(b.0.cmp(&a.0))` selects
/// LEX-SMALLEST on count-ties, so the sentinel must be
/// LARGER than every real letter to keep the real letter
/// winning. `'~'` (U+007E = 126) is larger than every
/// kernel state letter (`R`=82, `S`=83, `D`=68, `T`=84,
/// `t`=116, `X`=88, `Z`=90, `P`=80, `I`=73), so the
/// tiebreak picks `R`. The original `'?'` (U+003F = 63)
/// sentinel was SMALLER than every real letter, which
/// would have made this test fail.
#[test]
fn mode_tiebreak_against_default_state_picks_real_letter() {
    use crate::ctprof_compare::{AggRule, Aggregated, aggregate};
    let default_thread = ThreadState::default();
    let real_thread = ThreadState {
        state: 'R',
        ..ThreadState::default()
    };
    let agg = aggregate(
        AggRule::ModeChar(|t| t.state),
        &[&default_thread, &real_thread],
    );
    match &agg {
        Aggregated::Mode { .. } => assert_eq!(
            agg.mode_value(),
            "R",
            "Mode tiebreak between '~' (default sentinel) \
             and 'R' (real kernel state) must elect 'R'; \
             got {:?}",
            agg.mode_value(),
        ),
        other => panic!("expected Mode, got {other:?}"),
    }
}

/// Wire-format identity: hand-written JSON with raw
/// primitive values at every newtype-wrapped field position
/// must deserialize cleanly into a post-phase-2
/// `ThreadState` with the wrapper fields holding the
/// expected values. Covers one representative field per
/// newtype family — MonotonicCount, MonotonicNs, ClockTicks,
/// Bytes, PeakNs, GaugeNs, GaugeCount, OrdinalI32,
/// OrdinalU32, CategoricalString, CpuSet — so a regression
/// that breaks `serde(transparent)` on any wrapper would
/// surface here without needing a real .ctprof.zst file from
/// pre-phase-2 capture. Pre-phase-2 snapshot files (raw
/// `u64`/`i32`/`String`/`Vec<u32>` at every position)
/// continue to deserialize identically.
#[test]
fn wire_format_identity_raw_primitives_deserialize_into_wrapped_thread_state() {
    let json = r#"{
        "tid": 1234,
        "tgid": 1234,
        "pcomm": "demo",
        "comm": "demo-w",
        "cgroup": "/app",
        "start_time_clock_ticks": 555000,
        "policy": "SCHED_OTHER",
        "nice": -5,
        "cpu_affinity": [0, 1, 2, 3],
        "processor": 7,
        "state": "R",
        "ext_enabled": false,
        "run_time_ns": 1000000,
        "wait_time_ns": 0,
        "timeslices": 50,
        "voluntary_csw": 100,
        "nonvoluntary_csw": 25,
        "nr_wakeups": 200,
        "nr_wakeups_local": 80,
        "nr_wakeups_remote": 30,
        "nr_wakeups_sync": 10,
        "nr_wakeups_migrate": 5,
        "nr_wakeups_affine": 60,
        "nr_wakeups_affine_attempts": 100,
        "nr_migrations": 8,
        "nr_forced_migrations": 1,
        "nr_failed_migrations_affine": 0,
        "nr_failed_migrations_running": 0,
        "nr_failed_migrations_hot": 0,
        "wait_sum": 5000000,
        "wait_count": 15,
        "wait_max": 250000,
        "voluntary_sleep_ns": 3200000,
        "sleep_max": 180000,
        "block_sum": 1100000,
        "block_max": 60000,
        "iowait_sum": 77000,
        "iowait_count": 18,
        "exec_max": 90000,
        "slice_max": 400000,
        "allocated_bytes": 16777216,
        "deallocated_bytes": 8388608,
        "minflt": 7777,
        "majflt": 8888,
        "utime_clock_ticks": 10,
        "stime_clock_ticks": 11,
        "priority": 25,
        "rt_priority": 99,
        "core_forceidle_sum": 0,
        "fair_slice_ns": 250000,
        "nr_threads": 4,
        "smaps_rollup_kb": {},
        "rchar": 100,
        "wchar": 200,
        "syscr": 10,
        "syscw": 20,
        "read_bytes": 4096,
        "write_bytes": 8192,
        "cancelled_write_bytes": 1024,
        "cpu_delay_count": 0,
        "cpu_delay_total_ns": 0,
        "cpu_delay_max_ns": 0,
        "cpu_delay_min_ns": 0,
        "blkio_delay_count": 0,
        "blkio_delay_total_ns": 0,
        "blkio_delay_max_ns": 0,
        "blkio_delay_min_ns": 0,
        "swapin_delay_count": 0,
        "swapin_delay_total_ns": 0,
        "swapin_delay_max_ns": 0,
        "swapin_delay_min_ns": 0,
        "freepages_delay_count": 0,
        "freepages_delay_total_ns": 0,
        "freepages_delay_max_ns": 0,
        "freepages_delay_min_ns": 0,
        "thrashing_delay_count": 0,
        "thrashing_delay_total_ns": 0,
        "thrashing_delay_max_ns": 0,
        "thrashing_delay_min_ns": 0,
        "compact_delay_count": 0,
        "compact_delay_total_ns": 0,
        "compact_delay_max_ns": 0,
        "compact_delay_min_ns": 0,
        "wpcopy_delay_count": 0,
        "wpcopy_delay_total_ns": 0,
        "wpcopy_delay_max_ns": 0,
        "wpcopy_delay_min_ns": 0,
        "irq_delay_count": 0,
        "irq_delay_total_ns": 0,
        "irq_delay_max_ns": 0,
        "irq_delay_min_ns": 0,
        "hiwater_rss_bytes": 0,
        "hiwater_vm_bytes": 0
    }"#;
    let t: ThreadState = serde_json::from_str(json).expect("deserialize");
    // One representative field per newtype family proves
    // serde(transparent) works post-migration.
    assert_eq!(t.run_time_ns, crate::metric_types::MonotonicNs(1_000_000));
    assert_eq!(t.timeslices, crate::metric_types::MonotonicCount(50));
    assert_eq!(t.utime_clock_ticks, crate::metric_types::ClockTicks(10));
    assert_eq!(t.allocated_bytes, crate::metric_types::Bytes(16_777_216));
    assert_eq!(
        t.cancelled_write_bytes,
        crate::metric_types::Bytes(1024),
        "cancelled_write_bytes round-trips through the JSON \
         wire format alongside the other Bytes-typed fields",
    );
    assert_eq!(t.wait_max, crate::metric_types::PeakNs(250_000));
    assert_eq!(t.fair_slice_ns, crate::metric_types::GaugeNs(250_000));
    assert_eq!(t.nr_threads, crate::metric_types::GaugeCount(4));
    assert_eq!(t.nice, crate::metric_types::OrdinalI32(-5));
    assert_eq!(t.rt_priority, crate::metric_types::OrdinalU32(99));
    assert_eq!(
        t.policy,
        crate::metric_types::CategoricalString::from("SCHED_OTHER")
    );
    assert_eq!(
        t.cpu_affinity,
        crate::metric_types::CpuSet(vec![0, 1, 2, 3])
    );
}

/// Type-pin: nr_threads MUST be `GaugeCount`. A future
/// refactor that flips it to a different newtype (e.g.
/// `MonotonicCount`, which would silently re-enable Summable
/// and let `--group-by comm`/`--group-by cgroup` over-count
/// the parent process N-fold) would break this single
/// `let _: GaugeCount = ...;` assignment. The test compiles
/// only when the type is exactly `GaugeCount`.
#[test]
fn nr_threads_field_pinned_to_gauge_count() {
    let t = ThreadState::default();
    let _: crate::metric_types::GaugeCount = t.nr_threads;
}

/// Type-pin: cancelled_write_bytes MUST be `Bytes`. A future
/// refactor that flipped it to a non-byte type (e.g. plain
/// `MonotonicCount`, dropping the IEC-binary auto-scale
/// ladder and the registry's `unit: "B"` rendering) would
/// break this single `let _: Bytes = ...;` assignment. The
/// test compiles only when the type is exactly `Bytes`.
#[test]
fn cancelled_write_bytes_field_pinned_to_bytes() {
    let t = ThreadState::default();
    let _: crate::metric_types::Bytes = t.cancelled_write_bytes;
}

/// `PsiHalf::avg10_percent` divides the centi-percent
/// representation by 100 to yield the percentage. Pin
/// representative values across the documented range
/// (0.00..=100.99 per the kernel-EWMA-rounding bound on
/// the struct doc) so a regression that swapped the divisor
/// (e.g. `* 100.0` instead of `/ 100.0`) or that dropped a
/// digit of precision surfaces here. Mirrors the
/// `format_psi_avg_centi_percent` pin in tests_render.rs but
/// at the typed-conversion boundary that downstream
/// consumers of the centi-percent storage cross.
#[test]
fn psi_half_avg10_percent_converts_centi_percent_to_percent() {
    // 0 → 0.0 %
    let zero = PsiHalf {
        avg10: 0,
        avg60: 0,
        avg300: 0,
        total_usec: 0,
    };
    assert_eq!(zero.avg10_percent(), 0.0);

    // 1 (one centi-percent) → 0.01 %
    let one = PsiHalf {
        avg10: 1,
        ..PsiHalf::default()
    };
    assert!(
        (one.avg10_percent() - 0.01).abs() < f64::EPSILON,
        "avg10=1 must convert to 0.01 %, got {}",
        one.avg10_percent(),
    );

    // 100 (one full percent worth of centi-percent) → 1.0 %
    let one_pct = PsiHalf {
        avg10: 100,
        ..PsiHalf::default()
    };
    assert!(
        (one_pct.avg10_percent() - 1.0).abs() < f64::EPSILON,
        "avg10=100 must convert to 1.0 %, got {}",
        one_pct.avg10_percent(),
    );

    // 5050 → 50.50 % (mid-range typical value)
    let mid = PsiHalf {
        avg10: 5050,
        ..PsiHalf::default()
    };
    assert!(
        (mid.avg10_percent() - 50.50).abs() < 1e-9,
        "avg10=5050 must convert to 50.50 %, got {}",
        mid.avg10_percent(),
    );

    // 10000 → 100.00 %
    let max = PsiHalf {
        avg10: 10000,
        ..PsiHalf::default()
    };
    assert!(
        (max.avg10_percent() - 100.0).abs() < f64::EPSILON,
        "avg10=10000 must convert to 100.0 %, got {}",
        max.avg10_percent(),
    );

    // 10099 → 100.99 % (kernel's EWMA-rounding upper bound
    // documented on the struct).
    let over = PsiHalf {
        avg10: 10099,
        ..PsiHalf::default()
    };
    assert!(
        (over.avg10_percent() - 100.99).abs() < 1e-9,
        "avg10=10099 must convert to 100.99 %, got {}",
        over.avg10_percent(),
    );

    // u16::MAX → 655.35 %. Exercises the upper boundary of
    // the storage type (the kernel's EWMA cannot legitimately
    // produce this, but the conversion must not panic or
    // wrap; defensive pin).
    let umax = PsiHalf {
        avg10: u16::MAX,
        ..PsiHalf::default()
    };
    assert!(
        (umax.avg10_percent() - (u16::MAX as f64 / 100.0)).abs() < 1e-6,
        "avg10=u16::MAX must convert to {} %, got {}",
        u16::MAX as f64 / 100.0,
        umax.avg10_percent(),
    );
}

/// `PsiHalf::avg60_percent` is the 60-second analogue of
/// `avg10_percent` and uses the same centi-percent → percent
/// conversion. Pin a representative subset so a regression
/// that diverged the two methods (e.g. copy-paste error
/// reading `self.avg10` from the avg60 method) surfaces
/// here independent of the avg10 test.
#[test]
fn psi_half_avg60_percent_uses_avg60_field() {
    // Distinct centi-percent values per field so a regression
    // that read the wrong field surfaces as the wrong output.
    let p = PsiHalf {
        avg10: 1234,
        avg60: 5678,
        avg300: 9012,
        total_usec: 0,
    };
    assert!(
        (p.avg60_percent() - 56.78).abs() < 1e-9,
        "avg60=5678 must convert to 56.78 % (NOT avg10's 12.34); got {}",
        p.avg60_percent(),
    );
    // Boundary
    let zero = PsiHalf::default();
    assert_eq!(zero.avg60_percent(), 0.0);
    let max = PsiHalf {
        avg60: 10000,
        ..PsiHalf::default()
    };
    assert!(
        (max.avg60_percent() - 100.0).abs() < f64::EPSILON,
        "avg60=10000 must convert to 100.0 %, got {}",
        max.avg60_percent(),
    );
}

/// `PsiHalf::avg300_percent` is the 300-second analogue.
/// Same pattern as the avg60 test — pin distinct field
/// values so a regression that read the wrong field is
/// directly visible.
#[test]
fn psi_half_avg300_percent_uses_avg300_field() {
    let p = PsiHalf {
        avg10: 1234,
        avg60: 5678,
        avg300: 9012,
        total_usec: 0,
    };
    assert!(
        (p.avg300_percent() - 90.12).abs() < 1e-9,
        "avg300=9012 must convert to 90.12 % (NOT avg10/avg60's values); got {}",
        p.avg300_percent(),
    );
    // Boundary
    let zero = PsiHalf::default();
    assert_eq!(zero.avg300_percent(), 0.0);
    let max = PsiHalf {
        avg300: 10000,
        ..PsiHalf::default()
    };
    assert!(
        (max.avg300_percent() - 100.0).abs() < f64::EPSILON,
        "avg300=10000 must convert to 100.0 %, got {}",
        max.avg300_percent(),
    );
}

/// All three percent methods round-trip the kernel-emission
/// shape `LOAD_INT.LOAD_FRAC` losslessly. The kernel writes
/// each average as a 2-decimal-digit percentage at
/// `kernel/sched/psi.c:1284`; the centi-percent storage at
/// the [`PsiHalf`] field captures both digits as a single
/// integer (`int * 100 + frac`). Pin that the percent method
/// reproduces both digits with float precision.
#[test]
fn psi_half_percent_methods_preserve_kernel_two_decimal_precision() {
    // (int, frac) → centi-percent storage value
    // → expected percentage f64 to 2 decimal places.
    let cases: &[(u16, u16, u16, f64)] = &[
        (3, 25, 325, 3.25),
        (42, 7, 4207, 42.07),
        (87, 50, 8750, 87.50),
        (99, 99, 9999, 99.99),
    ];
    for (int_part, frac_part, stored, expected) in cases {
        // Sanity check the test fixture: int * 100 + frac
        // matches the stored value.
        assert_eq!(
            (*int_part as u32) * 100 + (*frac_part as u32),
            *stored as u32,
            "fixture: int*100+frac must equal stored",
        );
        let p = PsiHalf {
            avg10: *stored,
            avg60: *stored,
            avg300: *stored,
            total_usec: 0,
        };
        assert!(
            (p.avg10_percent() - *expected).abs() < 1e-9,
            "stored={stored} → expected {expected}, got avg10={}",
            p.avg10_percent(),
        );
        assert!(
            (p.avg60_percent() - *expected).abs() < 1e-9,
            "stored={stored} → expected {expected}, got avg60={}",
            p.avg60_percent(),
        );
        assert!(
            (p.avg300_percent() - *expected).abs() < 1e-9,
            "stored={stored} → expected {expected}, got avg300={}",
            p.avg300_percent(),
        );
    }
}