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}