ktstr/ctprof_compare/
options.rs

1//! User-facing comparison configuration.
2//!
3//! Holds the operator-supplied knobs that drive [`super::compare()`]
4//! and the aggregation rule taxonomy that the metric registry
5//! ([`super::CTPROF_METRICS`]) is parameterized over.
6//!
7//! Three layers:
8//!
9//! 1. [`GroupBy`] + [`GroupByOrDefault`] + [`CompareOptions`] +
10//!    [`SortKey`] — the inputs the operator types into the CLI
11//!    or constructs programmatically; [`super::compare()`] receives
12//!    a `&CompareOptions` and dispatches grouping / sort behavior
13//!    accordingly. [`GroupByOrDefault`] is the newtype wrapper
14//!    that gives [`CompareOptions::default`] a meaningful default
15//!    grouping (`GroupBy::Pcomm`) without requiring every field
16//!    to be spelled out.
17//!
18//! 2. [`AggRule`] — closed enumeration of per-metric reductions
19//!    over a thread bucket. Each variant carries a typed accessor
20//!    `fn(&ThreadState) -> SomeNewtype` from [`crate::metric_types`]
21//!    so the compiler enforces wrapper / reducer pairing at
22//!    registry-build time. The dispatch lives in
23//!    [`super::aggregate()`].
24//!
25//! 3. [`AggRule::ladder`] — the per-variant
26//!    [`super::ScaleLadder`] mapping consumed by the cell formatters
27//!    in [`super::scale`]. Co-located with [`AggRule`] (rather than
28//!    in [`super::scale`]) so a future contributor adding a new
29//!    `AggRule` variant updates the ladder dispatch in the same
30//!    file as the variant — the compiler's exhaustiveness check on
31//!    the closed match catches the omission immediately. Splitting
32//!    the impl into [`super::scale`] would create a back-edge
33//!    (scale → AggRule) that obscures this discipline.
34
35use crate::ctprof::ThreadState;
36
37use super::ScaleLadder;
38
39/// Grouping key for the ctprof compare.
40///
41/// The default is [`GroupBy::Pcomm`] — aggregate every thread
42/// belonging to the same process name together with token-based
43/// pattern normalization, so ephemeral worker pools whose pcomm
44/// differs only by digit-suffix collapse across snapshots. The
45/// other variants exist for operators who want to slice along a
46/// different axis: `Cgroup` groups by cgroup path (useful for
47/// container-per-workload deployments); `Comm` groups by thread
48/// name across every process with the same token-based pattern
49/// normalization (so `tokio-worker-{0..N}` collapse into one
50/// `tokio-worker-{N}` bucket and `kworker/0:1H-events_highpri`,
51/// `kworker/1:0H-events_highpri`, ... collapse into one
52/// `kworker/{N}:{N}H-events_highpri` bucket); `CommExact` groups
53/// by literal thread name (useful when distinct token values
54/// carry meaning that the normalizer would erase, e.g. tracking
55/// each per-CPU `kworker/u8:N` independently).
56#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
57pub enum GroupBy {
58    /// Group by process name (`pcomm`). Default grouping: pcomm
59    /// is the leader thread's `task->comm`, read from
60    /// `/proc/<tgid>/comm` at capture time (see
61    /// [`crate::ctprof::ThreadState::pcomm`]). Per-thread `comm`
62    /// values, by contrast, can drift over a process's lifetime
63    /// (worker threads reset their comm under load, `taskset`
64    /// toggles names, etc.); the leader's comm is the
65    /// per-process identity captured at snapshot time and stays
66    /// constant across that snapshot. Pcomm grouping is therefore
67    /// the most reliable axis for "give me the per-process
68    /// resource picture", which is why it's the default.
69    ///
70    /// Naive pcomm grouping has one common failure mode: workers
71    /// with digit suffixes (`worker-0`, `worker-1`, ...) each
72    /// land in their own bucket and the per-pool aggregate gets
73    /// scattered across N rows. Token-based pattern
74    /// normalization handles this: pcomms that produce the same
75    /// skeleton under `pattern_key`'s normalizer cluster into
76    /// one bucket whose internal join key is the skeleton. The
77    /// normalizer splits each pcomm on a separator class
78    /// (`[.\-_/:@+\[\]\s]+`) and classifies each token as
79    /// pure-digit (`{N}`), hex-like (`{H}`),
80    /// alpha-prefix-plus-digits (`prefix{N}`),
81    /// digits-plus-alpha-suffix (`{N}suffix`), or literal —
82    /// identical rules to the [`Comm`](Self::Comm) axis.
83    /// Singleton buckets revert to the literal pcomm so a lone
84    /// process stays ungrouped instead of advertising a
85    /// `worker-{N}` pattern that no other process shares.
86    /// Display labels are generated by `grex` for buckets with
87    /// ≥ 2 distinct member pcomms; the rendered label is a
88    /// regex showing the shared prefix + alternation, while the
89    /// join key remains deterministic across snapshots. Disable
90    /// normalization with [`CompareOptions::no_thread_normalize`]
91    /// to group by literal `pcomm`.
92    Pcomm,
93    /// Group by cgroup path. Cgroup-level enrichment is surfaced
94    /// in the output alongside the aggregated thread metrics.
95    Cgroup,
96    /// Group by thread name pattern across every process. Threads
97    /// whose names produce the same skeleton under
98    /// `pattern_key`'s token-based normalizer cluster into one
99    /// bucket whose internal join key is the skeleton. The
100    /// normalizer splits each comm on a separator class
101    /// (`[.\-_/:@+\[\]\s]+`) and classifies each token as pure-digit
102    /// (`{N}`), hex-like (`{H}`), alpha-prefix-plus-digits
103    /// (`prefix{N}`), digits-plus-alpha-suffix (`{N}suffix`), or
104    /// literal. Singleton buckets revert to the literal thread
105    /// name so a lone worker stays ungrouped.
106    /// Display labels are generated by `grex` for buckets with ≥2
107    /// distinct member names; the rendered label is a regex
108    /// showing the shared prefix + alternation, while the join key
109    /// remains deterministic across snapshots. Disable
110    /// normalization with
111    /// [`CompareOptions::no_thread_normalize`].
112    Comm,
113    /// Group by literal thread name (`comm`) — exact match, no
114    /// pattern aggregation. Use this when distinct token values
115    /// carry meaning the normalizer would erase, e.g. tracking each
116    /// per-CPU `kworker/u8:N` independently rather than collapsing
117    /// the fleet into one `kworker/u{N}:{N}` bucket.
118    ///
119    /// Distinct from `--group-by comm --no-thread-normalize`:
120    /// this variant ONLY disables thread-axis normalization,
121    /// leaving the smaps_rollup pcomm keying still normalized
122    /// (per `collect_smaps_rollup`). The
123    /// `--no-thread-normalize` flag, by contrast, disables
124    /// normalization across every name-family axis (Comm, Pcomm,
125    /// AND smaps_rollup). Pick `CommExact` when you want literal
126    /// thread names but still want smaps to join across
127    /// snapshots; pick `Comm + no_thread_normalize` when you
128    /// also want literal smaps PID identity.
129    CommExact,
130    /// Run all three pattern-aware axes (Cgroup → Pcomm → Comm)
131    /// and render each as a labeled block. Gives a comprehensive
132    /// at-a-glance summary without re-running with different
133    /// `--group-by` flags. Each axis gets its own `## Primary
134    /// metrics` section, independently truncated by `--limit`.
135    All,
136}
137
138/// Options controlling `compare`.
139#[derive(Debug, Clone, Default)]
140#[non_exhaustive]
141pub struct CompareOptions {
142    pub group_by: GroupByOrDefault,
143    /// Glob patterns that collapse dynamic cgroup path segments
144    /// to a canonical form before grouping. Tried in listed
145    /// order; the first pattern that matches a thread's cgroup
146    /// path replaces the whole path with the pattern string, so
147    /// paths differing only in wildcard-matched segments collapse
148    /// onto one key. A path matching no pattern is returned
149    /// verbatim. See `flatten_cgroup_path` for the rewrite rule
150    /// and examples.
151    ///
152    /// Independent of [`Self::no_cg_normalize`] — explicit
153    /// glob patterns apply first; auto-normalization (token-based)
154    /// runs after, gated by `no_cg_normalize`.
155    pub cgroup_flatten: Vec<String>,
156    /// When true, disable token-based pattern normalization
157    /// across every name-family axis: [`GroupBy::Comm`],
158    /// [`GroupBy::Pcomm`], AND the smaps_rollup keying in
159    /// `collect_smaps_rollup` (which keys by
160    /// `pattern_key(&t.pcomm)` under default normalization, but
161    /// reverts to literal `pcomm[tgid]` when this flag is set so
162    /// each PID stays attributable).
163    ///
164    /// Under this flag: threads / processes group by their
165    /// literal name; smaps rows preserve their per-PID identity.
166    /// The pure-digit/hex/alpha+digits placeholders never fire on
167    /// any of those axes. Mirror of [`Self::no_cg_normalize`] for
168    /// the thread / process axes. Has no effect under
169    /// [`GroupBy::CommExact`] (already literal) or
170    /// [`GroupBy::Cgroup`].
171    pub no_thread_normalize: bool,
172    /// When true, disable token-based pattern normalization for
173    /// cgroup-path grouping ([`GroupBy::Cgroup`]). Cgroup paths
174    /// group by their literal post-flatten path (no Layer 1, 2,
175    /// or 3 substitutions). Explicit `cgroup_flatten` glob
176    /// patterns still apply. Has no effect under other groupings.
177    pub no_cg_normalize: bool,
178    /// Multi-key sort spec for the diff rows. When non-empty,
179    /// overrides the default `delta_pct desc` sort. Each
180    /// [`SortKey`] names one metric from
181    /// `CTPROF_METRICS` or `CTPROF_DERIVED_METRICS`
182    /// and a direction; groups rank by the tuple
183    /// (`metric_1_delta`, `metric_2_delta`, ...) under
184    /// lexicographic order with per-key direction. Within a
185    /// group, rows appear in registry order. The sort
186    /// composes with [`Self::group_by`]: groups are formed under
187    /// the chosen axis (pcomm / cgroup / comm / comm-exact) and
188    /// then ranked by their aggregated metric values, so the
189    /// same `sort_by` spec works under every grouping. See
190    /// `parse_sort_by` for the CLI string parser.
191    pub sort_by: Vec<SortKey>,
192}
193
194/// One key in a multi-key `--sort-by` spec. Names a metric from
195/// `CTPROF_METRICS` or `CTPROF_DERIVED_METRICS` and
196/// the sort direction for that key. Direction defaults to
197/// descending (largest delta first) so the common operator
198/// request — "show me the biggest regressions first" — is the
199/// unmarked form.
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub struct SortKey {
202    /// Metric name. Holds one of the `CTPROF_METRICS` or
203    /// `CTPROF_DERIVED_METRICS` entries' `name` fields
204    /// verbatim — `parse_sort_by` looks up the input string in
205    /// either registry and stores the matched `&'static str`, so
206    /// this never carries an allocation. Equality against a
207    /// registry `name` is by content (`str::eq`); both sides
208    /// reference the same `&'static str` from the registry, so
209    /// the byte-by-byte comparison succeeds in O(name.len())
210    /// without any heap access. The two registries are disjoint
211    /// (the `registry_and_derived_names_disjoint` test pins
212    /// this) so a `metric` value resolves unambiguously to one
213    /// or the other.
214    pub metric: &'static str,
215    /// True for descending (largest first), false for ascending
216    /// (smallest first).
217    pub descending: bool,
218}
219
220/// Newtype wrapper around [`GroupBy`] that defaults to
221/// [`GroupBy::Pcomm`]. Separate type so `CompareOptions::default()`
222/// does not need to spell out every field.
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub struct GroupByOrDefault(pub GroupBy);
225
226impl Default for GroupByOrDefault {
227    /// Defaults to [`GroupBy::Pcomm`] — parent-comm is the most
228    /// stable per-process identity across versions / runs (a
229    /// kernel-build version change typically does NOT rename
230    /// parent comms, whereas pid values shuffle every run and
231    /// comm values can drift if the binary is renamed). When two
232    /// ctprof runs are compared, grouping by Pcomm aligns
233    /// equivalent workloads even when their per-invocation pids
234    /// differ.
235    fn default() -> Self {
236        Self(GroupBy::Pcomm)
237    }
238}
239
240impl From<GroupBy> for GroupByOrDefault {
241    fn from(g: GroupBy) -> Self {
242        Self(g)
243    }
244}
245
246/// Aggregation rule for a single metric.
247///
248/// Encoded as an enum rather than a trait object so the registry
249/// table (`CTPROF_METRICS`) can live in static memory. Each
250/// variant's accessor returns the typed
251/// [`crate::metric_types`] newtype that matches the reduction
252/// — the reader and rule are paired by construction so a new
253/// metric cannot register a peak field against a sum reducer
254/// (`SumNs(|t| t.wait_max)` fails to compile because `wait_max`
255/// is `PeakNs`, not `MonotonicNs`).
256///
257/// Each variant maps 1:1 to a marker trait in
258/// [`crate::metric_types`]: `Sum*` variants take a [`Summable`]
259/// type, `Max*` variants take a [`Maxable`] type that is NOT
260/// also `Summable` (counters use `Sum*` even though they
261/// implement both — registering a counter as `Max*` would mask
262/// the sum semantics with the per-contributor maximum), `Range*`
263/// variants take a [`Rangeable`] type, `Mode*` variants take a
264/// [`Modeable`] type or a primitive that the dispatch coerces to
265/// `String`, and [`AggRule::Affinity`] takes the dedicated
266/// [`crate::metric_types::CpuSet`] for the affinity-summary
267/// reduction.
268///
269/// [`Summable`]: crate::metric_types::Summable
270/// [`Maxable`]: crate::metric_types::Maxable
271/// [`Rangeable`]: crate::metric_types::Rangeable
272/// [`Modeable`]: crate::metric_types::Modeable
273#[derive(Debug, Clone, Copy)]
274pub enum AggRule {
275    /// Sum across the group of a [`MonotonicCount`] field. Used
276    /// for unitless cumulative counters (`nr_wakeups`,
277    /// `voluntary_csw`, `minflt`, syscall counts, …). The
278    /// dispatch routes through
279    /// [`crate::metric_types::Summable::sum_across`] which uses
280    /// `saturating_add` per the no-wraparound contract.
281    ///
282    /// [`MonotonicCount`]: crate::metric_types::MonotonicCount
283    SumCount(fn(&ThreadState) -> crate::metric_types::MonotonicCount),
284    /// Sum across the group of a [`MonotonicNs`] field. Used for
285    /// cumulative-time counters in nanoseconds (`run_time_ns`,
286    /// `wait_time_ns`, `wait_sum`, `voluntary_sleep_ns`,
287    /// `block_sum`, `iowait_sum`, `core_forceidle_sum`).
288    ///
289    /// [`MonotonicNs`]: crate::metric_types::MonotonicNs
290    SumNs(fn(&ThreadState) -> crate::metric_types::MonotonicNs),
291    /// Sum across the group of a [`ClockTicks`] field. Used for
292    /// USER_HZ-scaled cumulative time counters
293    /// (`utime_clock_ticks`, `stime_clock_ticks`).
294    ///
295    /// [`ClockTicks`]: crate::metric_types::ClockTicks
296    SumTicks(fn(&ThreadState) -> crate::metric_types::ClockTicks),
297    /// Sum across the group of a [`Bytes`] field. Used for
298    /// IEC-binary-scaled byte counters (`allocated_bytes`,
299    /// `deallocated_bytes`, `rchar`, `wchar`, `read_bytes`,
300    /// `write_bytes`, `cancelled_write_bytes`).
301    ///
302    /// [`Bytes`]: crate::metric_types::Bytes
303    SumBytes(fn(&ThreadState) -> crate::metric_types::Bytes),
304    /// Maximum across the group of a [`PeakNs`] field — the
305    /// kernel `*_max` schedstats (`wait_max`, `sleep_max`,
306    /// `block_max`, `exec_max`, `slice_max`). Each thread
307    /// already carries its own lifetime max-seen value from the
308    /// kernel's scheduler call sites (e.g. `update_se` in
309    /// `kernel/sched/fair.c` for `exec_max`; see
310    /// `struct sched_statistics` in `include/linux/sched.h`).
311    /// Group-level reduction takes the largest across members so
312    /// a row surfaces the worst single window any thread in the
313    /// group has ever experienced. Summing per-thread maxes
314    /// would conflate "one thread with a 1s spike" with "1000
315    /// threads with 1ms spikes each" — `PeakNs` therefore does
316    /// NOT implement `Summable`, and trying to register one as
317    /// `SumNs` is a compile error.
318    ///
319    /// [`PeakNs`]: crate::metric_types::PeakNs
320    MaxPeak(fn(&ThreadState) -> crate::metric_types::PeakNs),
321    /// Maximum across the group of a [`PeakBytes`] field — the
322    /// byte-typed twin of `MaxPeak`. Used for taskstats-sourced
323    /// lifetime memory watermarks (`hiwater_rss_bytes`,
324    /// `hiwater_vm_bytes`).
325    /// `xacct_add_tsk` (`kernel/tsacct.c::xacct_add_tsk`, lines
326    /// 99-104) reads the watermark out of the SHARED `mm_struct`
327    /// via `get_mm_hiwater_rss(mm)` / `get_mm_hiwater_vm(mm)`, so
328    /// sibling threads of the same tgid all report the same
329    /// value; cross-thread Max within a single process is a no-op.
330    /// Cross-PROCESS Max (e.g. under `--group-by pcomm` when the
331    /// bucket spans multiple parent processes) is the meaningful
332    /// reduction: it picks the largest watermark any tgid in the
333    /// bucket reported. Routes through the IEC binary auto-scale
334    /// ladder (`crate::metric_types::ScaleLadder::Bytes`) so a
335    /// 7.5 GiB watermark renders as `7.500GiB` instead of
336    /// dominating the table with raw byte counts. Summing
337    /// watermarks would over-count shared address-space mappings
338    /// across sibling threads N-fold — `PeakBytes` does NOT
339    /// implement `Summable`.
340    ///
341    /// [`PeakBytes`]: crate::metric_types::PeakBytes
342    MaxPeakBytes(fn(&ThreadState) -> crate::metric_types::PeakBytes),
343    /// Maximum across the group of a [`GaugeNs`] field —
344    /// instantaneous-time gauges where summing is meaningless.
345    /// `fair_slice_ns` is the per-thread CURRENT scheduler slice
346    /// (stale under SCHED_EXT — see field doc) read at capture
347    /// time, not a high-water value. Summing instantaneous
348    /// gauges produces a number with no physical meaning — N
349    /// nearly-identical instantaneous values sum to `N * gauge`
350    /// regardless of group composition, drowning the signal.
351    /// Max instead surfaces "the longest current slice any
352    /// thread in the bucket is running with", which IS the
353    /// signal a user comparing two snapshots cares about.
354    ///
355    /// [`GaugeNs`]: crate::metric_types::GaugeNs
356    MaxGaugeNs(fn(&ThreadState) -> crate::metric_types::GaugeNs),
357    /// Maximum across the group of a [`GaugeCount`] field —
358    /// leader-deduped structural counts. `nr_threads` is
359    /// populated only on the tgid leader (`tid == tgid`) and
360    /// zero on every non-leader thread of the same process; see
361    /// `capture_thread_at_with_tally`. Sum across a comm- or
362    /// cgroup-bucketed group would render 0 for any bucket
363    /// whose leader fell elsewhere because non-leader members
364    /// each contribute 0. Max instead reads through to the
365    /// leader's value, surfacing "the largest process
366    /// represented in this bucket" regardless of which axis the
367    /// bucket is built around. The row count already covers
368    /// "how many threads are here", so the structural field's
369    /// value adds new information rather than restating the row
370    /// count.
371    ///
372    /// [`GaugeCount`]: crate::metric_types::GaugeCount
373    MaxGaugeCount(fn(&ThreadState) -> crate::metric_types::GaugeCount),
374    /// Ordinal i32, aggregated as the observed [min, max] range.
375    /// Used for signed-domain ordinals (`nice`, `priority`,
376    /// `processor`). Delta math uses the midpoint of each range
377    /// as the scalar; output prints both the range and the
378    /// delta. The dispatch routes through
379    /// [`crate::metric_types::Rangeable::range_across`] and
380    /// widens to `i64` for `Aggregated::OrdinalRange`.
381    ///
382    /// [`OrdinalI32`]: crate::metric_types::OrdinalI32
383    RangeI32(fn(&ThreadState) -> crate::metric_types::OrdinalI32),
384    /// Ordinal u32, aggregated as the observed [min, max] range.
385    /// Used for unsigned-domain ordinals (`rt_priority`,
386    /// kernel-typed `unsigned int`). Same shape as
387    /// [`AggRule::RangeI32`] but the inner width matches the
388    /// kernel-side `unsigned int` declaration; the dispatch
389    /// widens the resulting `u32` to `i64` for
390    /// `Aggregated::OrdinalRange`.
391    ///
392    /// [`OrdinalU32`]: crate::metric_types::OrdinalU32
393    RangeU32(fn(&ThreadState) -> crate::metric_types::OrdinalU32),
394    /// Categorical string, aggregated as the mode (most-frequent
395    /// value). Used for `policy` (string-valued
396    /// [`crate::metric_types::CategoricalString`]). Delta is
397    /// textual: "same" if both modes agree, "differs" otherwise
398    /// — there is no arithmetic on a policy name. The dispatch
399    /// routes through
400    /// [`crate::metric_types::Modeable::mode_across`].
401    Mode(fn(&ThreadState) -> crate::metric_types::CategoricalString),
402    /// Categorical char, aggregated as the mode. Used for
403    /// `state` (single-letter task state from
404    /// `/proc/<tid>/status`). The dispatch coerces the `char`
405    /// to a `String` via `to_string()` before reducing — `char`
406    /// itself is NOT [`Modeable`] (only
407    /// [`crate::metric_types::CategoricalString`] is), so this
408    /// variant exists to keep the registry's accessor type
409    /// matching the `ThreadState` field type without forcing the
410    /// field into a wrapper. If a second char-valued metric
411    /// appears, promote both fields to a dedicated
412    /// `CategoricalChar` wrapper rather than continuing the
413    /// ad-hoc coercion (mirrors the `CategoricalBool`
414    /// promotion guidance on [`AggRule::ModeBool`]).
415    ///
416    /// [`Modeable`]: crate::metric_types::Modeable
417    ModeChar(fn(&ThreadState) -> char),
418    /// Categorical bool, aggregated as the mode. Used for
419    /// `ext_enabled` (sched_ext class membership). Same shape as
420    /// [`AggRule::ModeChar`]: the dispatch coerces via
421    /// `to_string()` so `"true"`/`"false"` participate in the
422    /// mode reduction. If a second bool-valued metric appears,
423    /// promote both fields to a dedicated `CategoricalBool`
424    /// wrapper rather than continuing the ad-hoc coercion.
425    ///
426    /// Tiebreak skew (FA-2): the lex-smallest-wins tiebreak
427    /// inside `Modeable::mode_across` makes `"false"` (`'f'`,
428    /// 0x66) win an equal-count tie against `"true"` (`'t'`,
429    /// 0x74). This matches the legacy pre-phase-3 behavior —
430    /// the old `to_string()` coercion fed the same string pair
431    /// through the same lex-tiebreak — but is worth flagging
432    /// explicitly: a 50/50 sched_ext-on/off bucket renders
433    /// `false` as the mode rather than picking the more
434    /// "informative" `true`. Operators reading a `false` mode
435    /// in a heterogeneous bucket should check the `count/total`
436    /// fraction.
437    ModeBool(fn(&ThreadState) -> bool),
438    /// CPU affinity set, aggregated as the num_cpus range across
439    /// the group plus a uniform-cpuset rendering when every
440    /// thread shared the same allowed set. Used for
441    /// `cpu_affinity`. The accessor returns
442    /// [`crate::metric_types::CpuSet`]; the dispatch unwraps to
443    /// `Vec<u32>` for the `AffinitySummary` reduction.
444    ///
445    /// Unlike the `Sum*` / `Max*` / `Range*` / `Mode*` rules,
446    /// `Affinity` does NOT route through a
447    /// [`crate::metric_types`] trait method — its reduction
448    /// produces an `AffinitySummary` (num_cpus range +
449    /// uniform-cpuset flag), not a homogeneous `CpuSet`, so the
450    /// inline aggregator in `aggregate` walks the per-thread
451    /// `Vec<u32>` directly. A future `Affinable` trait could
452    /// fold the body into [`crate::metric_types`] but the
453    /// summary type is single-use today.
454    ///
455    /// Type-system bypass caveat (FA-1): the typed `AggRule`
456    /// shape catches "wrong wrapper" mistakes
457    /// (`SumNs(|t| t.wait_max)` fails to compile because
458    /// `wait_max` is `PeakNs`), but a closure body that
459    /// actively MISWRAPS the underlying field — e.g.
460    /// `SumNs(|t| MonotonicNs(t.wait_max.0))` — laundering a
461    /// peak through the sum wrapper still type-checks. Don't
462    /// do that. The wrapper category is load-bearing; the type
463    /// system catches the variant mismatch but cannot
464    /// inspect the inside of an arbitrary closure.
465    Affinity(fn(&ThreadState) -> crate::metric_types::CpuSet),
466}
467
468impl AggRule {
469    /// The auto-scale ladder for this rule's value cell.
470    ///
471    /// Closed match — adding a new [`AggRule`] variant requires
472    /// adding the ladder mapping here. The mapping is one-to-one
473    /// with the typed accessor newtype: [`AggRule::SumNs`] →
474    /// [`ScaleLadder::Ns`], [`AggRule::SumBytes`] →
475    /// [`ScaleLadder::Bytes`], etc.
476    pub fn ladder(&self) -> ScaleLadder {
477        match self {
478            // Cumulative counters — Sum reductions, ladder
479            // determined by the unit family of the typed
480            // accessor. SumCount and MaxGaugeCount both produce a
481            // unitless count; SumNs / MaxPeak / MaxGaugeNs all
482            // produce a ns value; SumTicks produces ticks;
483            // SumBytes / MaxPeakBytes produce bytes.
484            AggRule::SumCount(_) => ScaleLadder::Unitless,
485            AggRule::SumNs(_) => ScaleLadder::Ns,
486            AggRule::SumTicks(_) => ScaleLadder::Ticks,
487            AggRule::SumBytes(_) => ScaleLadder::Bytes,
488            AggRule::MaxPeak(_) => ScaleLadder::Ns,
489            AggRule::MaxPeakBytes(_) => ScaleLadder::Bytes,
490            AggRule::MaxGaugeNs(_) => ScaleLadder::Ns,
491            AggRule::MaxGaugeCount(_) => ScaleLadder::Unitless,
492            // Range / Mode / Affinity carry no ladder — the
493            // Aggregated Display impl handles render directly.
494            AggRule::RangeI32(_)
495            | AggRule::RangeU32(_)
496            | AggRule::Mode(_)
497            | AggRule::ModeChar(_)
498            | AggRule::ModeBool(_)
499            | AggRule::Affinity(_) => ScaleLadder::None,
500        }
501    }
502}