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}