ktstr/scenario/snapshot/
pickers.rs

1//! Predefined disambiguator closures for
2//! [`Snapshot::live_var_via`](super::view::Snapshot::live_var_via).
3//!
4//! Pickers are plain functions matching the closure shape
5//! `live_var_via`'s `picker` arg expects (`FnOnce(&[(&str,
6//! SnapshotField)]) -> Option<usize>`). Pass by name:
7//!
8//! ```ignore
9//! snap.live_var_via("nr_dispatched", pickers::max_by_counter_value)
10//! ```
11//!
12//! Every picker baked into this module is a HEURISTIC for some
13//! "live instance vs prior instance" question; the picker NAME
14//! tells you what operation it performs (not what semantic the
15//! caller can conclude). Choose deliberately — the caller is
16//! responsible for picking a picker whose operation matches the
17//! metric's shape (counter vs gauge vs sentinel) and the test's
18//! timing relative to scheduler swaps.
19
20use super::SnapshotField;
21
22/// Pick the candidate whose `as_u64()` value is largest. The
23/// operation is purely mechanical: project each candidate via
24/// `SnapshotField::as_u64`, drop those that fail to project
25/// (wrong type, missing field, etc.), and return the index of
26/// the surviving candidate with the largest u64.
27///
28/// # Active-bss heuristic
29///
30/// For a cumulative non-decreasing counter where the inactive
31/// instance's bss stays at BSS-zero init (e.g. the just-killed
32/// scheduler's `bpf_bpf.bss` copy after `Op::ReplaceScheduler`),
33/// the live instance has by definition accumulated more events,
34/// so max-by-value reliably picks the live bss copy. This is the
35/// load-bearing usage pattern multi-instance A/B tests reach for.
36///
37/// # When this picks WRONG
38///
39/// Treat the picker as "max by u64," not as "the live instance":
40///
41/// - **Gauges or stateful fields.** An inactive copy can carry a
42///   sticky non-zero value from before detach (e.g. a high-water
43///   mark, a last-seen-timestamp). Max-by-value would still pick
44///   it after the live instance starts from zero.
45/// - **Sentinel-init counters** (e.g. `u64::MAX` on init meaning
46///   "unset"). Max is meaningless here — the uninitialized copy
47///   wins every comparison.
48/// - **Immediately-post-swap reads** before the new instance has
49///   any events. The just-killed copy still has the larger
50///   cumulative count for some window after the swap. For
51///   `Op::ReplaceScheduler` races the new
52///   `wait_for_accessor_publish_advance` gate already serializes
53///   most of that window away, but a snapshot fired before the
54///   first post-swap counter increment will still observe the
55///   old instance's cumulative value as the maximum.
56///
57/// For any of these, write a picker that names the live instance
58/// explicitly — by binary fingerprint, by obj-name prefix
59/// equality, or by a separately captured liveness signal — and
60/// pass it to `live_var_via` instead.
61///
62/// # Return semantic
63///
64/// Returns the index of the max candidate, or `None` if every
65/// candidate failed to project to `u64` (in which case
66/// `live_var_via` surfaces a
67/// [`SnapshotError::ProjectionFailed`](super::SnapshotError::ProjectionFailed)
68/// naming the picker as the source). Ties resolve to the LAST
69/// tied index (deterministic; matches `Iterator::max_by_key`'s
70/// "last element of equal-maximum group wins" rule). Test
71/// authors who need a different tie-break write a sibling picker
72/// rather than relying on a specific ordering rule that's not
73/// load-bearing for the active-bss heuristic this picker targets.
74pub fn max_by_counter_value(fields: &[(&str, SnapshotField<'_>)]) -> Option<usize> {
75    fields
76        .iter()
77        .enumerate()
78        .filter_map(|(i, (_, f))| Some((i, f.as_u64().ok()?)))
79        .max_by_key(|(_, v)| *v)
80        .map(|(i, _)| i)
81}
82
83/// Pick the candidate row whose fields sum to the largest u64 — the
84/// "max-activity bss" heuristic generalized to N co-picked variables.
85/// For each row, the picker `as_u64`-projects every field and
86/// `saturating_add`s them; rows containing ANY field that fails to
87/// project to u64 are dropped entirely (not partial-summed); among
88/// the surviving rows, the one with the largest sum wins.
89///
90/// Pairs with [`super::view::Snapshot::live_vars_via`] for ratio /
91/// fraction metrics: the row whose counters have accumulated the
92/// most TOTAL events is the live scheduler instance, and selecting
93/// that row guarantees all N returned fields come from the same
94/// source map (no cross-bss-copy corruption).
95///
96/// # When this picks WRONG
97///
98/// Inherits every failure mode of [`max_by_counter_value`] (gauges,
99/// sentinel-init counters like `u64::MAX`, immediately-post-swap
100/// windows before the new instance has accumulated past the old's
101/// BSS-zero point), plus one sum-specific mode:
102///
103/// **Any-non-u64-field row excluded.** A single rodata or
104/// non-counter variable accidentally included in the name set
105/// silently makes every candidate row ineligible (every row would
106/// have that non-u64 field). Verify all N names project to u64
107/// counters BEFORE composing the picker.
108///
109/// Sums use `saturating_add`: a row containing a sentinel
110/// `u64::MAX` saturates to `u64::MAX` and wins all comparisons.
111/// Treat that as a sentinel-mode failure (per max_by_counter_value's
112/// caveat).
113///
114/// Ties resolve to the LAST tied row (matches
115/// [`Iterator::max_by_key`] semantics — same as
116/// [`max_by_counter_value`]).
117pub fn max_by_sum_u64(rows: &[(&str, Vec<SnapshotField<'_>>)]) -> Option<usize> {
118    rows.iter()
119        .enumerate()
120        .filter_map(|(i, (_, fields))| {
121            let mut sum: u64 = 0;
122            for f in fields {
123                let v = f.as_u64().ok()?;
124                sum = sum.saturating_add(v);
125            }
126            Some((i, sum))
127        })
128        .max_by_key(|(_, s)| *s)
129        .map(|(i, _)| i)
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::monitor::btf_render::RenderedValue;
136
137    fn u64_field(value: u64) -> RenderedValue {
138        RenderedValue::Uint { bits: 64, value }
139    }
140
141    fn struct_field() -> RenderedValue {
142        RenderedValue::Struct {
143            type_name: Some("opaque".into()),
144            members: vec![],
145        }
146    }
147
148    #[test]
149    fn max_by_counter_value_picks_largest_u64() {
150        let a = u64_field(10);
151        let b = u64_field(100);
152        let c = u64_field(5);
153        let fields = vec![
154            ("alpha.bss", SnapshotField::Value(&a)),
155            ("beta.bss", SnapshotField::Value(&b)),
156            ("gamma.bss", SnapshotField::Value(&c)),
157        ];
158        assert_eq!(max_by_counter_value(&fields), Some(1));
159    }
160
161    #[test]
162    fn max_by_counter_value_skips_non_u64_picks_max_from_remainder() {
163        let a = u64_field(10);
164        let s = struct_field();
165        let c = u64_field(100);
166        let fields = vec![
167            ("alpha.bss", SnapshotField::Value(&a)),
168            ("beta.bss", SnapshotField::Value(&s)),
169            ("gamma.bss", SnapshotField::Value(&c)),
170        ];
171        assert_eq!(
172            max_by_counter_value(&fields),
173            Some(2),
174            "Struct candidate drops out via as_u64 error; max of \
175             {{alpha=10, gamma=100}} is gamma at index 2",
176        );
177    }
178
179    #[test]
180    fn max_by_counter_value_returns_none_when_all_candidates_non_u64() {
181        let s1 = struct_field();
182        let s2 = struct_field();
183        let fields = vec![
184            ("alpha.bss", SnapshotField::Value(&s1)),
185            ("beta.bss", SnapshotField::Value(&s2)),
186        ];
187        assert_eq!(
188            max_by_counter_value(&fields),
189            None,
190            "every candidate fails as_u64 — picker returns None so \
191             live_var_via surfaces ProjectionFailed naming the picker",
192        );
193    }
194
195    #[test]
196    fn max_by_counter_value_tie_picks_last() {
197        let a = u64_field(50);
198        let b = u64_field(50);
199        let c = u64_field(50);
200        let fields = vec![
201            ("alpha.bss", SnapshotField::Value(&a)),
202            ("beta.bss", SnapshotField::Value(&b)),
203            ("gamma.bss", SnapshotField::Value(&c)),
204        ];
205        assert_eq!(
206            max_by_counter_value(&fields),
207            Some(2),
208            "Iterator::max_by_key returns the LAST element of an \
209             equal-maximum group; picker tie-break is deterministic \
210             across runs but lands on the last tied index, not the \
211             first",
212        );
213    }
214
215    #[test]
216    fn max_by_counter_value_empty_input_returns_none() {
217        let fields: Vec<(&str, SnapshotField<'_>)> = vec![];
218        assert_eq!(max_by_counter_value(&fields), None);
219    }
220
221    // ---------- max_by_sum_u64 ----------
222
223    #[test]
224    fn max_by_sum_u64_picks_largest_summed_row() {
225        let a1 = u64_field(10);
226        let a2 = u64_field(20);
227        let b1 = u64_field(5);
228        let b2 = u64_field(5);
229        let c1 = u64_field(30);
230        let c2 = u64_field(40);
231        let rows = vec![
232            (
233                "alpha.bss",
234                vec![SnapshotField::Value(&a1), SnapshotField::Value(&a2)],
235            ),
236            (
237                "beta.bss",
238                vec![SnapshotField::Value(&b1), SnapshotField::Value(&b2)],
239            ),
240            (
241                "gamma.bss",
242                vec![SnapshotField::Value(&c1), SnapshotField::Value(&c2)],
243            ),
244        ];
245        assert_eq!(
246            max_by_sum_u64(&rows),
247            Some(2),
248            "row 2 sum = 70, beats row 0 (30) and row 1 (10)",
249        );
250    }
251
252    #[test]
253    fn max_by_sum_u64_tie_picks_last() {
254        let v = u64_field(10);
255        let rows = vec![
256            (
257                "a",
258                vec![SnapshotField::Value(&v), SnapshotField::Value(&v)],
259            ),
260            (
261                "b",
262                vec![SnapshotField::Value(&v), SnapshotField::Value(&v)],
263            ),
264            (
265                "c",
266                vec![SnapshotField::Value(&v), SnapshotField::Value(&v)],
267            ),
268        ];
269        assert_eq!(
270            max_by_sum_u64(&rows),
271            Some(2),
272            "Iterator::max_by_key returns the LAST tied element; \
273             matches max_by_counter_value semantic for caller \
274             consistency",
275        );
276    }
277
278    #[test]
279    fn max_by_sum_u64_drops_rows_with_any_non_u64_field() {
280        let u = u64_field(10);
281        let s = struct_field();
282        let big = u64_field(1000);
283        let rows = vec![
284            // row 0: u64 + Struct → drop entirely (any non-u64 → ineligible)
285            (
286                "alpha",
287                vec![SnapshotField::Value(&u), SnapshotField::Value(&s)],
288            ),
289            // row 1: u64 + u64 → eligible, sum = 1010
290            (
291                "beta",
292                vec![SnapshotField::Value(&u), SnapshotField::Value(&big)],
293            ),
294            // row 2: Struct + u64 → drop entirely
295            (
296                "gamma",
297                vec![SnapshotField::Value(&s), SnapshotField::Value(&big)],
298            ),
299        ];
300        assert_eq!(
301            max_by_sum_u64(&rows),
302            Some(1),
303            "only row 1 survives the any-non-u64-eligibility filter; \
304             picker is NOT partial-summing (Struct + u64 doesn't fall \
305             back to summing the u64 alone)",
306        );
307    }
308
309    #[test]
310    fn max_by_sum_u64_all_rows_have_non_u64_field_returns_none() {
311        let u = u64_field(10);
312        let s = struct_field();
313        let rows = vec![
314            (
315                "alpha",
316                vec![SnapshotField::Value(&u), SnapshotField::Value(&s)],
317            ),
318            (
319                "beta",
320                vec![SnapshotField::Value(&s), SnapshotField::Value(&u)],
321            ),
322        ];
323        assert_eq!(
324            max_by_sum_u64(&rows),
325            None,
326            "every row has at least one non-u64 field; downstream \
327             live_vars_via surfaces ProjectionFailed",
328        );
329    }
330
331    #[test]
332    fn max_by_sum_u64_empty_rows_returns_none() {
333        let rows: Vec<(&str, Vec<SnapshotField<'_>>)> = vec![];
334        assert_eq!(max_by_sum_u64(&rows), None);
335    }
336
337    #[test]
338    fn max_by_sum_u64_saturates_on_overflow() {
339        let big = u64_field(u64::MAX);
340        let one = u64_field(1);
341        let rows = vec![
342            (
343                "alpha",
344                vec![SnapshotField::Value(&big), SnapshotField::Value(&one)],
345            ),
346            (
347                "beta",
348                vec![SnapshotField::Value(&one), SnapshotField::Value(&one)],
349            ),
350        ];
351        // alpha saturates to u64::MAX; beta sums to 2. Alpha wins.
352        assert_eq!(max_by_sum_u64(&rows), Some(0));
353    }
354}