ktstr/
metric_types.rs

1//! Type-safe wrappers for per-thread metric values.
2//!
3//! Each registered metric in [`crate::ctprof_compare::CTPROF_METRICS`]
4//! has a kernel-source-grounded semantic category — counter,
5//! cumulative-time, peak high-water (ns and bytes), instantaneous
6//! gauge, byte count, ordinal scalar, categorical, or cpuset. The
7//! aggregation
8//! pipeline reduces values per category: counters sum, peaks take
9//! max, gauges take max, ordinals carry a [min, max] range,
10//! categoricals carry the mode (most-frequent value), and cpusets
11//! carry an affinity summary.
12//!
13//! # Temporal window
14//!
15//! Every counter / cumulative-time / peak / byte-count newtype
16//! defined here represents a value that the kernel accumulates
17//! across the THREAD LIFETIME — from thread birth to the
18//! moment of the procfs read. All of these fields share the
19//! same window because they live in the same `task_struct` and
20//! tick along with the same task. That shared window is what
21//! makes ratios across fields well-defined (e.g.
22//! `cpu_efficiency = run_time_ns / (run_time_ns + wait_time_ns)`
23//! is a meaningful fraction because both numerator and
24//! denominator measure the same task's same lifetime).
25//!
26//! Cross-file read skew during one capture pass (the
27//! capture pipeline reads `/proc/<tid>/stat`, then `/sched`,
28//! then `/io`, etc. with a few hundred microseconds of drift
29//! between them) is negligible against cumulative-from-birth
30//! totals that grow over hours or days of thread runtime —
31//! the small in-flight delta during the read is rounding noise
32//! relative to the lifetime accumulator. The qualifier holds
33//! relative to a lifetime accumulator that has had time to
34//! integrate; threads captured very early in their lifetime
35//! carry larger relative read-skew error, but their absolute
36//! contribution to any group aggregate is correspondingly
37//! small (a thread alive for 500 µs cannot meaningfully drag
38//! a group total even if its individual reads are skewed by
39//! 100 µs).
40//!
41//! [`crate::ctprof_compare`] runs in two modes that both
42//! preserve the shared-window property: SHOW renders one
43//! snapshot's lifetime totals; COMPARE subtracts two snapshots
44//! captured at different wall-clock instants to scope the values
45//! to the (capture-A, capture-B) interval. In both modes every
46//! field carries the same temporal window, so cross-field ratios
47//! and per-thread totals stay well-defined.
48//!
49//! Two newtypes break this convention deliberately: [`GaugeNs`]
50//! (a current-instantaneous reading like the scheduler's current
51//! slice) and [`GaugeCount`] (a current count like
52//! `signal_struct->nr_threads`) — the per-newtype docs call out
53//! the gauge family separately.
54//!
55//! # Type-system enforcement
56//!
57//! Encoding the category into the type system surfaces
58//! category-mismatched aggregation as a compile error. The
59//! [`crate::ctprof_compare::AggRule`] dispatch routes each
60//! variant through the typed newtype's reduction trait — `Sum*`
61//! through [`Summable::sum_across`], `Max*` through
62//! [`Maxable::max_across`], `Range*` through
63//! [`Rangeable::range_across`], and `Mode*` through
64//! [`Modeable::mode_across`] — so a registry entry that pairs a
65//! peak field with a sum reduction (e.g. `t.wait_max`
66//! ([`PeakNs`]) bound to a `Sum*` rule whose accessor returns a
67//! [`Summable`] value) fails to compile rather than producing a
68//! meaningless `1×1s ⊕ 1000×1ms` aggregate. This module defines
69//! the newtypes and traits the dispatch consumes.
70//!
71//! # The newtypes
72//!
73//! - [`MonotonicCount`] — pure counter (only ever goes up across a
74//!   thread's lifetime). Examples: `nr_wakeups`, `nr_migrations`,
75//!   `voluntary_csw`.
76//! - [`DeadCounter`] — same wire shape as [`MonotonicCount`] but
77//!   tagged for kernel counters whose update path is permanently
78//!   dead (the field exists in `task_struct` but no kernel writer
79//!   touches it on any current code path — `nr_wakeups_idle`,
80//!   `nr_migrations_cold`, `nr_wakeups_passive` all match this
81//!   shape today). Captured for parity with `/proc/<tid>/sched`
82//!   line numbers but does NOT implement any reduction trait
83//!   ([`Summable`] / [`Maxable`] / [`Rangeable`] / [`Modeable`])
84//!   — the value is structurally zero, so every reduction is
85//!   trivially zero and rendering it through any of the live
86//!   reductions implies "we measured a thing" when in fact we
87//!   measured a kernel-side dead pointer. The registry-level
88//!   accommodation (a no-op aggregation arm or registry removal)
89//!   is the migration batch's problem; this newtype's job is to
90//!   make the dead-counter status visible at the field
91//!   declaration so the migration can't accidentally pair it
92//!   with a [`Summable`]-bound `AggRule` variant.
93//! - [`MonotonicNs`] — cumulative-time counter, ns. Examples:
94//!   `run_time_ns`, `wait_sum`, `voluntary_sleep_ns`,
95//!   `block_sum`, `iowait_sum`, `core_forceidle_sum`.
96//! - [`PeakNs`] — lifetime high-water mark, ns. The kernel
97//!   updates these via `if (delta > stat->max) stat->max = delta`
98//!   inside `update_stats_*` wrappers (kernel/sched/stats.c) and
99//!   inline schedstat updates in `kernel/sched/fair.c` (e.g.
100//!   `slice_max` in `set_next_entity`, `exec_max` in
101//!   `update_se`). Summing peaks is a category error —
102//!   `1 thread × 1s peak` carries different meaning than
103//!   `1000 threads × 1ms peak`. Examples: `wait_max`,
104//!   `sleep_max`, `block_max`, `exec_max`, `slice_max`.
105//! - [`PeakBytes`] — lifetime high-water mark, bytes (per-process
106//!   `hiwater_rss` / `hiwater_vm` from `struct taskstats` via the
107//!   genetlink path). Same Maxable-only contract as [`PeakNs`] but
108//!   Bytes-typed, so it renders on the IEC byte ladder
109//!   (`B → KiB → MiB → GiB → TiB`) instead of the ns ladder.
110//! - [`GaugeNs`] — instantaneous gauge sampled at capture time, ns.
111//!   `fair_slice_ns` is the canonical example. Summing gauges is a
112//!   category error — N nearly-identical instantaneous samples
113//!   sum to N×gauge with no physical meaning.
114//! - [`GaugeCount`] — gauge-family unitless count (u64) that can
115//!   go up AND down at runtime. Carries the same Maxable-only
116//!   contract as [`GaugeNs`] but renders as a plain count rather
117//!   than a nanosecond ladder. `nr_threads` (the process-wide
118//!   thread count from `signal_struct->nr_threads`) is the
119//!   canonical example — threads spawn and exit so the value is
120//!   not monotonic, and the registry reduces it by Max across a
121//!   group rather than Sum. Distinct from [`GaugeNs`] because
122//!   "thread count" and "current slice in nanoseconds" do not
123//!   share a unit; routing nr_threads through GaugeNs would
124//!   render it on the ns auto-scale ladder, which is a unit lie.
125//! - [`ClockTicks`] — USER_HZ-scaled time. Examples:
126//!   `utime_clock_ticks`, `stime_clock_ticks`. Auto-scale
127//!   ladder is `ticks → Kticks → Mticks` (decimal SI),
128//!   distinct from ns (also decimal SI, different unit) and
129//!   bytes (IEC binary).
130//! - [`Bytes`] — byte counts. Examples: `allocated_bytes`,
131//!   `read_bytes`, `wchar`. Auto-scale ladder is IEC binary
132//!   (`B → KiB → MiB → GiB → TiB`).
133//! - [`OrdinalI32`] / [`OrdinalU32`] / [`OrdinalU64`] — bounded
134//!   scalar, range-aggregated (no sum). [`OrdinalI32`] examples:
135//!   `nice` ([-20, 19]), `priority`
136//!   (CFS=[0, 39], RT=[-2, -100], DL=-101), `processor` (last
137//!   CPU the task ran on; signed for symmetry with `nice` — the
138//!   kernel's `task_cpu()` returns `unsigned int`
139//!   (`include/linux/sched.h`), but ktstr stores i32 to share
140//!   the [`OrdinalI32`] wrapper with the genuinely-signed nice
141//!   and priority fields). [`OrdinalU32`] is for u32-backed
142//!   ordinal fields like `rt_priority` (real-time priority,
143//!   0..99 in practice for SCHED_FIFO / SCHED_RR; the kernel
144//!   declares `unsigned int task_struct::rt_priority` in
145//!   `include/linux/sched.h`, so a `u32` matches the kernel
146//!   field width exactly). [`OrdinalU64`] is reserved for
147//!   future ordinal metrics whose kernel-side type genuinely
148//!   exceeds `u32::MAX`; no field uses it today.
149//! - [`CategoricalString`] — string-valued, mode-aggregated.
150//!   `policy` is the only example. The `state` char
151//!   and `ext_enabled` bool fields stay unwrapped on
152//!   [`crate::ctprof::ThreadState`]; the
153//!   [`crate::ctprof_compare::AggRule::ModeChar`] and
154//!   [`crate::ctprof_compare::AggRule::ModeBool`] accessors coerce
155//!   them through `String` via `to_string()` at the call
156//!   site. If a second bool field appears, promote both to a
157//!   dedicated `CategoricalBool` wrapper rather than continuing the
158//!   ad-hoc coercion.
159//! - [`CpuSet`] — `Vec<u32>` of CPU IDs, affinity-aggregated.
160//!   `cpu_affinity` is the only example.
161//!
162//! # The marker traits
163//!
164//! - [`Summable`] — sum across a group. Implemented by the four
165//!   counter newtypes ([`MonotonicCount`], [`MonotonicNs`],
166//!   [`ClockTicks`], [`Bytes`]). NOT implemented by [`PeakNs`] /
167//!   [`GaugeNs`] / [`GaugeCount`] / [`OrdinalI32`] /
168//!   [`OrdinalU32`] / [`OrdinalU64`] / [`CategoricalString`] /
169//!   [`CpuSet`]. The trait is sealed via
170//!   `sealed::SummableSealed` so a downstream crate cannot add
171//!   `impl Summable for PeakNs` to bypass the category invariant.
172//! - [`Maxable`] — reduce by max. Implemented by [`PeakNs`]
173//!   (max-of-peak is "worst peak any contributor saw across its
174//!   lifetime"), [`GaugeNs`] (max-of-gauge is "longest current
175//!   slice in the bucket"), and [`GaugeCount`] (max-of-count is
176//!   "biggest current count any contributor carried"). NOT
177//!   implemented by [`Summable`] cumulative counters
178//!   ([`MonotonicCount`] / [`MonotonicNs`] / [`ClockTicks`] /
179//!   [`Bytes`]) — max-across-snapshots on a lifetime accumulator
180//!   reduces to "the last snapshot's value", which is mostly
181//!   noise relative to the lifetime-integrated quantity it
182//!   reports. NOT implemented by ordinals (those carry a
183//!   `[min, max]` range, not a single max), nor by
184//!   [`CategoricalString`] (string max has no useful semantic),
185//!   nor by [`CpuSet`] (the affinity reduction is a custom
186//!   summary, not a bare max). Sealed via
187//!   `sealed::MaxableSealed`.
188//!
189//!   `max_across` returns `Option<Self>`: `None` for an empty
190//!   iterator (so callers can distinguish "no contributors" from
191//!   "all contributors had zero"), `Some(largest)` otherwise.
192//!   The parallel `Summable::try_sum_across` returns
193//!   `Option<Self>` with the same empty-iterator semantics. The
194//!   `try_` prefix (rather than `checked_`) avoids colliding
195//!   with the stdlib's overflow-detection naming convention —
196//!   this is an empty-iterator check, not an arithmetic check.
197//! - [`Modeable`] — reduce by mode (most-frequent value).
198//!   Implemented by [`CategoricalString`] only. Sealed via
199//!   `sealed::ModeableSealed`.
200//! - [`Rangeable`] — reduce by `[min, max]`. Implemented by
201//!   [`OrdinalI32`], [`OrdinalU32`], and [`OrdinalU64`]. Sealed
202//!   via `sealed::RangeableSealed`. `range_across` returns
203//!   `Option<Range<Self>>` — the [`Range`] newtype enforces
204//!   `min ≤ max` at construction so a downstream consumer cannot
205//!   observe a swapped pair.
206//!
207//! Reductions are exposed as **trait methods** on
208//! [`Summable`] / [`Maxable`] / [`Rangeable`] / [`Modeable`].
209//! Callers must import the relevant trait (or `use
210//! ktstr::metric_types::*;`) to call `T::sum_across(...)` /
211//! `T::max_across(...)` / `T::range_across(...)` /
212//! `T::mode_across(...)`. The traits double as compile-time
213//! markers — a generic site that wants "any summable type" can
214//! take `T: Summable` and statically reject `PeakNs`.
215//!
216//! # Wire-format compatibility
217//!
218//! Every wrapper carries `#[serde(transparent)]` so the JSON
219//! representation matches the unwrapped primitive. The
220//! [`crate::ctprof::ThreadState`] migration to these
221//! newtypes preserves wire format — existing
222//! snapshot files (`.ctprof.zst`) deserialize unchanged.
223//!
224//! # What this module is NOT
225//!
226//! - It is NOT a unit-of-measure system. There is no
227//!   `MonotonicNs * MonotonicNs = MonotonicNs²` — these wrappers
228//!   carry semantic category, not algebraic dimensionality.
229//! - It is NOT a runtime-typed value enum (that lives next to
230//!   the [`crate::ctprof_compare::AggRule`] dispatch). This
231//!   module only defines the building-block newtypes.
232
233use serde::{Deserialize, Serialize};
234
235// ---------------------------------------------------------------------------
236// Newtype wrappers
237// ---------------------------------------------------------------------------
238
239/// Pure monotonic counter — only ever goes up over a thread's
240/// lifetime, accumulated by the kernel from thread birth to the
241/// moment of the procfs read. Sum across a group; delta across
242/// snapshots scopes the value to the inter-capture interval.
243///
244/// Examples in [`crate::ctprof_compare::CTPROF_METRICS`]:
245/// `nr_wakeups`, `nr_migrations`, `voluntary_csw`,
246/// `nonvoluntary_csw`, `wait_count`, `iowait_count`,
247/// `timeslices`, `minflt`, `majflt`, `syscr`, `syscw`.
248///
249/// `nr_threads` is NOT in this category — it is a structural
250/// gauge that goes up AND down at runtime (threads spawn and
251/// exit), so it reduces by max across a group, not sum. See
252/// [`GaugeCount`].
253#[repr(transparent)]
254#[derive(
255    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
256)]
257#[serde(transparent)]
258pub struct MonotonicCount(pub u64);
259
260/// Cumulative-time counter, nanoseconds, accumulated by the
261/// kernel from thread birth. Same temporal-window shape as
262/// [`MonotonicCount`] but tagged for the ns auto-scale ladder
263/// (ns → µs → ms → s).
264///
265/// Examples: `run_time_ns`, `wait_time_ns`, `wait_sum`,
266/// `voluntary_sleep_ns`, `block_sum`, `iowait_sum`,
267/// `core_forceidle_sum`.
268/// Cross-field ratios (e.g.
269/// `run_time_ns / (run_time_ns + wait_time_ns)`) are valid
270/// because every [`MonotonicNs`] field on
271/// [`crate::ctprof::ThreadState`] is integrated over the
272/// same thread-lifetime window.
273///
274/// # u64 backing vs kernel s64
275///
276/// Some kernel sources for these values are typed `s64` —
277/// `sum_sleep_runtime` and `sum_block_runtime` live in
278/// `struct sched_statistics` (`include/linux/sched.h`) as
279/// `s64`. The capture pipeline parses these via
280/// `parsed_ns_from_dotted` in [`crate::ctprof`], which
281/// returns `Err(ParseDottedNs::Negative)` on negative dotted
282/// values; the `parse_sched` closure maps that to `None`, and the
283/// capture-site `unwrap_or(0)` then collapses `None` to zero
284/// before the wrapper is constructed. The `u64` backing here is therefore
285/// safe because the parser path guarantees non-negative input
286/// — NOT because the kernel field type promises non-negative.
287/// Any new writer that bypasses `parsed_ns_from_dotted` must
288/// replicate its non-negative guard. A future capture-side
289/// change that exposes raw kernel s64 directly would need a
290/// sentinel-aware wrapper or a dedicated `SignedNs` newtype.
291#[repr(transparent)]
292#[derive(
293    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
294)]
295#[serde(transparent)]
296pub struct MonotonicNs(pub u64);
297
298/// USER_HZ-scaled tick counter, accumulated by the kernel from
299/// thread birth. The kernel exposes user-mode and kernel-mode
300/// CPU time, plus delayacct blkio delay, in ticks of the
301/// userspace-visible `USER_HZ` frequency. Auto-scale ladder is
302/// `ticks → Kticks → Mticks` (decimal SI), kept distinct from
303/// ns and bytes so the rendered cell carries the correct unit
304/// suffix.
305///
306/// Examples: `utime_clock_ticks`, `stime_clock_ticks`. Same
307/// lifetime-window contract as [`MonotonicNs`]; sum across a
308/// group, delta across snapshots.
309#[repr(transparent)]
310#[derive(
311    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
312)]
313#[serde(transparent)]
314pub struct ClockTicks(pub u64);
315
316/// Byte count, IEC-binary auto-scaled
317/// (`B → KiB → MiB → GiB → TiB`). Accumulated by the kernel
318/// (or jemalloc, for the per-thread TSD allocator counters)
319/// from thread birth.
320///
321/// Examples: `allocated_bytes`, `deallocated_bytes`, `rchar`,
322/// `wchar`, `read_bytes`, `write_bytes`, `cancelled_write_bytes`.
323/// Same lifetime-window contract as [`MonotonicNs`]; sum across
324/// a group, delta across snapshots.
325#[repr(transparent)]
326#[derive(
327    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
328)]
329#[serde(transparent)]
330pub struct Bytes(pub u64);
331
332/// Kernel counter whose update path is permanently dead. The
333/// field exists in `task_struct` (and is exposed via
334/// `/proc/<tid>/sched`) but no kernel writer touches it on any
335/// current code path.
336///
337/// Examples that historically motivated this newtype:
338/// `nr_wakeups_idle`, `nr_migrations_cold`, `nr_wakeups_passive`
339/// — these fields were removed from
340/// [`crate::ctprof::ThreadState`] because no kernel code
341/// path increments them on 6.16 or 7.1 (no
342/// `schedstat_inc(p->stats.nr_wakeups_idle)` /
343/// `nr_migrations_cold` / `nr_wakeups_passive` call site exists
344/// anywhere under `kernel/`). The newtype remains as
345/// infrastructure for future dead counters that get exposed in
346/// `/proc` before (or instead of) being wired up as live
347/// counters.
348///
349/// Wire format matches [`MonotonicCount`] (`u64`,
350/// `serde(transparent)`); the capture pipeline parses the same
351/// procfs lines and stores the same bits. The type-system
352/// difference is in the trait list: a [`MonotonicCount`] is
353/// [`Summable`] / [`Maxable`], while [`DeadCounter`] is neither.
354/// A registry entry that pairs a `DeadCounter` field with a
355/// [`Summable`]-bound `AggRule` variant fails to compile,
356/// flagging the dead status at the type level rather than
357/// surfacing as a "0 + 0 + 0" rendered cell.
358///
359/// # Migration affordance
360///
361/// A field can be flipped from [`MonotonicCount`] to
362/// [`DeadCounter`] without regenerating any `.ctprof.zst` snapshot
363/// files: the `repr(transparent)` + `serde(transparent)` wire
364/// format is structurally identical (a bare `u64`). Existing
365/// snapshots deserialize unchanged. The flip changes only the
366/// in-memory trait surface, which the registry consumes through
367/// `AggRule` accessors — adjusting those (or removing the
368/// field's registry entry entirely) is the only edit beyond the
369/// field type itself.
370///
371/// Defaults to zero. The reduction-trait omission is
372/// deliberate: all four reductions ([`Summable::sum_across`],
373/// [`Maxable::max_across`], [`Rangeable::range_across`],
374/// [`Modeable::mode_across`]) on a column of structural zeros
375/// trivially produce zero, but rendering that "zero" through a
376/// live reduction implies "we measured zero events" when the
377/// truth is "we measured a kernel-side dead pointer." Either
378/// add a no-op `AggRule` variant in the migration batch, or
379/// drop these fields from the registry entirely — both are the
380/// migration batch's call.
381#[repr(transparent)]
382#[derive(
383    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
384)]
385#[serde(transparent)]
386pub struct DeadCounter(pub u64);
387
388/// Lifetime high-water mark, nanoseconds. The kernel updates
389/// these as a max-against-prior in `update_stats_*` /
390/// `update_se` / `set_next_entity` paths
391/// (`kernel/sched/stats.c`, `kernel/sched/fair.c`); the value
392/// at any procfs read is the largest single window the thread
393/// has accumulated since its birth. Group reduction takes max
394/// across contributors so the rendered cell surfaces the worst
395/// single window any thread experienced over its lifetime.
396///
397/// # Cross-thread vs cross-snapshot semantics
398///
399/// The Max reduction over a bucket of threads produces the
400/// worst single window observed across DIFFERENT tasks — task
401/// A's `wait_max` and task B's `wait_max` measure two distinct
402/// scheduling histories, and the bucket-level max picks
403/// whichever task experienced the worst case. The result
404/// belongs to that one worst task, not to the bucket as a
405/// whole; downstream consumers should read the rendered cell
406/// as "this bucket contained at least one task that saw N ns
407/// of wait" rather than "all tasks in this bucket saw at most
408/// N ns of wait" (which is the same shape, but a much weaker
409/// statement).
410///
411/// In COMPARE mode the per-thread `PeakNs` delta between two
412/// snapshots is `peak_after - peak_before` — the kernel only
413/// ever raises the field, so the delta is non-negative and
414/// represents the AMOUNT BY WHICH THE LIFETIME HIGH-WATER LINE
415/// ROSE during the (capture-A, capture-B) interval, NOT the
416/// magnitude of the worst event in that interval. A new
417/// scheduling window inside the interval only moves the
418/// high-water line if its own magnitude exceeds every prior
419/// window the task had ever experienced; if every interval
420/// event was strictly smaller than `peak_before`, the delta is
421/// zero even though events did occur. The delta is therefore
422/// not itself a PeakNs in the same sense as the lifetime
423/// reading — it is a difference of high-water marks. The
424/// bucket reduction takes max over those deltas, surfacing the
425/// worst rise across contributors during the interval; this
426/// can dramatically under-report transient bad windows that
427/// happened earlier in any contributor's lifetime.
428///
429/// Summing peaks across threads is a category error — does not
430/// implement [`Summable`]. Implements [`Maxable`].
431///
432/// Examples: `wait_max`, `sleep_max`, `block_max`, `exec_max`,
433/// `slice_max`.
434///
435/// # u64 backing vs kernel s64
436///
437/// Of the `*_max` schedstat fields, only `exec_max` is typed
438/// `s64` in `struct sched_statistics`
439/// (`include/linux/sched.h`); `wait_max`, `sleep_max`,
440/// `block_max`, and `slice_max` are `u64`. The capture pipeline
441/// parses every dotted-ms.ns value via `parsed_ns_from_dotted`
442/// in [`crate::ctprof`], which returns `Err(ParseDottedNs::Negative)`
443/// on negative dotted values; the `parse_sched` closure maps that
444/// to `None`, and the capture-site `unwrap_or(0)` then collapses
445/// `None` to zero before the wrapper is constructed. The `u64`
446/// backing here is therefore safe even for `exec_max` because
447/// the parser path guarantees non-negative input — NOT because
448/// every kernel-side field promises non-negative. Any new
449/// writer that bypasses `parsed_ns_from_dotted` must replicate
450/// its non-negative guard.
451#[repr(transparent)]
452#[derive(
453    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
454)]
455#[serde(transparent)]
456pub struct PeakNs(pub u64);
457
458/// Lifetime high-water mark, bytes. Same Maxable-only contract
459/// as [`PeakNs`] but Bytes-typed so the renderer routes through
460/// the IEC binary auto-scale ladder
461/// (`B → KiB → MiB → GiB → TiB`) instead of the ns ladder.
462///
463/// The kernel's taskstats interface (`/proc/<tid>/stat` does NOT
464/// expose these — they require the genetlink TASKSTATS_CMD_GET
465/// path; see `crate::taskstats::TaskstatsClient`) carries
466/// `hiwater_rss` and `hiwater_vm` as KB-truncated lifetime
467/// watermarks. The capture pipeline multiplies by 1024 at the
468/// boundary so the wire-format value is in bytes, matching the
469/// existing `Bytes` newtype unit.
470///
471/// # Cross-thread vs cross-snapshot semantics
472///
473/// **Sibling threads of the same tgid see the same value**:
474/// `xacct_add_tsk` (`kernel/tsacct.c::xacct_add_tsk`, lines 99-104)
475/// fills `hiwater_rss` and `hiwater_vm` from
476/// `get_mm_hiwater_rss(mm)` / `get_mm_hiwater_vm(mm)` — both read
477/// from the shared `mm_struct`, so every thread of a given tgid
478/// reports the same lifetime watermark. A Max reduction across
479/// sibling threads of the same process is therefore a no-op on
480/// the watermark itself; the meaningful Max is the one across
481/// DIFFERENT processes, where each tgid carries its own
482/// mm_struct and an independent watermark history. Use Max
483/// expecting "the biggest process's watermark in the bucket",
484/// not "the worst-behaving thread of any process".
485///
486/// **Kernel threads (`mm == NULL`) report zero**: the same
487/// `xacct_add_tsk` path guards the assignments behind
488/// `if (mm)` (line 100), so kernel threads (PF_KTHREAD) leave
489/// the field at the kernel-side zero.
490///
491/// In COMPARE mode the per-thread `PeakBytes` delta between two
492/// snapshots is `peak_after - peak_before` — the kernel only
493/// ever raises the field, so the delta is non-negative and
494/// represents the AMOUNT BY WHICH THE LIFETIME WATERMARK GREW
495/// during the (capture-A, capture-B) interval, NOT the
496/// allocation size during that interval. A new allocation only
497/// moves the watermark if its peak RSS exceeds the prior
498/// lifetime maximum; an interval full of small allocations that
499/// stayed under `peak_before` shows a zero delta.
500///
501/// Summing watermarks across threads is a category error — does
502/// not implement [`Summable`]. Implements [`Maxable`].
503///
504/// # u64 backing
505///
506/// Bytes are non-negative by definition; the kernel-side
507/// `__u64 hiwater_rss` / `__u64 hiwater_vm` in `struct taskstats`
508/// (`include/uapi/linux/taskstats.h`) are u64 KB counts, and the
509/// post-multiply byte count fits comfortably in u64 across any
510/// realistic per-process memory footprint.
511#[repr(transparent)]
512#[derive(
513    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
514)]
515#[serde(transparent)]
516pub struct PeakBytes(pub u64);
517
518/// Instantaneous gauge sampled at capture time, nanoseconds.
519/// Distinct from [`PeakNs`]: a gauge is a snapshot of the
520/// CURRENT value of a kernel field, not a lifetime maximum.
521/// `fair_slice_ns` reads the per-thread `slice` line from
522/// `/proc/<tid>/sched`, which carries the scheduler's current
523/// timeslice for the task — a point-in-time reading, not a
524/// thread-lifetime accumulator. Cross-field ratios with
525/// [`MonotonicNs`] / [`MonotonicCount`] / etc. produce a
526/// quantity with mixed temporal interpretation (numerator
527/// integrates from thread birth, denominator samples the
528/// present), so callers should treat such ratios as a
529/// rough hint rather than a well-defined fraction.
530///
531/// Group reduction takes max across contributors. Sum across
532/// threads is a category error — does not implement [`Summable`].
533/// Implements [`Maxable`].
534#[repr(transparent)]
535#[derive(
536    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
537)]
538#[serde(transparent)]
539pub struct GaugeNs(pub u64);
540
541/// Gauge-family unitless count (u64). Distinct from
542/// [`MonotonicCount`]: a [`MonotonicCount`] only ever goes UP
543/// over a thread's lifetime (integrated from birth), while a
544/// [`GaugeCount`] is sampled at capture time and can go up AND
545/// down at runtime as the underlying state changes. Distinct
546/// from [`GaugeNs`]: same Maxable-only contract, but renders as
547/// a unitless count rather than a nanosecond ladder.
548///
549/// `nr_threads` (the process-wide thread count from
550/// `signal_struct->nr_threads`) is the canonical example —
551/// threads spawn and exit, so the value is not monotonic.
552/// Summing thread counts across a group is meaningless (a bucket
553/// of N threads sharing a tgid would over-count their parent
554/// process N-fold); the registry reduces by Max so the rendered
555/// cell shows "the largest process represented in this bucket."
556///
557/// Routing this kind of field through [`GaugeNs`] would render
558/// it on the ns auto-scale ladder — a unit lie. The dedicated
559/// type makes the intent explicit at the field declaration and
560/// lets the format dispatch pick the unitless ladder
561/// instead.
562///
563/// Implements [`Maxable`]. Does NOT implement [`Summable`].
564#[repr(transparent)]
565#[derive(
566    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
567)]
568#[serde(transparent)]
569pub struct GaugeCount(pub u64);
570
571/// Bounded ordinal scalar (i32). Range-aggregated across a
572/// group: the cell carries the observed `[min, max]` interval,
573/// not a sum. Sum is meaningless for ordinals — adding two `nice`
574/// values doesn't produce a third nice value.
575///
576/// Examples: `nice` ([-20, 19]), `priority`
577/// (CFS=[0, 39], RT=[-2, -100], DL=-101), `processor` (last CPU
578/// the task ran on; signed for symmetry with `nice` — the
579/// kernel's `task_cpu()` returns `unsigned int`
580/// (`include/linux/sched.h`), but ktstr stores i32 to share the
581/// [`OrdinalI32`] wrapper with the genuinely-signed nice and
582/// priority fields).
583#[repr(transparent)]
584#[derive(
585    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
586)]
587#[serde(transparent)]
588pub struct OrdinalI32(pub i32);
589
590/// Bounded ordinal scalar (u32). Same range-aggregation contract
591/// as [`OrdinalI32`] but for unsigned 32-bit fields.
592///
593/// Example: `rt_priority` (real-time priority, bounded 0..99 in
594/// practice for SCHED_FIFO / SCHED_RR). The kernel declares
595/// `unsigned int task_struct::rt_priority` at
596/// `include/linux/sched.h`; emitted by procfs via
597/// `seq_put_decimal_ull(m, " ", task->rt_priority)` at
598/// `fs/proc/array.c:637`. A `u32` matches the kernel field width
599/// exactly — narrower than the historical `u64` parse path
600/// because no plausible kernel-side rt_priority value exceeds
601/// `u32::MAX`.
602#[repr(transparent)]
603#[derive(
604    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
605)]
606#[serde(transparent)]
607pub struct OrdinalU32(pub u32);
608
609/// Bounded ordinal scalar (u64). Same range-aggregation contract
610/// as [`OrdinalI32`] but for unsigned 64-bit fields. No registry
611/// metric uses this width today; reserved for future ordinal
612/// metrics whose kernel-side type genuinely exceeds `u32::MAX`.
613#[repr(transparent)]
614#[derive(
615    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
616)]
617#[serde(transparent)]
618pub struct OrdinalU64(pub u64);
619
620/// Categorical string-valued field. Group reduction takes the
621/// mode (most-frequent value); ties break alphabetically per the
622/// existing `aggregate(AggRule::Mode, ...)` rule.
623///
624/// `policy` (SCHED_OTHER, SCHED_FIFO, SCHED_RR, SCHED_BATCH,
625/// SCHED_IDLE, SCHED_DEADLINE, SCHED_EXT) is the only
626/// [`CategoricalString`] field on
627/// [`crate::ctprof::ThreadState`]. The
628/// `state: char` and `ext_enabled: bool` fields stay unwrapped
629/// — the `AggRule::ModeChar` and `AggRule::ModeBool` accessors
630/// coerce them through `String` via `to_string()` at the call site. If a second
631/// bool-valued metric appears, promote both to a dedicated
632/// `CategoricalBool` wrapper rather than continuing the ad-hoc
633/// coercion.
634#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)]
635#[serde(transparent)]
636pub struct CategoricalString(pub String);
637
638/// CPU affinity set. Group reduction produces an
639/// [`crate::ctprof_compare::AffinitySummary`] carrying the
640/// num_cpus range plus a uniform-cpuset flag.
641#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)]
642#[serde(transparent)]
643pub struct CpuSet(pub Vec<u32>);
644
645// ---------------------------------------------------------------------------
646// `From<primitive>` and `From<newtype>` round-trip impls
647// ---------------------------------------------------------------------------
648//
649// Each newtype gets a pair of From impls so the capture layer can
650// keep parsing primitives and convert at the boundary, and so
651// downstream callers reading the .0 field can pull the primitive
652// back out. Note that From between *different* newtypes is
653// deliberately NOT implemented — the whole point of the type
654// system is to reject cross-category mixing.
655
656macro_rules! impl_u64_newtype_from {
657    ($t:ident) => {
658        impl From<u64> for $t {
659            fn from(v: u64) -> Self {
660                Self(v)
661            }
662        }
663        impl From<$t> for u64 {
664            fn from(v: $t) -> Self {
665                v.0
666            }
667        }
668    };
669}
670
671impl_u64_newtype_from!(MonotonicCount);
672impl_u64_newtype_from!(MonotonicNs);
673impl_u64_newtype_from!(ClockTicks);
674impl_u64_newtype_from!(Bytes);
675impl_u64_newtype_from!(DeadCounter);
676impl_u64_newtype_from!(PeakNs);
677impl_u64_newtype_from!(PeakBytes);
678impl_u64_newtype_from!(GaugeNs);
679impl_u64_newtype_from!(GaugeCount);
680
681impl From<i32> for OrdinalI32 {
682    fn from(v: i32) -> Self {
683        Self(v)
684    }
685}
686impl From<OrdinalI32> for i32 {
687    fn from(v: OrdinalI32) -> Self {
688        v.0
689    }
690}
691
692impl From<u32> for OrdinalU32 {
693    fn from(v: u32) -> Self {
694        Self(v)
695    }
696}
697impl From<OrdinalU32> for u32 {
698    fn from(v: OrdinalU32) -> Self {
699        v.0
700    }
701}
702
703impl From<u64> for OrdinalU64 {
704    fn from(v: u64) -> Self {
705        Self(v)
706    }
707}
708impl From<OrdinalU64> for u64 {
709    fn from(v: OrdinalU64) -> Self {
710        v.0
711    }
712}
713
714impl From<String> for CategoricalString {
715    fn from(v: String) -> Self {
716        Self(v)
717    }
718}
719impl From<CategoricalString> for String {
720    fn from(v: CategoricalString) -> Self {
721        v.0
722    }
723}
724impl From<&str> for CategoricalString {
725    fn from(v: &str) -> Self {
726        Self(v.to_string())
727    }
728}
729
730impl From<Vec<u32>> for CpuSet {
731    fn from(v: Vec<u32>) -> Self {
732        Self(v)
733    }
734}
735impl From<CpuSet> for Vec<u32> {
736    fn from(v: CpuSet) -> Self {
737        v.0
738    }
739}
740
741// ---------------------------------------------------------------------------
742// `Display` — delegate to the underlying primitive so the
743// auto-scale ladder and ad-hoc `format!("{}", ...)`
744// callers can render a wrapped value as a bare integer / string
745// without unwrapping `.0`. Wrappers carry semantic category, not
746// formatting policy: a unit-aware render path is the format
747// dispatch, which consults the registry's `unit` tag
748// rather than the wrapper type. `Display` here is the
749// minimal pass-through.
750// ---------------------------------------------------------------------------
751
752macro_rules! impl_display_passthrough {
753    ($t:ident) => {
754        impl std::fmt::Display for $t {
755            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
756                std::fmt::Display::fmt(&self.0, f)
757            }
758        }
759    };
760}
761
762impl_display_passthrough!(MonotonicCount);
763impl_display_passthrough!(MonotonicNs);
764impl_display_passthrough!(ClockTicks);
765impl_display_passthrough!(Bytes);
766impl_display_passthrough!(DeadCounter);
767impl_display_passthrough!(PeakNs);
768impl_display_passthrough!(PeakBytes);
769impl_display_passthrough!(GaugeNs);
770impl_display_passthrough!(GaugeCount);
771impl_display_passthrough!(OrdinalI32);
772impl_display_passthrough!(OrdinalU32);
773impl_display_passthrough!(OrdinalU64);
774impl_display_passthrough!(CategoricalString);
775
776// CpuSet has no canonical Display — the rendered form depends
777// on whether the call site wants `format_cpu_range` ("0-3,5"
778// collapsed runs) or a verbatim debug list. Callers reach for
779// `cpuset.0` and feed it through the appropriate renderer.
780
781// ---------------------------------------------------------------------------
782// Marker traits + reductions
783// ---------------------------------------------------------------------------
784
785/// Private sealing module: the supertraits live here so a
786/// downstream crate cannot bypass the category invariant by
787/// writing `impl Summable for PeakNs`. Adding a new Summable
788/// (or Maxable, Modeable, Rangeable) requires editing this
789/// module — the choke point the type system creates.
790mod sealed {
791    /// Sealed supertrait of [`super::Summable`].
792    pub trait SummableSealed {}
793    /// Sealed supertrait of [`super::Maxable`].
794    pub trait MaxableSealed {}
795    /// Sealed supertrait of [`super::Modeable`].
796    pub trait ModeableSealed {}
797    /// Sealed supertrait of [`super::Rangeable`].
798    pub trait RangeableSealed {}
799}
800
801/// Marker for newtypes that can be summed across a group.
802///
803/// Implemented by [`MonotonicCount`], [`MonotonicNs`],
804/// [`ClockTicks`], and [`Bytes`] — every newtype whose value is
805/// a thread-lifetime accumulator. Summing two such accumulators
806/// across a group is well-defined because both contributors
807/// carry the same temporal window (each thread's lifetime),
808/// and the group total represents the same window union.
809///
810/// Deliberately NOT implemented by [`PeakNs`] / [`GaugeNs`] /
811/// [`GaugeCount`] / [`OrdinalI32`] / [`OrdinalU32`] /
812/// [`OrdinalU64`] / [`CategoricalString`] / [`CpuSet`] — those
813/// reductions are category errors and a generic site bound on
814/// `T: Summable` will reject them at compile time.
815///
816/// Sealed via `sealed::SummableSealed`: a downstream crate
817/// cannot write `impl Summable for PeakNs` because the sealed
818/// supertrait is private to this module.
819///
820/// `sum_across` uses `saturating_add` to mirror the existing
821/// [`crate::ctprof_compare::aggregate`] contract: per-thread
822/// counters are non-negative u64s, the group total cannot exceed
823/// `u64::MAX`, and a hostile or corrupt reading that would push
824/// the sum past `u64::MAX` saturates rather than wrapping.
825///
826/// `sum_across` collapses an empty iterator to the additive
827/// identity (zero, via `Self::default()` shape — the four
828/// counter newtypes default to `Self(0)`). Callers that need
829/// to distinguish "no contributors" from "all contributors had
830/// zero" — for example, to suppress a derived ratio whose
831/// denominator bucket was empty rather than zero-valued — use
832/// [`try_sum_across`](Self::try_sum_across), which returns
833/// `None` for an empty iterator and `Some(total)` otherwise.
834/// The two methods report the same value on every non-empty
835/// input. The `try_` prefix (rather than `checked_`) avoids
836/// colliding with the stdlib's `checked_*` numeric methods,
837/// which detect arithmetic overflow — this method only flags
838/// an empty iterator (saturation happens unconditionally in
839/// `sum_across`).
840#[diagnostic::on_unimplemented(
841    message = "`{Self}` is not Summable — summing it would conflate semantic categories \
842               or temporal windows",
843    label = "this metric type cannot be summed across a group",
844    note = "PeakNs (lifetime high-water): use Maxable::max_across; \
845            GaugeNs/GaugeCount (instantaneous samples — different temporal window than the \
846            lifetime accumulators): use Maxable::max_across; \
847            OrdinalI32/OrdinalU32/OrdinalU64 (bounded scalars): use Rangeable::range_across; \
848            CategoricalString: use Modeable::mode_across; CpuSet: use the \
849            AffinitySummary reduction in ctprof_compare; \
850            DeadCounter: kernel-side dead pointer — value is structurally zero; the \
851            registry must use a no-op aggregation arm (or omit the field) rather than \
852            sum across structural zeros"
853)]
854pub trait Summable: sealed::SummableSealed + Sized + Copy {
855    /// Sum across the iterator, saturating at `u64::MAX`.
856    /// Empty input collapses to the additive identity (zero).
857    fn sum_across(items: impl IntoIterator<Item = Self>) -> Self;
858
859    /// Same total as [`sum_across`](Self::sum_across) on every
860    /// non-empty input; returns `None` for an empty iterator so
861    /// callers can distinguish "no contributors" from "all
862    /// contributors summed to zero." Useful when a downstream
863    /// derived metric (e.g. a ratio) needs to suppress the
864    /// row entirely rather than render `0 / 0`.
865    ///
866    /// The `try_` prefix (rather than `checked_`) avoids
867    /// colliding with the stdlib's `checked_*` numeric methods,
868    /// which detect arithmetic overflow. This method only
869    /// flags an empty iterator — overflow handling is identical
870    /// to `sum_across` (saturating, unconditional).
871    fn try_sum_across(items: impl IntoIterator<Item = Self>) -> Option<Self> {
872        let mut it = items.into_iter();
873        // Relies on Self: Copy (Summable trait bound) so the
874        // next-and-chain pattern works without duplicating the
875        // first element — `it.next()?` consumes the first item
876        // for the empty check, and `iter::once(first)` re-emits
877        // the same value into the chain that feeds sum_across.
878        let first = it.next()?;
879        Some(Self::sum_across(std::iter::once(first).chain(it)))
880    }
881}
882
883/// Marker for newtypes that can be reduced by max across a
884/// group.
885///
886/// Implemented by [`PeakNs`] (max-of-peak is the worst
887/// high-water mark any contributor saw across its lifetime),
888/// [`PeakBytes`] (the byte-typed twin of [`PeakNs`]; max of
889/// per-task hiwater_rss / hiwater_vm), [`GaugeNs`]
890/// (max-of-gauge is the longest current value in the bucket —
891/// distinct temporal window: each gauge is a fresh sample at
892/// capture time, not a lifetime accumulator), and
893/// [`GaugeCount`] (max-of-count is the biggest current value
894/// in the bucket — same gauge-window caveat).
895///
896/// Deliberately NOT implemented by [`Summable`] cumulative
897/// counters ([`MonotonicCount`] / [`MonotonicNs`] /
898/// [`ClockTicks`] / [`Bytes`]): max-across-snapshots on a
899/// thread-lifetime accumulator reduces to "the value of the
900/// last snapshot," because each snapshot's reading dominates
901/// every prior reading by construction (the kernel only ever
902/// raises a lifetime counter). That gives a reduction whose
903/// "maximum" is the most-recent reading rather than a worst
904/// single window — useful as a sanity bound, but rendering it
905/// alongside per-thread peaks invites confusion. If a future
906/// metric truly needs the lifetime-integrated max of a
907/// cumulative counter, introduce a dedicated peak-of-counter
908/// newtype rather than re-adding `Maxable` to a Summable type.
909/// Deliberately NOT implemented by ordinals (those carry a
910/// `[min, max]` range, not a single max), nor by
911/// [`CategoricalString`] (string max has no useful semantic),
912/// nor by [`CpuSet`] (the affinity reduction is a custom
913/// summary, not a bare max).
914///
915/// Sealed via `sealed::MaxableSealed`: a downstream crate
916/// cannot write `impl Maxable for CategoricalString` because the
917/// sealed supertrait is private to this module.
918///
919/// `max_across` returns `Option<Self>`: `None` for an empty
920/// iterator (so callers can distinguish "no contributors" from
921/// "max was zero — the worst reading any contributor reported
922/// happened to be the additive identity"), `Some(largest)`
923/// otherwise. Aggregation callers that want to preserve the
924/// pre-Option contract collapse `None` to the type's
925/// `default()` value at the call site.
926#[diagnostic::on_unimplemented(
927    message = "`{Self}` is not Maxable — `max` is undefined for this category",
928    label = "this metric type does not support max-across",
929    note = "MonotonicCount/MonotonicNs/ClockTicks/Bytes (Summable cumulative counters): \
930            use Summable::sum_across; max-across-snapshots on a lifetime accumulator \
931            reduces to the most-recent reading, not a worst window; \
932            OrdinalI32/OrdinalU32/OrdinalU64: use Rangeable::range_across; \
933            CategoricalString: use Modeable::mode_across; CpuSet: use the \
934            AffinitySummary reduction in ctprof_compare; \
935            DeadCounter: kernel-side dead pointer — value is structurally zero; the \
936            registry must use a no-op aggregation arm (or omit the field) rather than \
937            max across structural zeros"
938)]
939pub trait Maxable: sealed::MaxableSealed + Sized + Copy + Ord {
940    fn max_across(items: impl IntoIterator<Item = Self>) -> Option<Self>;
941}
942
943/// Marker for newtypes reduced by mode (most-frequent value).
944/// Implemented by [`CategoricalString`].
945///
946/// `mode_across` returns `None` when the input iterator is
947/// empty. Ties break by ascending sort order on the value type
948/// to match the existing
949/// [`crate::ctprof_compare::aggregate`]
950/// [`crate::ctprof_compare::AggRule::Mode`] contract:
951/// "lexicographically smaller wins" for equal-frequency strings.
952///
953/// Sealed via `sealed::ModeableSealed`: a downstream crate
954/// cannot write `impl Modeable for u64` because the sealed
955/// supertrait is private to this module.
956#[diagnostic::on_unimplemented(
957    message = "`{Self}` is not Modeable — Modeable is reserved for CategoricalString in this codebase",
958    label = "this metric type does not support mode-across",
959    note = "CategoricalString is the only Modeable type today. Numeric types use Summable / \
960            Maxable / Rangeable depending on category. If a new categorical newtype \
961            needs mode-aggregation, add the impl in metric_types.rs."
962)]
963pub trait Modeable: sealed::ModeableSealed + Sized + Clone + Eq + Ord {
964    /// Returns `(mode_value, count, total)` over the input
965    /// iterator, or `None` when the iterator is empty.
966    fn mode_across(items: impl IntoIterator<Item = Self>) -> Option<(Self, usize, usize)> {
967        use std::collections::BTreeMap;
968        let mut counts: BTreeMap<Self, usize> = BTreeMap::new();
969        let mut total = 0usize;
970        for item in items {
971            *counts.entry(item).or_default() += 1;
972            total += 1;
973        }
974        if total == 0 {
975            return None;
976        }
977        // BTreeMap iterates in ascending key order, so the
978        // sequence of (key, count) pairs walks the candidate
979        // values lex-ascending. The closure `a.1.cmp(&b.1)
980        // .then(b.0.cmp(&a.0))` ranks first by count (higher
981        // wins), then by key (smaller wins). Each key appears
982        // at most once in the map by construction (BTreeMap
983        // dedups on key), so the closure never compares two
984        // entries with identical primary AND secondary keys —
985        // meaning the std-library "max_by keeps the LAST equally-
986        // maximum element" tiebreak is unreachable here. The
987        // closure is a strict total order over the unique keys
988        // and produces the lex-smallest mode at the highest
989        // count.
990        let (value, count) = counts
991            .into_iter()
992            .max_by(|a, b| a.1.cmp(&b.1).then(b.0.cmp(&a.0)))
993            .expect("non-empty inputs produce a non-empty count map");
994        Some((value, count, total))
995    }
996}
997
998/// Inclusive `[min, max]` interval over a [`Rangeable`] type.
999///
1000/// The constructor enforces `min ≤ max`, so a `Range<T>` value
1001/// in hand is a proof that the contained pair is well-ordered;
1002/// downstream consumers can read [`min`](Self::min) /
1003/// [`max`](Self::max) without re-checking. The invariant is
1004/// checked at runtime in debug builds via `debug_assert!`.
1005///
1006/// Construction sites in this crate (the [`Rangeable::range_across`]
1007/// reduction) walk the input iterator and produce a `Range`
1008/// directly; misuse — calling `Range::new(b, a)` with `a < b` —
1009/// is a programmer error and panics in debug, sneaks through in
1010/// release (the wrapped pair is then `[max, min]`, so any caller
1011/// reading [`min`](Self::min) gets the larger value). External
1012/// callers constructing a `Range` from external bounds should
1013/// pre-sort.
1014///
1015/// `Range<T>` deliberately omits `Ord` / `PartialOrd` /
1016/// `Serialize` / `Deserialize`:
1017/// - It is an in-memory aggregation result, not a wire-format
1018///   boundary; the `aggregate()` dispatch destructures `Range`
1019///   into the existing
1020///   [`crate::ctprof_compare::Aggregated::OrdinalRange`]
1021///   variant (which carries `min: i64, max: i64`), so the typed
1022///   invariant is enforced at the reduction boundary and the
1023///   untyped tuple shape continues to cross every serialized
1024///   boundary downstream.
1025/// - Comparing two `Range` values to each other has no defined
1026///   semantic — there is no obvious ordering on intervals — and
1027///   adding `derive(Ord)` would bring [`std::cmp::Ord::min`] /
1028///   [`std::cmp::Ord::max`] into scope on `Range<T>` and shadow
1029///   the inherent accessors at every call site.
1030///
1031/// **Heads-up for future contributors**: if Ord/PartialOrd
1032/// derives are ever added, expect breakage at every existing
1033/// `.min()` / `.max()` call site — those resolve to the
1034/// inherent methods today and will start resolving to the trait
1035/// methods (different signature, different return type) the
1036/// moment the derives are added.
1037#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1038pub struct Range<T: PartialOrd> {
1039    min: T,
1040    max: T,
1041}
1042
1043impl<T: PartialOrd> Range<T> {
1044    /// Construct a `Range` from a `(min, max)` pair.
1045    ///
1046    /// `debug_assert!`s that `min ≤ max` — the [`Rangeable`]
1047    /// reduction guarantees this by walking the input and
1048    /// tracking min and max separately, so the assertion never
1049    /// fires on internal call sites. External callers must
1050    /// pre-sort.
1051    pub fn new(min: T, max: T) -> Self {
1052        debug_assert!(
1053            min.partial_cmp(&max) != Some(std::cmp::Ordering::Greater),
1054            "Range::new requires min <= max — got a min that compares strictly greater"
1055        );
1056        Self { min, max }
1057    }
1058
1059    /// The lower bound of the interval.
1060    pub fn min(&self) -> &T {
1061        &self.min
1062    }
1063
1064    /// The upper bound of the interval.
1065    pub fn max(&self) -> &T {
1066        &self.max
1067    }
1068
1069    /// Consume the range and return the `(min, max)` tuple.
1070    /// Useful at boundaries where the caller has its own
1071    /// pair-shaped representation (e.g. the
1072    /// [`crate::ctprof_compare::Aggregated::OrdinalRange`]
1073    /// variant).
1074    pub fn into_tuple(self) -> (T, T) {
1075        (self.min, self.max)
1076    }
1077}
1078
1079/// Marker for newtypes reduced by `[min, max]` range.
1080/// Implemented by [`OrdinalI32`], [`OrdinalU32`], and
1081/// [`OrdinalU64`].
1082///
1083/// `range_across` returns `Option<Range<Self>>` — `None` for
1084/// an empty iterator, `Some(Range)` otherwise. The wrapped
1085/// `Range` value carries `min ≤ max` as a type-system invariant
1086/// so downstream consumers (the format dispatch, derived
1087/// metrics, the `Aggregated::OrdinalRange` boundary) cannot
1088/// observe a swapped pair. The reduction tracks min and max
1089/// separately while walking the input, so the constructor
1090/// invariant is satisfied by construction.
1091///
1092/// Sealed via `sealed::RangeableSealed`: a downstream crate
1093/// cannot write `impl Rangeable for u64` because the sealed
1094/// supertrait is private to this module.
1095#[diagnostic::on_unimplemented(
1096    message = "`{Self}` is not Rangeable — Rangeable is reserved for bounded ordinals in this codebase",
1097    label = "this metric type does not support range-across",
1098    note = "Counters/peaks/gauges: use Summable::sum_across or Maxable::max_across; \
1099            CategoricalString: use Modeable::mode_across; CpuSet: use the AffinitySummary \
1100            reduction in ctprof_compare"
1101)]
1102pub trait Rangeable: sealed::RangeableSealed + Sized + Copy + Ord {
1103    fn range_across(items: impl IntoIterator<Item = Self>) -> Option<Range<Self>> {
1104        let mut it = items.into_iter();
1105        let first = it.next()?;
1106        let mut min = first;
1107        let mut max = first;
1108        for v in it {
1109            if v < min {
1110                min = v;
1111            }
1112            if v > max {
1113                max = v;
1114            }
1115        }
1116        Some(Range::new(min, max))
1117    }
1118}
1119
1120// Macro for the four cumulative-counter shapes (Summable only,
1121// NOT Maxable — see the Maxable trait doc for the
1122// last-snapshot-dominates-everything rationale). The sealed
1123// supertrait impl gates Summable so external crates can't extend
1124// the trait list outside this module.
1125//
1126// "only" in `impl_summable_only_u64` = Summable-only (i.e. NOT
1127// also Maxable, the natural sibling) — see the Maxable trait
1128// doc for why. Renaming to `impl_summable_not_maxable_u64`
1129// would be more explicit but verbose; the macro body below
1130// shows the trait surface in 3 lines.
1131macro_rules! impl_summable_only_u64 {
1132    ($t:ident) => {
1133        impl sealed::SummableSealed for $t {}
1134        impl Summable for $t {
1135            fn sum_across(items: impl IntoIterator<Item = Self>) -> Self {
1136                let mut total: u64 = 0;
1137                for v in items {
1138                    total = total.saturating_add(v.0);
1139                }
1140                Self(total)
1141            }
1142        }
1143    };
1144}
1145
1146impl_summable_only_u64!(MonotonicCount);
1147impl_summable_only_u64!(MonotonicNs);
1148impl_summable_only_u64!(ClockTicks);
1149impl_summable_only_u64!(Bytes);
1150
1151// Peak / Gauge are Maxable, NOT Summable. `max_across` walks
1152// the input and returns Option<Self> so the empty-iterator case
1153// is distinguishable from "all contributors had zero".
1154macro_rules! impl_maxable_only_u64 {
1155    ($t:ident) => {
1156        impl sealed::MaxableSealed for $t {}
1157        impl Maxable for $t {
1158            fn max_across(items: impl IntoIterator<Item = Self>) -> Option<Self> {
1159                let mut it = items.into_iter();
1160                let first = it.next()?;
1161                let mut out = first;
1162                for v in it {
1163                    if v > out {
1164                        out = v;
1165                    }
1166                }
1167                Some(out)
1168            }
1169        }
1170    };
1171}
1172
1173impl_maxable_only_u64!(PeakNs);
1174impl_maxable_only_u64!(PeakBytes);
1175impl_maxable_only_u64!(GaugeNs);
1176impl_maxable_only_u64!(GaugeCount);
1177
1178impl sealed::RangeableSealed for OrdinalI32 {}
1179impl sealed::RangeableSealed for OrdinalU32 {}
1180impl sealed::RangeableSealed for OrdinalU64 {}
1181impl Rangeable for OrdinalI32 {}
1182impl Rangeable for OrdinalU32 {}
1183impl Rangeable for OrdinalU64 {}
1184
1185impl sealed::ModeableSealed for CategoricalString {}
1186impl Modeable for CategoricalString {}
1187
1188#[cfg(test)]
1189mod tests {
1190    use super::*;
1191
1192    // -- Round-trip From impls -----------------------------------------------
1193
1194    #[test]
1195    fn monotonic_count_from_u64_roundtrips() {
1196        let v: MonotonicCount = 42u64.into();
1197        assert_eq!(v.0, 42);
1198        let back: u64 = v.into();
1199        assert_eq!(back, 42);
1200    }
1201
1202    #[test]
1203    fn monotonic_ns_from_u64_roundtrips() {
1204        let v: MonotonicNs = 1_000_000u64.into();
1205        assert_eq!(v.0, 1_000_000);
1206        let back: u64 = v.into();
1207        assert_eq!(back, 1_000_000);
1208    }
1209
1210    #[test]
1211    fn clock_ticks_from_u64_roundtrips() {
1212        let v: ClockTicks = 1234u64.into();
1213        assert_eq!(v.0, 1234);
1214        let back: u64 = v.into();
1215        assert_eq!(back, 1234);
1216    }
1217
1218    #[test]
1219    fn bytes_from_u64_roundtrips() {
1220        let v: Bytes = (1024 * 1024).into();
1221        assert_eq!(v.0, 1024 * 1024);
1222        let back: u64 = v.into();
1223        assert_eq!(back, 1024 * 1024);
1224    }
1225
1226    #[test]
1227    fn peak_ns_from_u64_roundtrips() {
1228        let v: PeakNs = 999u64.into();
1229        assert_eq!(v.0, 999);
1230        let back: u64 = v.into();
1231        assert_eq!(back, 999);
1232    }
1233
1234    #[test]
1235    fn gauge_ns_from_u64_roundtrips() {
1236        let v: GaugeNs = 7u64.into();
1237        assert_eq!(v.0, 7);
1238        let back: u64 = v.into();
1239        assert_eq!(back, 7);
1240    }
1241
1242    #[test]
1243    fn ordinal_i32_from_i32_roundtrips() {
1244        let v: OrdinalI32 = (-20).into();
1245        assert_eq!(v.0, -20);
1246        let back: i32 = v.into();
1247        assert_eq!(back, -20);
1248    }
1249
1250    #[test]
1251    fn gauge_count_from_u64_roundtrips() {
1252        let v: GaugeCount = 16u64.into();
1253        assert_eq!(v.0, 16);
1254        let back: u64 = v.into();
1255        assert_eq!(back, 16);
1256    }
1257
1258    #[test]
1259    fn ordinal_u32_from_u32_roundtrips() {
1260        let v: OrdinalU32 = 99u32.into();
1261        assert_eq!(v.0, 99);
1262        let back: u32 = v.into();
1263        assert_eq!(back, 99);
1264    }
1265
1266    #[test]
1267    fn ordinal_u64_from_u64_roundtrips() {
1268        let v: OrdinalU64 = 99u64.into();
1269        assert_eq!(v.0, 99);
1270        let back: u64 = v.into();
1271        assert_eq!(back, 99);
1272    }
1273
1274    #[test]
1275    fn categorical_string_from_string_roundtrips() {
1276        let v: CategoricalString = "SCHED_FIFO".to_string().into();
1277        assert_eq!(v.0, "SCHED_FIFO");
1278        let back: String = v.into();
1279        assert_eq!(back, "SCHED_FIFO");
1280    }
1281
1282    #[test]
1283    fn categorical_string_from_str_works() {
1284        let v: CategoricalString = "SCHED_OTHER".into();
1285        assert_eq!(v.0, "SCHED_OTHER");
1286    }
1287
1288    #[test]
1289    fn cpuset_from_vec_roundtrips() {
1290        let cpus = vec![0u32, 1, 2, 3];
1291        let v: CpuSet = cpus.clone().into();
1292        assert_eq!(v.0, cpus);
1293        let back: Vec<u32> = v.into();
1294        assert_eq!(back, cpus);
1295    }
1296
1297    // -- Summable -------------------------------------------------------------
1298
1299    #[test]
1300    fn summable_monotonic_count_sums_to_total() {
1301        let xs = [MonotonicCount(10), MonotonicCount(20), MonotonicCount(30)];
1302        let s = MonotonicCount::sum_across(xs);
1303        assert_eq!(s, MonotonicCount(60));
1304    }
1305
1306    #[test]
1307    fn summable_monotonic_ns_saturates_on_overflow() {
1308        let xs = [MonotonicNs(u64::MAX), MonotonicNs(5)];
1309        let s = MonotonicNs::sum_across(xs);
1310        assert_eq!(s, MonotonicNs(u64::MAX));
1311    }
1312
1313    #[test]
1314    fn summable_clock_ticks_sums() {
1315        let xs = [ClockTicks(100), ClockTicks(50)];
1316        let s = ClockTicks::sum_across(xs);
1317        assert_eq!(s, ClockTicks(150));
1318    }
1319
1320    #[test]
1321    fn summable_bytes_sums() {
1322        let xs = [Bytes(1024), Bytes(2048), Bytes(4096)];
1323        let s = Bytes::sum_across(xs);
1324        assert_eq!(s, Bytes(7168));
1325    }
1326
1327    #[test]
1328    fn summable_empty_iterator_returns_zero() {
1329        let s = MonotonicCount::sum_across(std::iter::empty());
1330        assert_eq!(s, MonotonicCount(0));
1331    }
1332
1333    /// Compile-time gate: counters implement Summable; PeakNs /
1334    /// GaugeNs / GaugeCount / ordinals / categoricals do NOT.
1335    /// The static `assert_summable<T>()` helper compiles only
1336    /// when the type satisfies `T: Summable`, so this test pins
1337    /// the four counter newtypes by exercising the bound. The
1338    /// negative assertion — that `assert_summable::<PeakNs>()`,
1339    /// `assert_summable::<GaugeNs>()`,
1340    /// `assert_summable::<GaugeCount>()` etc. fail to compile —
1341    /// is enforced by the [`sealed::SummableSealed`] supertrait
1342    /// and the omission of those `impl SummableSealed` lines.
1343    /// Adding any one would require an explicit edit to this
1344    /// module's `impl_summable_only_u64!` invocations.
1345    #[test]
1346    fn summable_only_implemented_for_counters() {
1347        fn assert_summable<T: Summable>() {}
1348        assert_summable::<MonotonicCount>();
1349        assert_summable::<MonotonicNs>();
1350        assert_summable::<ClockTicks>();
1351        assert_summable::<Bytes>();
1352    }
1353
1354    #[test]
1355    fn try_sum_across_empty_returns_none() {
1356        let s = MonotonicCount::try_sum_across(std::iter::empty());
1357        assert!(s.is_none());
1358    }
1359
1360    #[test]
1361    fn try_sum_across_non_empty_matches_sum_across() {
1362        let xs = [MonotonicCount(10), MonotonicCount(20), MonotonicCount(30)];
1363        let unchecked = MonotonicCount::sum_across(xs);
1364        let tried = MonotonicCount::try_sum_across(xs).expect("non-empty");
1365        assert_eq!(unchecked, tried);
1366        assert_eq!(tried, MonotonicCount(60));
1367    }
1368
1369    #[test]
1370    fn try_sum_across_saturates_on_overflow() {
1371        let xs = [MonotonicNs(u64::MAX), MonotonicNs(5)];
1372        let s = MonotonicNs::try_sum_across(xs).expect("non-empty");
1373        assert_eq!(s, MonotonicNs(u64::MAX));
1374    }
1375
1376    /// Singleton input still produces `Some(value)` — proves
1377    /// `try_sum_across` does not "consume the first element to
1378    /// test for emptiness" in a way that would lose data.
1379    #[test]
1380    fn try_sum_across_singleton_returns_that_value() {
1381        let s = MonotonicCount::try_sum_across([MonotonicCount(42)]).expect("non-empty");
1382        assert_eq!(s, MonotonicCount(42));
1383    }
1384
1385    /// Compile-time gate: `try_sum_across` is part of the
1386    /// `Summable` trait surface, so every Summable type carries
1387    /// the empty-aware variant for free.
1388    #[test]
1389    fn try_sum_across_available_on_every_summable() {
1390        fn assert_try_sum<T: Summable>() {
1391            let _ = T::try_sum_across(std::iter::empty());
1392        }
1393        assert_try_sum::<MonotonicCount>();
1394        assert_try_sum::<MonotonicNs>();
1395        assert_try_sum::<ClockTicks>();
1396        assert_try_sum::<Bytes>();
1397    }
1398
1399    // -- Maxable --------------------------------------------------------------
1400
1401    #[test]
1402    fn maxable_peak_ns_picks_largest() {
1403        let xs = [PeakNs(100), PeakNs(500), PeakNs(200)];
1404        let m = PeakNs::max_across(xs).expect("non-empty");
1405        assert_eq!(m, PeakNs(500));
1406    }
1407
1408    #[test]
1409    fn maxable_gauge_ns_picks_largest() {
1410        let xs = [GaugeNs(7), GaugeNs(99), GaugeNs(50)];
1411        let m = GaugeNs::max_across(xs).expect("non-empty");
1412        assert_eq!(m, GaugeNs(99));
1413    }
1414
1415    #[test]
1416    fn maxable_gauge_count_picks_largest() {
1417        let xs = [GaugeCount(3), GaugeCount(11), GaugeCount(7)];
1418        let m = GaugeCount::max_across(xs).expect("non-empty");
1419        assert_eq!(m, GaugeCount(11));
1420    }
1421
1422    #[test]
1423    fn maxable_empty_iterator_returns_none() {
1424        let m = PeakNs::max_across(std::iter::empty());
1425        assert!(m.is_none());
1426    }
1427
1428    #[test]
1429    fn maxable_singleton_returns_that_value() {
1430        let m = PeakNs::max_across([PeakNs(42)]).expect("non-empty");
1431        assert_eq!(m, PeakNs(42));
1432    }
1433
1434    /// Singleton with the additive-identity value still produces
1435    /// `Some(zero)` rather than `None` — pins the contract that
1436    /// `None` exclusively signals "empty input," not "max happens
1437    /// to be zero."
1438    #[test]
1439    fn maxable_singleton_zero_returns_some_zero() {
1440        let m = PeakNs::max_across([PeakNs(0)]).expect("non-empty");
1441        assert_eq!(m, PeakNs(0));
1442    }
1443
1444    /// Compile-time gate: Maxable is implemented by exactly the
1445    /// peak / gauge family — `PeakNs`, `PeakBytes`, `GaugeNs`,
1446    /// `GaugeCount`. The four Summable cumulative counter newtypes
1447    /// are deliberately NOT Maxable: a static `assert_maxable<T>()`
1448    /// helper would refuse to compile against `MonotonicCount` /
1449    /// `MonotonicNs` / `ClockTicks` / `Bytes`. The
1450    /// `metric_types_*_not_maxable` fixtures under
1451    /// `tests/compile_fail/` pin the negative side empirically;
1452    /// this test pins the positive side.
1453    #[test]
1454    fn maxable_implemented_for_peaks_and_gauges() {
1455        fn assert_maxable<T: Maxable>() {}
1456        assert_maxable::<PeakNs>();
1457        assert_maxable::<PeakBytes>();
1458        assert_maxable::<GaugeNs>();
1459        assert_maxable::<GaugeCount>();
1460    }
1461
1462    // -- Rangeable ------------------------------------------------------------
1463
1464    #[test]
1465    fn rangeable_ordinal_i32_finds_min_max() {
1466        let xs = [
1467            OrdinalI32(-5),
1468            OrdinalI32(10),
1469            OrdinalI32(0),
1470            OrdinalI32(-20),
1471        ];
1472        let r = OrdinalI32::range_across(xs).expect("non-empty");
1473        assert_eq!(*r.min(), OrdinalI32(-20));
1474        assert_eq!(*r.max(), OrdinalI32(10));
1475    }
1476
1477    #[test]
1478    fn rangeable_ordinal_u32_finds_min_max() {
1479        let xs = [OrdinalU32(7), OrdinalU32(3), OrdinalU32(15)];
1480        let r = OrdinalU32::range_across(xs).expect("non-empty");
1481        assert_eq!(*r.min(), OrdinalU32(3));
1482        assert_eq!(*r.max(), OrdinalU32(15));
1483    }
1484
1485    #[test]
1486    fn rangeable_ordinal_u64_finds_min_max() {
1487        let xs = [
1488            OrdinalU64(50),
1489            OrdinalU64(99),
1490            OrdinalU64(0),
1491            OrdinalU64(25),
1492        ];
1493        let r = OrdinalU64::range_across(xs).expect("non-empty");
1494        assert_eq!(*r.min(), OrdinalU64(0));
1495        assert_eq!(*r.max(), OrdinalU64(99));
1496    }
1497
1498    #[test]
1499    fn rangeable_singleton_min_eq_max() {
1500        let r = OrdinalI32::range_across([OrdinalI32(42)]).expect("non-empty");
1501        assert_eq!(*r.min(), OrdinalI32(42));
1502        assert_eq!(*r.max(), OrdinalI32(42));
1503    }
1504
1505    #[test]
1506    fn rangeable_empty_iterator_returns_none() {
1507        let r = OrdinalI32::range_across(std::iter::empty());
1508        assert!(r.is_none());
1509    }
1510
1511    /// `Range::new` enforces `min ≤ max` via `debug_assert!`, so
1512    /// the type-system invariant matches the runtime check in
1513    /// debug builds. Pins the constructor's debug-build behavior.
1514    #[test]
1515    #[should_panic(expected = "min <= max")]
1516    fn range_new_debug_asserts_min_le_max_when_swapped() {
1517        let _ = Range::new(OrdinalI32(10), OrdinalI32(5));
1518    }
1519
1520    #[test]
1521    fn range_new_min_eq_max_is_allowed() {
1522        let r = Range::new(OrdinalI32(42), OrdinalI32(42));
1523        assert_eq!(*r.min(), OrdinalI32(42));
1524        assert_eq!(*r.max(), OrdinalI32(42));
1525    }
1526
1527    #[test]
1528    fn range_into_tuple_preserves_pair() {
1529        let r = Range::new(OrdinalU32(3), OrdinalU32(15));
1530        let (min, max) = r.into_tuple();
1531        assert_eq!(min, OrdinalU32(3));
1532        assert_eq!(max, OrdinalU32(15));
1533    }
1534
1535    /// `range_across` always satisfies `min ≤ max` because it
1536    /// tracks `min` and `max` separately while walking the input
1537    /// — the constructor's `debug_assert!` never fires on the
1538    /// reduction path. Pin this by exercising a worst-case
1539    /// reverse-sorted input.
1540    #[test]
1541    fn range_across_preserves_min_le_max_on_reversed_input() {
1542        let xs = [
1543            OrdinalI32(99),
1544            OrdinalI32(10),
1545            OrdinalI32(0),
1546            OrdinalI32(-20),
1547        ];
1548        let r = OrdinalI32::range_across(xs).expect("non-empty");
1549        assert!(r.min() <= r.max());
1550        assert_eq!(*r.min(), OrdinalI32(-20));
1551        assert_eq!(*r.max(), OrdinalI32(99));
1552    }
1553
1554    // -- DeadCounter ----------------------------------------------------------
1555
1556    #[test]
1557    fn dead_counter_from_u64_roundtrips() {
1558        let v: DeadCounter = 0u64.into();
1559        assert_eq!(v.0, 0);
1560        let back: u64 = v.into();
1561        assert_eq!(back, 0);
1562    }
1563
1564    /// Wire format for [`DeadCounter`] matches a bare `u64`,
1565    /// identical to [`MonotonicCount`]. The type-system
1566    /// difference is in the trait list (no Summable / Maxable /
1567    /// Rangeable / Modeable impl), not in the wire bytes.
1568    #[test]
1569    fn dead_counter_serde_transparent() {
1570        let v = DeadCounter(0);
1571        let json = serde_json::to_string(&v).expect("serialize");
1572        assert_eq!(json, "0");
1573        let back: DeadCounter = serde_json::from_str(&json).expect("deserialize");
1574        assert_eq!(v, back);
1575
1576        // Non-zero round-trip even though the kernel write path is
1577        // dead — the wire format must be identical to MonotonicCount
1578        // so the future migration can flip a field's wrapper without
1579        // regenerating snapshot files.
1580        let nonzero = DeadCounter(42);
1581        let nonzero_json = serde_json::to_string(&nonzero).expect("serialize");
1582        assert_eq!(nonzero_json, "42");
1583        let nonzero_back: DeadCounter = serde_json::from_str(&nonzero_json).expect("deserialize");
1584        assert_eq!(nonzero, nonzero_back);
1585    }
1586
1587    #[test]
1588    fn dead_counter_default_is_zero() {
1589        assert_eq!(DeadCounter::default(), DeadCounter(0));
1590    }
1591
1592    #[test]
1593    fn dead_counter_repr_transparent_size() {
1594        use std::mem::size_of;
1595        assert_eq!(size_of::<DeadCounter>(), size_of::<u64>());
1596    }
1597
1598    #[test]
1599    fn dead_counter_display_passthrough() {
1600        assert_eq!(format!("{}", DeadCounter(0)), "0");
1601        assert_eq!(format!("{}", DeadCounter(42)), "42");
1602    }
1603
1604    // -- Modeable -------------------------------------------------------------
1605
1606    #[test]
1607    fn modeable_categorical_string_picks_most_frequent() {
1608        let xs = [
1609            CategoricalString::from("SCHED_OTHER"),
1610            CategoricalString::from("SCHED_OTHER"),
1611            CategoricalString::from("SCHED_FIFO"),
1612        ];
1613        let (value, count, total) = CategoricalString::mode_across(xs).expect("non-empty");
1614        assert_eq!(value, CategoricalString::from("SCHED_OTHER"));
1615        assert_eq!(count, 2);
1616        assert_eq!(total, 3);
1617    }
1618
1619    /// Tie-break: equal counts → lex-smallest wins. Mirrors the
1620    /// ctprof_compare::aggregate(AggRule::Mode) rule (see
1621    /// `mode_rule_tie_break_is_lexicographic` over there).
1622    #[test]
1623    fn modeable_tie_break_is_lex_smallest() {
1624        let xs = [
1625            CategoricalString::from("SCHED_OTHER"),
1626            CategoricalString::from("SCHED_FIFO"),
1627        ];
1628        let (value, count, total) = CategoricalString::mode_across(xs).expect("non-empty");
1629        assert_eq!(value, CategoricalString::from("SCHED_FIFO"));
1630        assert_eq!(count, 1);
1631        assert_eq!(total, 2);
1632    }
1633
1634    #[test]
1635    fn modeable_empty_iterator_returns_none() {
1636        let r = CategoricalString::mode_across(std::iter::empty());
1637        assert!(r.is_none());
1638    }
1639
1640    #[test]
1641    fn modeable_unanimous_returns_total() {
1642        let xs = [
1643            CategoricalString::from("R"),
1644            CategoricalString::from("R"),
1645            CategoricalString::from("R"),
1646        ];
1647        let (value, count, total) = CategoricalString::mode_across(xs).expect("non-empty");
1648        assert_eq!(value, CategoricalString::from("R"));
1649        assert_eq!(count, 3);
1650        assert_eq!(total, 3);
1651    }
1652
1653    // -- repr(transparent) wire compatibility --------------------------------
1654
1655    /// Serde transparent: every newtype must serialize identically
1656    /// to its primitive. Pin the JSON shape so the
1657    /// `ThreadState` migration preserves existing
1658    /// snapshot files.
1659    #[test]
1660    fn serde_transparent_matches_primitive() {
1661        let raw_count = MonotonicCount(123);
1662        let raw_count_json = serde_json::to_string(&raw_count).expect("serialize");
1663        assert_eq!(raw_count_json, "123");
1664
1665        let raw_ns = MonotonicNs(456_789);
1666        let raw_ns_json = serde_json::to_string(&raw_ns).expect("serialize");
1667        assert_eq!(raw_ns_json, "456789");
1668
1669        let raw_ticks = ClockTicks(2048);
1670        let raw_ticks_json = serde_json::to_string(&raw_ticks).expect("serialize");
1671        assert_eq!(raw_ticks_json, "2048");
1672
1673        let raw_bytes = Bytes(1024 * 1024);
1674        let raw_bytes_json = serde_json::to_string(&raw_bytes).expect("serialize");
1675        assert_eq!(raw_bytes_json, "1048576");
1676
1677        let raw_dead = DeadCounter(7);
1678        let raw_dead_json = serde_json::to_string(&raw_dead).expect("serialize");
1679        assert_eq!(raw_dead_json, "7");
1680
1681        let raw_peak = PeakNs(99);
1682        let raw_peak_json = serde_json::to_string(&raw_peak).expect("serialize");
1683        assert_eq!(raw_peak_json, "99");
1684
1685        let raw_peak_bytes = PeakBytes(2_097_152);
1686        let raw_peak_bytes_json = serde_json::to_string(&raw_peak_bytes).expect("serialize");
1687        assert_eq!(raw_peak_bytes_json, "2097152");
1688
1689        let raw_gauge = GaugeNs(7_500_000);
1690        let raw_gauge_json = serde_json::to_string(&raw_gauge).expect("serialize");
1691        assert_eq!(raw_gauge_json, "7500000");
1692
1693        let raw_gauge_count = GaugeCount(16);
1694        let raw_gauge_count_json = serde_json::to_string(&raw_gauge_count).expect("serialize");
1695        assert_eq!(raw_gauge_count_json, "16");
1696
1697        let raw_ordi = OrdinalI32(-5);
1698        let raw_ordi_json = serde_json::to_string(&raw_ordi).expect("serialize");
1699        assert_eq!(raw_ordi_json, "-5");
1700
1701        let raw_ordu32 = OrdinalU32(99);
1702        let raw_ordu32_json = serde_json::to_string(&raw_ordu32).expect("serialize");
1703        assert_eq!(raw_ordu32_json, "99");
1704
1705        let raw_ordu64 = OrdinalU64(99);
1706        let raw_ordu64_json = serde_json::to_string(&raw_ordu64).expect("serialize");
1707        assert_eq!(raw_ordu64_json, "99");
1708
1709        let raw_str = CategoricalString::from("R");
1710        let raw_str_json = serde_json::to_string(&raw_str).expect("serialize");
1711        assert_eq!(raw_str_json, "\"R\"");
1712
1713        let raw_cpus = CpuSet(vec![0, 2, 4]);
1714        let raw_cpus_json = serde_json::to_string(&raw_cpus).expect("serialize");
1715        assert_eq!(raw_cpus_json, "[0,2,4]");
1716    }
1717
1718    /// Round-trip via JSON: serialize then deserialize must
1719    /// produce an equal value. Defends against an asymmetric
1720    /// transparent attribute (e.g. only on Serialize) that
1721    /// would silently produce different wire formats.
1722    #[test]
1723    fn serde_round_trip_through_json() {
1724        let v = MonotonicNs(987_654_321);
1725        let json = serde_json::to_string(&v).expect("serialize");
1726        let back: MonotonicNs = serde_json::from_str(&json).expect("deserialize");
1727        assert_eq!(v, back);
1728
1729        let s = CategoricalString::from("SCHED_DEADLINE");
1730        let json = serde_json::to_string(&s).expect("serialize");
1731        let back: CategoricalString = serde_json::from_str(&json).expect("deserialize");
1732        assert_eq!(s, back);
1733    }
1734
1735    /// repr(transparent) means each u64-backed newtype occupies
1736    /// exactly one u64 of memory. Pin this so a future derive
1737    /// addition that displaced the layout (e.g. forgetting
1738    /// repr(transparent) when adding fields) fails the test.
1739    #[test]
1740    fn repr_transparent_matches_primitive_size() {
1741        use std::mem::size_of;
1742        assert_eq!(size_of::<MonotonicCount>(), size_of::<u64>());
1743        assert_eq!(size_of::<MonotonicNs>(), size_of::<u64>());
1744        assert_eq!(size_of::<ClockTicks>(), size_of::<u64>());
1745        assert_eq!(size_of::<Bytes>(), size_of::<u64>());
1746        assert_eq!(size_of::<DeadCounter>(), size_of::<u64>());
1747        assert_eq!(size_of::<PeakNs>(), size_of::<u64>());
1748        assert_eq!(size_of::<PeakBytes>(), size_of::<u64>());
1749        assert_eq!(size_of::<GaugeNs>(), size_of::<u64>());
1750        assert_eq!(size_of::<GaugeCount>(), size_of::<u64>());
1751        assert_eq!(size_of::<OrdinalI32>(), size_of::<i32>());
1752        assert_eq!(size_of::<OrdinalU32>(), size_of::<u32>());
1753        assert_eq!(size_of::<OrdinalU64>(), size_of::<u64>());
1754    }
1755
1756    /// Default values are zero / empty — pin so a future change
1757    /// that shifts the default (e.g. signaling "no data" with a
1758    /// sentinel) doesn't slip in unnoticed.
1759    #[test]
1760    fn defaults_are_zero_or_empty() {
1761        assert_eq!(MonotonicCount::default(), MonotonicCount(0));
1762        assert_eq!(MonotonicNs::default(), MonotonicNs(0));
1763        assert_eq!(ClockTicks::default(), ClockTicks(0));
1764        assert_eq!(Bytes::default(), Bytes(0));
1765        assert_eq!(DeadCounter::default(), DeadCounter(0));
1766        assert_eq!(PeakNs::default(), PeakNs(0));
1767        assert_eq!(PeakBytes::default(), PeakBytes(0));
1768        assert_eq!(GaugeNs::default(), GaugeNs(0));
1769        assert_eq!(GaugeCount::default(), GaugeCount(0));
1770        assert_eq!(OrdinalI32::default(), OrdinalI32(0));
1771        assert_eq!(OrdinalU32::default(), OrdinalU32(0));
1772        assert_eq!(OrdinalU64::default(), OrdinalU64(0));
1773        assert_eq!(CategoricalString::default(), CategoricalString::from(""));
1774        assert_eq!(CpuSet::default(), CpuSet(vec![]));
1775    }
1776}