ktstr/scenario/sample/
bpf.rs

1//! BPF-axis projection for [`SampleSeries`].
2//!
3//! Each [`Sample`](super::Sample) carries a frozen [`Snapshot`] over
4//! BPF program state captured at the freeze rendezvous. This module
5//! exposes the closure-based [`SampleSeries::bpf`] projection (manual
6//! field access via the Snapshot accessor surface) and the auto-
7//! discovering [`SampleSeries::bpf_map`] → [`BpfMapProjector`] pair
8//! that enumerates a map's struct members and projects each as
9//! `SeriesField<u64>` / `SeriesField<i64>` / `SeriesField<f64>`.
10//!
11//! Orthogonal to [`super::stats`]: the BPF axis sources its values
12//! from the kernel-side BPF state (counters, ringbuf items, struct
13//! members); the stats axis sources from the userspace scheduler's
14//! `scx_stats` JSON. Tests typically use both — BPF for low-level
15//! state, stats for scheduler-author-defined metrics.
16
17use crate::assert::temporal::SeriesField;
18use crate::scenario::snapshot::{Snapshot, SnapshotField, SnapshotResult};
19
20use super::{SampleSeries, build_series_field};
21
22impl SampleSeries {
23    /// Project the series along the BPF axis. The closure receives
24    /// each sample's [`Snapshot`] and returns a
25    /// [`SnapshotResult<T>`] — typically a typed value extracted
26    /// via `snap.var(...).as_u64()` or
27    /// `snap.map(...).at(...).get(...).as_u64()`. Errors flow
28    /// through into the resulting [`SeriesField`] as per-sample
29    /// `Err` slots so a temporal-assertion pattern can decide
30    /// whether to fail or skip on a missing field.
31    ///
32    /// `label` is owned (`impl Into<String>`) and lands in
33    /// [`crate::assert::temporal::SeriesField::label`] for failure-
34    /// message rendering. Callers may pass a `&'static str` literal
35    /// or a runtime-built `String` (for auto-discovered struct or
36    /// JSON key names).
37    pub fn bpf<T, F>(&self, label: impl Into<String>, project: F) -> SeriesField<T>
38    where
39        F: Fn(&Snapshot<'_>) -> SnapshotResult<T>,
40    {
41        build_series_field(&self.rows, label, |row| {
42            // Placeholder reports carry no real BPF state — the
43            // freeze rendezvous timed out (or the capture pipeline
44            // otherwise failed). Surface a dedicated PlaceholderSample
45            // error variant BEFORE invoking the projection closure
46            // so the temporal-assertion patterns can branch on
47            // "placeholder, skip" distinctly from "field missing,
48            // skip" when rendering the verdict's skip-Note.
49            if row.report.is_placeholder {
50                return Err(
51                    crate::scenario::snapshot::SnapshotError::PlaceholderSample {
52                        tag: row.tag.clone(),
53                        reason: row
54                            .report
55                            .scx_walker_unavailable
56                            .clone()
57                            .unwrap_or_else(|| "placeholder report".to_string()),
58                    },
59                );
60            }
61            let snap = Snapshot::new(&row.report);
62            project(&snap)
63        })
64    }
65
66    /// Project the live scheduler's `<obj>.<section>` global
67    /// variable named `name` as `u64`. Per-row equivalent of
68    /// `snap.live_var(name).as_u64()`, but with the placeholder
69    /// short-circuit baked in.
70    ///
71    /// **Why this exists.** Single-binary tests can use
72    /// `series.bpf("label", |s| s.var(name).as_u64())` directly —
73    /// `var()` auto-disambiguates via the active-scheduler walker
74    /// when multiple maps share a global symbol (e.g. post-
75    /// `Op::ReplaceScheduler` with two scheduler instances). The
76    /// `bpf_live_u64` helper saves the closure boilerplate AND
77    /// makes the live-resolution semantics visible in the call
78    /// site's NAME, not buried in a projector body. The label on
79    /// the resulting [`SeriesField`] is the `name` argument
80    /// verbatim.
81    pub fn bpf_live_u64(&self, name: &str) -> SeriesField<u64> {
82        self.bpf_live_phase_stable(name, |f| f.as_u64())
83    }
84
85    /// Sibling of [`Self::bpf_live_u64`] projecting as `i64`.
86    pub fn bpf_live_i64(&self, name: &str) -> SeriesField<i64> {
87        self.bpf_live_phase_stable(name, |f| f.as_i64())
88    }
89
90    /// Sibling of [`Self::bpf_live_u64`] projecting as `f64`.
91    pub fn bpf_live_f64(&self, name: &str) -> SeriesField<f64> {
92        self.bpf_live_phase_stable(name, |f| f.as_f64())
93    }
94
95    /// Shared phase-stable projector for the `bpf_live_*` trio.
96    ///
97    /// Per-snapshot the underlying [`Snapshot::live_var`] correctly
98    /// disambiguates between bss copies via the walker-populated
99    /// [`crate::monitor::dump::FailureDumpReport::active_map_kvas`].
100    /// But across snapshots in the same phase, the walker can
101    /// re-publish (typical cause: post-`Op::ReplaceScheduler` swap
102    /// window) and successive snapshots can correctly pick
103    /// DIFFERENT bss copies — producing a non-monotonic counter
104    /// series that downstream reducers like
105    /// [`crate::assert::temporal::SeriesField::counter_delta_per_phase`]
106    /// can't reason about.
107    ///
108    /// This projector adds a phase-stability gate on top of the
109    /// per-snapshot pick. Two checks fire per phase:
110    ///
111    /// 1. **KVA drift within a walker-resolved set.** For each
112    ///    phase, pin to the FIRST sample whose
113    ///    `active_map_kvas` is non-empty. Later same-phase
114    ///    samples whose `active_map_kvas` matches the pin pass
115    ///    through. Later same-phase samples whose `active_map_kvas`
116    ///    differs surface as
117    ///    [`crate::scenario::snapshot::SnapshotError::WalkerDriftedWithinPhase`]
118    ///    (the walker re-published mid-phase, typically because
119    ///    an `Op::ReplaceScheduler` swap fired between snapshots).
120    /// 2. **Cross-scheduler leak via walker-absent samples.** If
121    ///    a phase contains at least one walker-resolved sample
122    ///    (non-empty `active_map_kvas`), every OTHER same-phase
123    ///    sample MUST also be walker-resolved. An empty-kvas
124    ///    sample in such a phase came from a pre-walker capture
125    ///    window OR a different scheduler instance entirely —
126    ///    its `Snapshot::live_var` read cannot be proven to come
127    ///    from the same bss as the pinned samples, so the value
128    ///    is non-comparable. Surface as
129    ///    `WalkerDriftedWithinPhase` with `sample_kvas = []`
130    ///    (the empty vec signals "this sample had no walker
131    ///    output" as distinct from "the walker output disagreed").
132    ///    The temporal patterns' standard error-skip semantics
133    ///    drop these samples from per-phase reducers like
134    ///    `counter_delta_per_phase`.
135    ///
136    /// Samples in a phase with NO walker-resolved siblings pass
137    /// through unchanged — the single-scheduler / pre-walker
138    /// case where the consumer's per-snapshot
139    /// [`Snapshot::active`] resolution is the only signal
140    /// available.
141    fn bpf_live_phase_stable<T, P>(&self, name: &str, project: P) -> SeriesField<T>
142    where
143        P: Fn(&SnapshotField<'_>) -> SnapshotResult<T>,
144    {
145        let label = name.to_string();
146        let name_owned = name.to_string();
147        // Pre-scan: identify phases that ever have a walker-resolved
148        // sample (non-empty active_map_kvas). Walker-absent samples
149        // in those phases are flagged as cross-scheduler-leak risks
150        // by the main loop below.
151        let mut phases_with_walker: std::collections::BTreeSet<crate::assert::Phase> =
152            std::collections::BTreeSet::new();
153        for row in &self.rows {
154            if row.report.is_placeholder {
155                continue;
156            }
157            if !row.report.active_map_kvas.is_empty()
158                && let Some(ph) = row.step_index.map(crate::assert::Phase::from)
159            {
160                phases_with_walker.insert(ph);
161            }
162        }
163        let mut values: Vec<SnapshotResult<T>> = Vec::with_capacity(self.rows.len());
164        let mut tags: Vec<String> = Vec::with_capacity(self.rows.len());
165        let mut elapsed: Vec<Option<u64>> = Vec::with_capacity(self.rows.len());
166        let mut phases: Vec<Option<crate::assert::Phase>> = Vec::with_capacity(self.rows.len());
167        let mut phase_kva_pin: std::collections::BTreeMap<crate::assert::Phase, Vec<u64>> =
168            std::collections::BTreeMap::new();
169        for row in &self.rows {
170            tags.push(row.tag.clone());
171            elapsed.push(row.elapsed_ms);
172            let phase = row.step_index.map(crate::assert::Phase::from);
173            phases.push(phase);
174
175            if row.report.is_placeholder {
176                values.push(Err(
177                    crate::scenario::snapshot::SnapshotError::PlaceholderSample {
178                        tag: row.tag.clone(),
179                        reason: row
180                            .report
181                            .scx_walker_unavailable
182                            .clone()
183                            .unwrap_or_else(|| "placeholder report".to_string()),
184                    },
185                ));
186                continue;
187            }
188            let snap = Snapshot::new(&row.report);
189            let field = snap.live_var(&name_owned);
190            let value = project(&field);
191            let sample_kvas: &[u64] = row.report.active_map_kvas.as_slice();
192
193            match (value, phase) {
194                (Ok(v), Some(ph)) if !sample_kvas.is_empty() => {
195                    let pin = phase_kva_pin
196                        .entry(ph)
197                        .or_insert_with(|| sample_kvas.to_vec());
198                    if pin.as_slice() == sample_kvas {
199                        values.push(Ok(v));
200                    } else {
201                        values.push(Err(
202                            crate::scenario::snapshot::SnapshotError::WalkerDriftedWithinPhase {
203                                phase: ph,
204                                pinned_kvas: pin.clone(),
205                                sample_kvas: sample_kvas.to_vec(),
206                                requested: name_owned.clone(),
207                            },
208                        ));
209                    }
210                }
211                (Ok(_v), Some(ph))
212                    if sample_kvas.is_empty() && phases_with_walker.contains(&ph) =>
213                {
214                    // Cross-scheduler leak guard: this sample had
215                    // no walker output but a sibling sample in the
216                    // same phase did. The Ok value cannot be proven
217                    // to come from the same scheduler as the pinned
218                    // siblings — surface as drift with empty
219                    // sample_kvas to disambiguate from "walker
220                    // output disagreed".
221                    let pinned = phase_kva_pin.get(&ph).cloned().unwrap_or_default();
222                    values.push(Err(
223                        crate::scenario::snapshot::SnapshotError::WalkerDriftedWithinPhase {
224                            phase: ph,
225                            pinned_kvas: pinned,
226                            sample_kvas: Vec::new(),
227                            requested: name_owned.clone(),
228                        },
229                    ));
230                }
231                (other, _) => values.push(other),
232            }
233        }
234        SeriesField::from_parts_with_phases_opt(label, tags, elapsed, values, phases)
235    }
236
237    /// Per-snapshot co-picked BPF projection of N counters from the
238    /// SAME global-section map. Lifts [`Snapshot::live_vars_via`] to
239    /// the series level: for each sample, calls the picker ONCE per
240    /// snapshot and projects the resulting `N` `SnapshotField`s as
241    /// `u64` into `N` parallel [`SeriesField`]s.
242    ///
243    /// **Why this exists.** The single-name `Self::bpf` closure shape
244    /// forces tests that need two co-picked counters (e.g.
245    /// `nr_cross_dispatch` + `nr_same_dispatch` from the same
246    /// scheduler bss copy after `Op::ReplaceScheduler`) to call the
247    /// picker TWICE per snapshot — once for each derived
248    /// `SeriesField` — paying picker cost `2N` instead of `N`. The
249    /// per-snapshot dedup happens here: one `live_vars_via` call per
250    /// row, eagerly split into `N` u64 vectors before any
251    /// `SeriesField` materializes.
252    ///
253    /// **Lifetime / coverage gaps surface per field.** If a snapshot
254    /// is a placeholder, every field's slot for that row carries the
255    /// same [`crate::scenario::snapshot::SnapshotError::PlaceholderSample`].
256    /// If `live_vars_via` fails (no candidate map has all `N` names,
257    /// or the picker returns `None`), every field's slot carries the
258    /// same underlying [`crate::scenario::snapshot::SnapshotError`] —
259    /// the failure is shared, not split. Per-field `.as_u64()` casts
260    /// that fail (the picked field doesn't render as a u64) surface
261    /// as per-field
262    /// [`crate::scenario::snapshot::SnapshotError::TypeMismatch`]
263    /// without contaminating sibling fields.
264    ///
265    /// The label routed onto each resulting [`SeriesField`] is the
266    /// caller-supplied name from `names` at the matching position.
267    pub fn live_bpf_vars_via<const N: usize, P>(
268        &self,
269        names: [&str; N],
270        picker: P,
271    ) -> [SeriesField<u64>; N]
272    where
273        P: for<'a> Fn(&[(&'a str, Vec<SnapshotField<'a>>)]) -> Option<usize> + Copy,
274    {
275        let mut per_field: [Vec<crate::scenario::snapshot::SnapshotResult<u64>>; N] =
276            std::array::from_fn(|_| Vec::with_capacity(self.rows.len()));
277        let mut tags: Vec<String> = Vec::with_capacity(self.rows.len());
278        let mut elapsed: Vec<Option<u64>> = Vec::with_capacity(self.rows.len());
279        let mut phases: Vec<Option<crate::assert::Phase>> = Vec::with_capacity(self.rows.len());
280
281        for row in &self.rows {
282            tags.push(row.tag.clone());
283            elapsed.push(row.elapsed_ms);
284            phases.push(row.step_index.map(crate::assert::Phase::from));
285
286            if row.report.is_placeholder {
287                let err = crate::scenario::snapshot::SnapshotError::PlaceholderSample {
288                    tag: row.tag.clone(),
289                    reason: row
290                        .report
291                        .scx_walker_unavailable
292                        .clone()
293                        .unwrap_or_else(|| "placeholder report".to_string()),
294                };
295                for slot in &mut per_field {
296                    slot.push(Err(err.clone()));
297                }
298                continue;
299            }
300
301            let snap = Snapshot::new(&row.report);
302            // Slice cast: live_vars_via takes &[&str], we hold [&str; N].
303            match snap.live_vars_via(&names, picker) {
304                Ok(fields) => {
305                    debug_assert_eq!(fields.len(), N);
306                    for (i, field) in fields.into_iter().enumerate() {
307                        per_field[i].push(field.as_u64());
308                    }
309                }
310                Err(e) => {
311                    for slot in &mut per_field {
312                        slot.push(Err(e.clone()));
313                    }
314                }
315            }
316        }
317
318        // Build N SeriesFields, each consuming its own per-field
319        // value vector. Tags / elapsed / phases share the same
320        // sample identity across fields — clone for each output.
321        std::array::from_fn(|i| {
322            crate::assert::temporal::SeriesField::from_parts_with_phases_opt(
323                names[i].to_string(),
324                tags.clone(),
325                elapsed.clone(),
326                std::mem::take(&mut per_field[i]),
327                phases.clone(),
328            )
329        })
330    }
331
332    /// Auto-project a top-level BPF map's struct members. The
333    /// returned [`BpfMapProjector`] auto-discovers struct member
334    /// names at sample 0 and exposes them via `.field_u64(name)` /
335    /// `.field_i64(name)` / `.field_f64(name)` — a caller that
336    /// wants every scalar field of a BSS struct without
337    /// enumerating each one by hand calls
338    /// `series.bpf_map("scx_obj.bss").at(0)` and then
339    /// `.field_u64("nr_dispatched")` for the field of interest.
340    ///
341    /// **Top-level scalar fields only.** The auto-projector reads
342    /// directly-named struct members (e.g. `"nr_dispatched"`,
343    /// `"stall"`). Nested struct members (e.g. `"ctx.weight"`) and
344    /// deeper paths are NOT auto-discoverable through the typed
345    /// `field_*` helpers — for those, use the manual closure
346    /// projection [`SampleSeries::bpf`] with
347    /// `|snap| snap.var("ctx").get("weight").as_u64()` (or the
348    /// equivalent map-walking shape). Per-CPU maps are also out
349    /// of scope: they require an explicit `.cpu(N)` narrow on
350    /// the [`Snapshot`] accessor surface, so callers route
351    /// through the manual closure path for those as well.
352    pub fn bpf_map<'a>(&'a self, map_name: &'a str) -> BpfMapProjector<'a> {
353        BpfMapProjector {
354            series: self,
355            map_name,
356            entry_index: 0,
357        }
358    }
359}
360
361/// Auto-projector handle returned by [`SampleSeries::bpf_map`].
362/// Lazily resolves the named map's value at the requested entry
363/// index when `Self::field` is invoked.
364pub struct BpfMapProjector<'a> {
365    series: &'a SampleSeries,
366    map_name: &'a str,
367    entry_index: usize,
368}
369
370impl<'a> BpfMapProjector<'a> {
371    /// Pin the entry index for the projection. Defaults to `0`
372    /// (typical for ARRAY / `.bss` / `.data` / `.rodata` maps,
373    /// which carry a single value at index 0). Use this to walk
374    /// into a HASH map at a specific ordinal.
375    pub fn at(mut self, index: usize) -> Self {
376        self.entry_index = index;
377        self
378    }
379
380    /// Project a single named struct field as `u64` (the most
381    /// common temporal-assertion shape — counters, byte counts).
382    /// The label routed onto the resulting [`SeriesField`] is the
383    /// caller-supplied field name; combined with the map name in
384    /// the diagnostic the failure message reads
385    /// `"<map>.<entry_index>.<field>"`.
386    pub fn field_u64(&self, field: &str) -> SeriesField<u64> {
387        let map_name = self.map_name.to_string();
388        let entry_index = self.entry_index;
389        let field_owned = field.to_string();
390        self.series.bpf(field, move |snap| {
391            let entry = match snap.map(&map_name) {
392                Ok(m) => m.at(entry_index),
393                Err(e) => return Err(e),
394            };
395            entry.get(&field_owned).as_u64()
396        })
397    }
398
399    /// Project a single named struct field as `i64`.
400    pub fn field_i64(&self, field: &str) -> SeriesField<i64> {
401        let map_name = self.map_name.to_string();
402        let entry_index = self.entry_index;
403        let field_owned = field.to_string();
404        self.series.bpf(field, move |snap| {
405            let entry = match snap.map(&map_name) {
406                Ok(m) => m.at(entry_index),
407                Err(e) => return Err(e),
408            };
409            entry.get(&field_owned).as_i64()
410        })
411    }
412
413    /// Project a single named struct field as `f64`.
414    pub fn field_f64(&self, field: &str) -> SeriesField<f64> {
415        let map_name = self.map_name.to_string();
416        let entry_index = self.entry_index;
417        let field_owned = field.to_string();
418        self.series.bpf(field, move |snap| {
419            let entry = match snap.map(&map_name) {
420                Ok(m) => m.at(entry_index),
421                Err(e) => return Err(e),
422            };
423            entry.get(&field_owned).as_f64()
424        })
425    }
426
427    /// Discover the struct member names of the map's rendered value,
428    /// unioned across ALL samples (first-seen order, deduplicated).
429    /// Useful for tests that want to enumerate every scalar field for a
430    /// blanket assertion.
431    ///
432    /// Discovery spans every row rather than `rows.first()` alone: sample
433    /// 0 can be a placeholder (the map missing or not yet captured) while
434    /// later rows carry the struct, and reading only row 0 would silently
435    /// enumerate nothing — blinding a "assert over every scalar field"
436    /// projection. Empty ONLY when NO sample renders the map as a struct
437    /// (the field set is genuinely undiscoverable).
438    pub fn member_names(&self) -> Vec<String> {
439        let mut names: Vec<String> = Vec::new();
440        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
441        for row in &self.series.rows {
442            let snap = Snapshot::new(&row.report);
443            let map = match snap.map(self.map_name) {
444                Ok(m) => m,
445                Err(_) => continue,
446            };
447            let entry = map.at(self.entry_index);
448            // Walk the entry's value — SnapshotEntry doesn't expose its
449            // struct members directly, but get("") returns the struct value
450            // (walk_dotted_path returns Value(root) for an empty path).
451            if let SnapshotField::Value(crate::monitor::btf_render::RenderedValue::Struct {
452                members,
453                ..
454            }) = entry.get("")
455            {
456                for m in members {
457                    if seen.insert(m.name.clone()) {
458                        names.push(m.name.clone());
459                    }
460                }
461            }
462        }
463        names
464    }
465
466    /// Project every struct member that resolves as `u64` for at
467    /// least one sample. Iterates [`Self::member_names`], calls
468    /// [`Self::field_u64`] for each, and keeps the entries whose
469    /// resulting [`SeriesField`] has at least one `Ok` value —
470    /// non-numeric members (strings, nested structs, floats) drop
471    /// out because their `as_u64()` cast always errors.
472    pub fn u64_fields(&self) -> Vec<(String, SeriesField<u64>)> {
473        self.member_names()
474            .into_iter()
475            .filter_map(|name| {
476                let field = self.field_u64(&name);
477                // Bind the predicate result and drop the
478                // values_iter borrow before moving `field`. A
479                // chained `.values_iter().any(...).then_some(...)`
480                // keeps the iterator alive across the move and
481                // fails the borrow check.
482                let any_ok = field.values_iter().any(|r| r.is_ok());
483                any_ok.then_some((name, field))
484            })
485            .collect()
486    }
487
488    /// Project every struct member that resolves as `f64` for at
489    /// least one sample. Mirrors [`Self::u64_fields`] using
490    /// [`Self::field_f64`].
491    pub fn f64_fields(&self) -> Vec<(String, SeriesField<f64>)> {
492        self.member_names()
493            .into_iter()
494            .filter_map(|name| {
495                let field = self.field_f64(&name);
496                let any_ok = field.values_iter().any(|r| r.is_ok());
497                any_ok.then_some((name, field))
498            })
499            .collect()
500    }
501
502    // -----------------------------------------------------------------
503    // Per-CPU projection. A `BPF_MAP_TYPE_PERCPU_ARRAY` /
504    // `*_PERCPU_HASH` value has one slot per CPU; the scalar
505    // `field_*` helpers above can't read it (`.get()` on a per-CPU
506    // entry returns `PerCpuNotNarrowed`). Two ways in:
507    //   - aggregate across CPUs: `field_cpu_sum_u64` etc., delegating
508    //     to the [`SnapshotEntry`](crate::scenario::snapshot::SnapshotEntry)
509    //     `cpu_*` reductions — None slots (unmapped / unreadable CPUs)
510    //     are skipped; the empty set (every slot None) yields
511    //     `Err(NoMatch)` for sum, max, and min alike, since a None slot
512    //     is unreadable rather than a real zero;
513    //   - select one CPU: [`Self::cpu`] → [`BpfMapCpuProjector`].
514    // -----------------------------------------------------------------
515
516    /// Sum a named per-CPU field across all CPUs as `u64`.
517    /// `Err(NoMatch)` when every slot is `None` (unreadable, not a real
518    /// zero); a readable all-zero map sums to `Ok(0)`. Delegates to
519    /// [`SnapshotEntry::cpu_sum_u64`](crate::scenario::snapshot::SnapshotEntry::cpu_sum_u64).
520    pub fn field_cpu_sum_u64(&self, field: &str) -> SeriesField<u64> {
521        let map_name = self.map_name.to_string();
522        let entry_index = self.entry_index;
523        let field_owned = field.to_string();
524        self.series.bpf(field, move |snap| {
525            let entry = match snap.map(&map_name) {
526                Ok(m) => m.at(entry_index),
527                Err(e) => return Err(e),
528            };
529            entry.cpu_sum_u64(&field_owned)
530        })
531    }
532
533    /// Sum a named per-CPU field across all CPUs as `i64`. The sum
534    /// saturates at `i64::MIN` / `i64::MAX` (parity with the `u64`
535    /// variant's `saturating_add`). `Err(NoMatch)` when every slot is
536    /// `None` (unreadable, not a real zero); a readable all-zero map
537    /// sums to `Ok(0)`. Delegates to
538    /// [`SnapshotEntry::cpu_sum_i64`](crate::scenario::snapshot::SnapshotEntry::cpu_sum_i64).
539    pub fn field_cpu_sum_i64(&self, field: &str) -> SeriesField<i64> {
540        let map_name = self.map_name.to_string();
541        let entry_index = self.entry_index;
542        let field_owned = field.to_string();
543        self.series.bpf(field, move |snap| {
544            let entry = match snap.map(&map_name) {
545                Ok(m) => m.at(entry_index),
546                Err(e) => return Err(e),
547            };
548            entry.cpu_sum_i64(&field_owned)
549        })
550    }
551
552    /// Sum a named per-CPU field across all CPUs as `f64`.
553    /// `Err(NoMatch)` when every slot is `None` (unreadable, not a real
554    /// zero); a readable all-zero map sums to `Ok(0.0)`. Delegates to
555    /// [`SnapshotEntry::cpu_sum_f64`](crate::scenario::snapshot::SnapshotEntry::cpu_sum_f64).
556    pub fn field_cpu_sum_f64(&self, field: &str) -> SeriesField<f64> {
557        let map_name = self.map_name.to_string();
558        let entry_index = self.entry_index;
559        let field_owned = field.to_string();
560        self.series.bpf(field, move |snap| {
561            let entry = match snap.map(&map_name) {
562                Ok(m) => m.at(entry_index),
563                Err(e) => return Err(e),
564            };
565            entry.cpu_sum_f64(&field_owned)
566        })
567    }
568
569    /// Maximum of a named per-CPU field across all CPUs as `u64`.
570    /// `Err(NoMatch)` when no CPU slot contributed. Delegates to
571    /// [`SnapshotEntry::cpu_max_u64`](crate::scenario::snapshot::SnapshotEntry::cpu_max_u64).
572    pub fn field_cpu_max_u64(&self, field: &str) -> SeriesField<u64> {
573        let map_name = self.map_name.to_string();
574        let entry_index = self.entry_index;
575        let field_owned = field.to_string();
576        self.series.bpf(field, move |snap| {
577            let entry = match snap.map(&map_name) {
578                Ok(m) => m.at(entry_index),
579                Err(e) => return Err(e),
580            };
581            entry.cpu_max_u64(&field_owned)
582        })
583    }
584
585    /// Maximum of a named per-CPU field across all CPUs as `i64`.
586    /// `Err(NoMatch)` when no CPU slot contributed. Delegates to
587    /// [`SnapshotEntry::cpu_max_i64`](crate::scenario::snapshot::SnapshotEntry::cpu_max_i64).
588    pub fn field_cpu_max_i64(&self, field: &str) -> SeriesField<i64> {
589        let map_name = self.map_name.to_string();
590        let entry_index = self.entry_index;
591        let field_owned = field.to_string();
592        self.series.bpf(field, move |snap| {
593            let entry = match snap.map(&map_name) {
594                Ok(m) => m.at(entry_index),
595                Err(e) => return Err(e),
596            };
597            entry.cpu_max_i64(&field_owned)
598        })
599    }
600
601    /// Maximum of a named per-CPU field across all CPUs as `f64`.
602    /// `Err(NoMatch)` when no CPU slot contributed; an all-NaN run
603    /// yields `Ok(NaN)`. Delegates to
604    /// [`SnapshotEntry::cpu_max_f64`](crate::scenario::snapshot::SnapshotEntry::cpu_max_f64).
605    pub fn field_cpu_max_f64(&self, field: &str) -> SeriesField<f64> {
606        let map_name = self.map_name.to_string();
607        let entry_index = self.entry_index;
608        let field_owned = field.to_string();
609        self.series.bpf(field, move |snap| {
610            let entry = match snap.map(&map_name) {
611                Ok(m) => m.at(entry_index),
612                Err(e) => return Err(e),
613            };
614            entry.cpu_max_f64(&field_owned)
615        })
616    }
617
618    /// Minimum of a named per-CPU field across all CPUs as `u64`.
619    /// `Err(NoMatch)` when no CPU slot contributed. Delegates to
620    /// [`SnapshotEntry::cpu_min_u64`](crate::scenario::snapshot::SnapshotEntry::cpu_min_u64).
621    pub fn field_cpu_min_u64(&self, field: &str) -> SeriesField<u64> {
622        let map_name = self.map_name.to_string();
623        let entry_index = self.entry_index;
624        let field_owned = field.to_string();
625        self.series.bpf(field, move |snap| {
626            let entry = match snap.map(&map_name) {
627                Ok(m) => m.at(entry_index),
628                Err(e) => return Err(e),
629            };
630            entry.cpu_min_u64(&field_owned)
631        })
632    }
633
634    /// Minimum of a named per-CPU field across all CPUs as `i64`.
635    /// `Err(NoMatch)` when no CPU slot contributed. Delegates to
636    /// [`SnapshotEntry::cpu_min_i64`](crate::scenario::snapshot::SnapshotEntry::cpu_min_i64).
637    pub fn field_cpu_min_i64(&self, field: &str) -> SeriesField<i64> {
638        let map_name = self.map_name.to_string();
639        let entry_index = self.entry_index;
640        let field_owned = field.to_string();
641        self.series.bpf(field, move |snap| {
642            let entry = match snap.map(&map_name) {
643                Ok(m) => m.at(entry_index),
644                Err(e) => return Err(e),
645            };
646            entry.cpu_min_i64(&field_owned)
647        })
648    }
649
650    /// Minimum of a named per-CPU field across all CPUs as `f64`.
651    /// `Err(NoMatch)` when no CPU slot contributed; an all-NaN run
652    /// yields `Ok(NaN)`. Delegates to
653    /// [`SnapshotEntry::cpu_min_f64`](crate::scenario::snapshot::SnapshotEntry::cpu_min_f64).
654    pub fn field_cpu_min_f64(&self, field: &str) -> SeriesField<f64> {
655        let map_name = self.map_name.to_string();
656        let entry_index = self.entry_index;
657        let field_owned = field.to_string();
658        self.series.bpf(field, move |snap| {
659            let entry = match snap.map(&map_name) {
660                Ok(m) => m.at(entry_index),
661                Err(e) => return Err(e),
662            };
663            entry.cpu_min_f64(&field_owned)
664        })
665    }
666
667    /// Narrow to a single CPU's slot of a per-CPU map, returning a
668    /// [`BpfMapCpuProjector`] whose `field_*` read CPU `n` (vs the
669    /// cross-CPU [`Self::field_cpu_sum_u64`] reductions). Mirrors
670    /// [`Self::at`] as a builder step; making the per-CPU SELECT a
671    /// distinct handle means `.cpu(n).field_cpu_sum_*` cannot be
672    /// written (aggregate-vs-select can't be mixed up).
673    ///
674    /// On a non-per-CPU map (`.bss` / ARRAY / HASH) `.cpu(n)` is a
675    /// no-op — the underlying [`SnapshotMap::cpu`](crate::scenario::snapshot::SnapshotMap::cpu)
676    /// narrow is recorded but ignored, so the value reads the same as
677    /// without it (it neither errors nor filters).
678    pub fn cpu(self, n: usize) -> BpfMapCpuProjector<'a> {
679        BpfMapCpuProjector {
680            series: self.series,
681            map_name: self.map_name,
682            entry_index: self.entry_index,
683            cpu: n,
684        }
685    }
686}
687
688/// Single-CPU view of a per-CPU BPF map, returned by
689/// [`BpfMapProjector::cpu`]. Its `field_*` read the chosen CPU's slot
690/// of the map's per-CPU value — the per-CPU SELECT counterpart to the
691/// cross-CPU [`BpfMapProjector::field_cpu_sum_u64`] reductions.
692///
693/// On a non-per-CPU map the CPU narrow is a no-op (see
694/// [`BpfMapProjector::cpu`]), so `field_*` read the plain value.
695pub struct BpfMapCpuProjector<'a> {
696    series: &'a SampleSeries,
697    map_name: &'a str,
698    entry_index: usize,
699    cpu: usize,
700}
701
702impl<'a> BpfMapCpuProjector<'a> {
703    /// Project this CPU's slot of a named struct field as `u64`.
704    pub fn field_u64(&self, field: &str) -> SeriesField<u64> {
705        let map_name = self.map_name.to_string();
706        let entry_index = self.entry_index;
707        let cpu = self.cpu;
708        let field_owned = field.to_string();
709        self.series.bpf(field, move |snap| {
710            let entry = match snap.map(&map_name) {
711                Ok(m) => m.cpu(cpu).at(entry_index),
712                Err(e) => return Err(e),
713            };
714            entry.get(&field_owned).as_u64()
715        })
716    }
717
718    /// Project this CPU's slot of a named struct field as `i64`.
719    pub fn field_i64(&self, field: &str) -> SeriesField<i64> {
720        let map_name = self.map_name.to_string();
721        let entry_index = self.entry_index;
722        let cpu = self.cpu;
723        let field_owned = field.to_string();
724        self.series.bpf(field, move |snap| {
725            let entry = match snap.map(&map_name) {
726                Ok(m) => m.cpu(cpu).at(entry_index),
727                Err(e) => return Err(e),
728            };
729            entry.get(&field_owned).as_i64()
730        })
731    }
732
733    /// Project this CPU's slot of a named struct field as `f64`.
734    pub fn field_f64(&self, field: &str) -> SeriesField<f64> {
735        let map_name = self.map_name.to_string();
736        let entry_index = self.entry_index;
737        let cpu = self.cpu;
738        let field_owned = field.to_string();
739        self.series.bpf(field, move |snap| {
740            let entry = match snap.map(&map_name) {
741                Ok(m) => m.cpu(cpu).at(entry_index),
742                Err(e) => return Err(e),
743            };
744            entry.get(&field_owned).as_f64()
745        })
746    }
747}
748
749#[cfg(test)]
750mod tests {
751    use super::*;
752    use crate::monitor::btf_render::{RenderedMember, RenderedValue};
753    use crate::monitor::dump::{FailureDumpMap, FailureDumpReport, SCHEMA_SINGLE};
754
755    fn synthetic_report(value: u64) -> FailureDumpReport {
756        let bss_value = RenderedValue::Struct {
757            type_name: Some(".bss".into()),
758            members: vec![
759                RenderedMember {
760                    name: "nr_dispatched".into(),
761                    value: RenderedValue::Uint { bits: 64, value },
762                },
763                RenderedMember {
764                    name: "stall".into(),
765                    value: RenderedValue::Uint { bits: 8, value: 0 },
766                },
767            ],
768        };
769        let bss_map = FailureDumpMap {
770            name: "scx_obj.bss".into(),
771            map_kva: 0,
772            map_type: 2,
773            value_size: 16,
774            max_entries: 1,
775            value: Some(bss_value),
776            entries: Vec::new(),
777            array_entries: Vec::new(),
778            percpu_entries: Vec::new(),
779            percpu_hash_entries: Vec::new(),
780            arena: None,
781            ringbuf: None,
782            stack_trace: None,
783            fd_array: None,
784            error: None,
785        };
786        FailureDumpReport {
787            schema: SCHEMA_SINGLE.to_string(),
788            active_map_kvas: Vec::new(),
789            maps: vec![bss_map],
790            ..Default::default()
791        }
792    }
793
794    /// Build a synthetic report with mixed-shape members so the
795    /// `u64_fields` / `f64_fields` auto-projectors exercise the
796    /// "at least one Ok" filter:
797    ///   - `nr_dispatched`: Uint — projects Ok as u64.
798    ///   - `stall`: Uint — projects Ok as u64.
799    ///   - `balance`: Float — projects Err as u64 (TypeMismatch),
800    ///     Ok as f64.
801    ///   - `flag_str`: Bytes — projects Err as both u64 and f64.
802    fn mixed_shape_report(disp: u64, balance: f64) -> FailureDumpReport {
803        let bss_value = RenderedValue::Struct {
804            type_name: Some(".bss".into()),
805            members: vec![
806                RenderedMember {
807                    name: "nr_dispatched".into(),
808                    value: RenderedValue::Uint {
809                        bits: 64,
810                        value: disp,
811                    },
812                },
813                RenderedMember {
814                    name: "stall".into(),
815                    value: RenderedValue::Uint { bits: 8, value: 0 },
816                },
817                RenderedMember {
818                    name: "balance".into(),
819                    value: RenderedValue::Float {
820                        bits: 64,
821                        value: balance,
822                    },
823                },
824                RenderedMember {
825                    name: "flag_str".into(),
826                    value: RenderedValue::Bytes {
827                        hex: "de ad".into(),
828                    },
829                },
830            ],
831        };
832        let bss_map = FailureDumpMap {
833            name: "scx_obj.bss".into(),
834            map_kva: 0,
835            map_type: 2,
836            value_size: 32,
837            max_entries: 1,
838            value: Some(bss_value),
839            entries: Vec::new(),
840            array_entries: Vec::new(),
841            percpu_entries: Vec::new(),
842            percpu_hash_entries: Vec::new(),
843            arena: None,
844            ringbuf: None,
845            stack_trace: None,
846            fd_array: None,
847            error: None,
848        };
849        FailureDumpReport {
850            schema: SCHEMA_SINGLE.to_string(),
851            active_map_kvas: Vec::new(),
852            maps: vec![bss_map],
853            ..Default::default()
854        }
855    }
856
857    #[test]
858    fn bpf_projection_extracts_field_per_sample() {
859        let drained = vec![
860            (
861                "periodic_000".to_string(),
862                synthetic_report(10),
863                None,
864                Some(100),
865            ),
866            (
867                "periodic_001".to_string(),
868                synthetic_report(20),
869                None,
870                Some(200),
871            ),
872        ];
873        let series = SampleSeries::from_drained(drained, None);
874        let field: SeriesField<u64> =
875            series.bpf("nr_dispatched", |snap| snap.var("nr_dispatched").as_u64());
876        let values: Vec<u64> = field
877            .values_iter()
878            .filter_map(|v| v.as_ref().ok().copied())
879            .collect();
880        assert_eq!(values, vec![10, 20]);
881    }
882
883    #[test]
884    fn bpf_map_projector_field_u64_extracts_field() {
885        let drained = vec![
886            (
887                "periodic_000".to_string(),
888                synthetic_report(10),
889                None,
890                Some(100),
891            ),
892            (
893                "periodic_001".to_string(),
894                synthetic_report(20),
895                None,
896                Some(200),
897            ),
898        ];
899        let series = SampleSeries::from_drained(drained, None);
900        let field = series
901            .bpf_map("scx_obj.bss")
902            .at(0)
903            .field_u64("nr_dispatched");
904        let values: Vec<u64> = field
905            .values_iter()
906            .filter_map(|v| v.as_ref().ok().copied())
907            .collect();
908        assert_eq!(values, vec![10, 20]);
909    }
910
911    #[test]
912    fn bpf_map_projector_member_names_lists_struct_fields() {
913        let drained = vec![(
914            "periodic_000".to_string(),
915            synthetic_report(10),
916            None,
917            Some(100),
918        )];
919        let series = SampleSeries::from_drained(drained, None);
920        let names = series.bpf_map("scx_obj.bss").at(0).member_names();
921        assert!(names.contains(&"nr_dispatched".to_string()));
922        assert!(names.contains(&"stall".to_string()));
923    }
924
925    /// `BpfMapProjector::u64_fields` keeps every member that yields
926    /// at least one `Ok` u64 across the series and drops members
927    /// whose every-sample projection errors. The mixed-shape report
928    /// above carries two u64 members (`nr_dispatched`, `stall`),
929    /// one f64-only member (`balance`) that errors on every u64
930    /// projection, and one bytes member (`flag_str`) that also
931    /// errors on every u64 projection. The returned vec must
932    /// surface only the two u64 names. The `SeriesField::label`
933    /// is set to the field name (see `BpfMapProjector::field_u64`),
934    /// so the tuple's first slot matches the struct member name
935    /// exactly.
936    #[test]
937    fn bpf_map_projector_u64_fields_keeps_at_least_one_ok_excludes_all_err() {
938        let drained = vec![
939            (
940                "periodic_000".to_string(),
941                mixed_shape_report(10, 1.5),
942                None,
943                Some(100),
944            ),
945            (
946                "periodic_001".to_string(),
947                mixed_shape_report(20, 2.5),
948                None,
949                Some(200),
950            ),
951        ];
952        let series = SampleSeries::from_drained(drained, None);
953        let fields = series.bpf_map("scx_obj.bss").at(0).u64_fields();
954        let names: Vec<&str> = fields.iter().map(|(n, _)| n.as_str()).collect();
955        assert!(
956            names.contains(&"nr_dispatched"),
957            "u64-shaped member must be kept: {names:?}",
958        );
959        assert!(
960            names.contains(&"stall"),
961            "u64-shaped member must be kept: {names:?}",
962        );
963        assert!(
964            !names.contains(&"balance"),
965            "Float-shaped member must be excluded — every u64 projection errors: {names:?}",
966        );
967        assert!(
968            !names.contains(&"flag_str"),
969            "Bytes-shaped member must be excluded — every u64 projection errors: {names:?}",
970        );
971        // The kept fields must carry the projected u64 values
972        // verbatim — the tuple's SeriesField is the same object
973        // `field_u64(name)` would return.
974        let dispatched = fields
975            .iter()
976            .find(|(n, _)| n == "nr_dispatched")
977            .expect("nr_dispatched kept above");
978        let values: Vec<u64> = dispatched
979            .1
980            .values_iter()
981            .filter_map(|r| r.as_ref().ok().copied())
982            .collect();
983        assert_eq!(
984            values,
985            vec![10, 20],
986            "kept SeriesField must carry the per-sample u64 projection",
987        );
988    }
989
990    /// Mirror of the u64 test for `f64_fields`. Float, Uint, Int,
991    /// and Enum members coerce to f64 (see `SnapshotField::as_f64`),
992    /// so all three numeric members are kept; the Bytes member
993    /// errors and is dropped. This pins the "at least one Ok"
994    /// filter for the f64 axis distinctly from the u64 axis.
995    #[test]
996    fn bpf_map_projector_f64_fields_keeps_at_least_one_ok_excludes_all_err() {
997        let drained = vec![
998            (
999                "periodic_000".to_string(),
1000                mixed_shape_report(10, 1.5),
1001                None,
1002                Some(100),
1003            ),
1004            (
1005                "periodic_001".to_string(),
1006                mixed_shape_report(20, 2.5),
1007                None,
1008                Some(200),
1009            ),
1010        ];
1011        let series = SampleSeries::from_drained(drained, None);
1012        let fields = series.bpf_map("scx_obj.bss").at(0).f64_fields();
1013        let names: Vec<&str> = fields.iter().map(|(n, _)| n.as_str()).collect();
1014        assert!(
1015            names.contains(&"nr_dispatched"),
1016            "Uint coerces to f64 — must be kept: {names:?}",
1017        );
1018        assert!(
1019            names.contains(&"stall"),
1020            "Uint coerces to f64 — must be kept: {names:?}",
1021        );
1022        assert!(
1023            names.contains(&"balance"),
1024            "Float coerces to f64 — must be kept: {names:?}",
1025        );
1026        assert!(
1027            !names.contains(&"flag_str"),
1028            "Bytes does not coerce to f64 — must be excluded: {names:?}",
1029        );
1030        let balance = fields
1031            .iter()
1032            .find(|(n, _)| n == "balance")
1033            .expect("balance kept above");
1034        let values: Vec<f64> = balance
1035            .1
1036            .values_iter()
1037            .filter_map(|r| r.as_ref().ok().copied())
1038            .collect();
1039        assert_eq!(values.len(), 2, "balance must surface one f64 per sample",);
1040        assert!((values[0] - 1.5).abs() < f64::EPSILON);
1041        assert!((values[1] - 2.5).abs() < f64::EPSILON);
1042    }
1043
1044    /// Empty series — no rows to discover member names from, so
1045    /// `member_names()` returns an empty vec and both auto-projectors
1046    /// yield empty results without panicking. Pins the zero-row
1047    /// iteration path in `BpfMapProjector::member_names` (the loop over
1048    /// `self.series.rows` runs zero times and returns an empty vec).
1049    #[test]
1050    fn bpf_map_projector_field_helpers_empty_series_yields_empty_vec() {
1051        let series = SampleSeries::empty();
1052        let u64s = series.bpf_map("scx_obj.bss").at(0).u64_fields();
1053        assert!(
1054            u64s.is_empty(),
1055            "empty series must yield empty u64_fields, got {} entries",
1056            u64s.len(),
1057        );
1058        let f64s = series.bpf_map("scx_obj.bss").at(0).f64_fields();
1059        assert!(
1060            f64s.is_empty(),
1061            "empty series must yield empty f64_fields, got {} entries",
1062            f64s.len(),
1063        );
1064    }
1065
1066    // --- per-CPU projection ---
1067
1068    /// Build a one-sample series with a single PERCPU map `cpu_ctxs`
1069    /// whose key 0 carries a per-CPU `cpu_ctx { dispatched: Uint }`
1070    /// slot per entry — `None` models an unreadable / unmapped CPU
1071    /// slot (the by-slot `None` the host renderer emits).
1072    fn percpu_series(per_cpu: &[Option<u64>]) -> SampleSeries {
1073        let slots: Vec<Option<RenderedValue>> = per_cpu
1074            .iter()
1075            .map(|v| {
1076                v.map(|n| RenderedValue::Struct {
1077                    type_name: Some("cpu_ctx".into()),
1078                    members: vec![RenderedMember {
1079                        name: "dispatched".into(),
1080                        value: RenderedValue::Uint { bits: 64, value: n },
1081                    }],
1082                })
1083            })
1084            .collect();
1085        let map = FailureDumpMap {
1086            name: "cpu_ctxs".into(),
1087            percpu_entries: vec![crate::monitor::dump::FailureDumpPercpuEntry {
1088                key: 0,
1089                per_cpu: slots,
1090            }],
1091            ..Default::default()
1092        };
1093        let report = FailureDumpReport {
1094            schema: SCHEMA_SINGLE.to_string(),
1095            maps: vec![map],
1096            ..Default::default()
1097        };
1098        SampleSeries::from_drained(
1099            vec![("periodic_000".to_string(), report, None, Some(100))],
1100            None,
1101        )
1102    }
1103
1104    fn oks_u64(f: SeriesField<u64>) -> Vec<u64> {
1105        f.values_iter()
1106            .filter_map(|r| r.as_ref().ok().copied())
1107            .collect()
1108    }
1109    fn oks_i64(f: SeriesField<i64>) -> Vec<i64> {
1110        f.values_iter()
1111            .filter_map(|r| r.as_ref().ok().copied())
1112            .collect()
1113    }
1114    fn oks_f64(f: SeriesField<f64>) -> Vec<f64> {
1115        f.values_iter()
1116            .filter_map(|r| r.as_ref().ok().copied())
1117            .collect()
1118    }
1119
1120    /// `field_cpu_sum_u64` sums the readable per-CPU slots and SKIPS
1121    /// the `None` (unreadable) slot.
1122    #[test]
1123    fn bpf_map_projector_field_cpu_sum_u64_sums_present_skips_none() {
1124        let series = percpu_series(&[Some(11), Some(22), None, Some(44)]);
1125        assert_eq!(
1126            oks_u64(
1127                series
1128                    .bpf_map("cpu_ctxs")
1129                    .at(0)
1130                    .field_cpu_sum_u64("dispatched")
1131            ),
1132            vec![11 + 22 + 44],
1133        );
1134    }
1135
1136    /// `field_cpu_max_u64` / `field_cpu_min_u64` reduce across the
1137    /// readable slots.
1138    #[test]
1139    fn bpf_map_projector_field_cpu_max_min_u64() {
1140        let series = percpu_series(&[Some(11), Some(22), None, Some(44)]);
1141        assert_eq!(
1142            oks_u64(
1143                series
1144                    .bpf_map("cpu_ctxs")
1145                    .at(0)
1146                    .field_cpu_max_u64("dispatched")
1147            ),
1148            vec![44],
1149        );
1150        assert_eq!(
1151            oks_u64(
1152                series
1153                    .bpf_map("cpu_ctxs")
1154                    .at(0)
1155                    .field_cpu_min_u64("dispatched")
1156            ),
1157            vec![11],
1158        );
1159    }
1160
1161    /// The NEW i64 reductions (`field_cpu_sum_i64` etc.) mirror the
1162    /// u64 ones over the same readable slots.
1163    #[test]
1164    fn bpf_map_projector_field_cpu_i64_aggregates() {
1165        let series = percpu_series(&[Some(11), Some(22), None, Some(44)]);
1166        assert_eq!(
1167            oks_i64(
1168                series
1169                    .bpf_map("cpu_ctxs")
1170                    .at(0)
1171                    .field_cpu_sum_i64("dispatched")
1172            ),
1173            vec![77],
1174        );
1175        assert_eq!(
1176            oks_i64(
1177                series
1178                    .bpf_map("cpu_ctxs")
1179                    .at(0)
1180                    .field_cpu_max_i64("dispatched")
1181            ),
1182            vec![44],
1183        );
1184        assert_eq!(
1185            oks_i64(
1186                series
1187                    .bpf_map("cpu_ctxs")
1188                    .at(0)
1189                    .field_cpu_min_i64("dispatched")
1190            ),
1191            vec![11],
1192        );
1193    }
1194
1195    /// `field_cpu_sum_f64` reads the Uint slots as f64 and sums them.
1196    #[test]
1197    fn bpf_map_projector_field_cpu_sum_f64() {
1198        let series = percpu_series(&[Some(10), None, Some(30)]);
1199        assert_eq!(
1200            oks_f64(
1201                series
1202                    .bpf_map("cpu_ctxs")
1203                    .at(0)
1204                    .field_cpu_sum_f64("dispatched")
1205            ),
1206            vec![40.0],
1207        );
1208    }
1209
1210    /// `.cpu(n)` selects ONE CPU's slot; an unmapped (`None`) slot
1211    /// surfaces as an error (distinct from the aggregate skip).
1212    #[test]
1213    fn bpf_map_projector_cpu_select_reads_one_cpu_and_errors_on_none() {
1214        let series = percpu_series(&[Some(11), Some(22), None, Some(44)]);
1215        assert_eq!(
1216            oks_u64(series.bpf_map("cpu_ctxs").cpu(1).field_u64("dispatched")),
1217            vec![22],
1218        );
1219        let none_slot = series.bpf_map("cpu_ctxs").cpu(2).field_u64("dispatched");
1220        assert!(
1221            none_slot.values_iter().all(|r| r.is_err()),
1222            "an unmapped CPU slot must surface as an error on select, not a value",
1223        );
1224    }
1225
1226    /// All-None: `field_cpu_sum_u64` AND `field_cpu_max_u64` both error.
1227    /// A None slot is UNREADABLE (host-read failure), not a real zero,
1228    /// so summing an all-None map to 0 would silently drop the missing
1229    /// data — sum now matches max/min (Err) rather than an empty-sum
1230    /// identity 0. A readable all-zero map still sums to Ok(0).
1231    #[test]
1232    fn bpf_map_projector_field_cpu_all_none_sum_and_max_error() {
1233        let series = percpu_series(&[None, None, None]);
1234        let sum = series
1235            .bpf_map("cpu_ctxs")
1236            .at(0)
1237            .field_cpu_sum_u64("dispatched");
1238        assert!(
1239            sum.values_iter().all(|r| r.is_err()),
1240            "all-None sum must error (None is unreadable, not a real zero), \
1241             not a silent Ok(0)",
1242        );
1243        let max = series
1244            .bpf_map("cpu_ctxs")
1245            .at(0)
1246            .field_cpu_max_u64("dispatched");
1247        assert!(
1248            max.values_iter().all(|r| r.is_err()),
1249            "all-None max must error (max of the empty set is undefined)",
1250        );
1251    }
1252
1253    /// A cross-CPU reduction on a NON-per-CPU map errors (the value is
1254    /// not a per-CPU entry) rather than silently mis-reading it.
1255    #[test]
1256    fn bpf_map_projector_field_cpu_sum_on_non_percpu_errors() {
1257        let series = SampleSeries::from_drained(
1258            vec![(
1259                "periodic_000".to_string(),
1260                synthetic_report(10),
1261                None,
1262                Some(100),
1263            )],
1264            None,
1265        );
1266        let f = series
1267            .bpf_map("scx_obj.bss")
1268            .at(0)
1269            .field_cpu_sum_u64("nr_dispatched");
1270        assert!(
1271            f.values_iter().all(|r| r.is_err()),
1272            "cpu_sum on a non-per-CPU map must error (TypeMismatch)",
1273        );
1274    }
1275
1276    /// A readable per-CPU slot whose value can't decode to the
1277    /// requested scalar makes the aggregate ERR (not a silent skip /
1278    /// partial sum) — only `None` (unreadable) slots are skipped. The
1279    /// aggregator's `?` on `as_u64()` propagates the decode failure and
1280    /// stops the walk, so a malformed slot can never be silently
1281    /// dropped from the sum (the no-silent-drop contract).
1282    #[test]
1283    fn bpf_map_projector_field_cpu_sum_errors_on_non_numeric_slot() {
1284        let bad = RenderedValue::Struct {
1285            type_name: Some("cpu_ctx".into()),
1286            members: vec![RenderedMember {
1287                name: "dispatched".into(),
1288                value: RenderedValue::Bytes {
1289                    hex: "de ad".into(),
1290                },
1291            }],
1292        };
1293        let good = RenderedValue::Struct {
1294            type_name: Some("cpu_ctx".into()),
1295            members: vec![RenderedMember {
1296                name: "dispatched".into(),
1297                value: RenderedValue::Uint { bits: 64, value: 7 },
1298            }],
1299        };
1300        let map = FailureDumpMap {
1301            name: "cpu_ctxs".into(),
1302            percpu_entries: vec![crate::monitor::dump::FailureDumpPercpuEntry {
1303                key: 0,
1304                per_cpu: vec![Some(bad), Some(good)],
1305            }],
1306            ..Default::default()
1307        };
1308        let report = FailureDumpReport {
1309            schema: SCHEMA_SINGLE.to_string(),
1310            maps: vec![map],
1311            ..Default::default()
1312        };
1313        let series = SampleSeries::from_drained(
1314            vec![("periodic_000".to_string(), report, None, Some(100))],
1315            None,
1316        );
1317        let f = series
1318            .bpf_map("cpu_ctxs")
1319            .at(0)
1320            .field_cpu_sum_u64("dispatched");
1321        assert!(
1322            f.values_iter().all(|r| r.is_err()),
1323            "a non-numeric readable slot must make field_cpu_sum_u64 ERR \
1324             (no silent skip / partial sum over the numeric slots)",
1325        );
1326    }
1327
1328    /// Build a synthetic two-bss report: `scx_obj.bss` with `cross
1329    /// = a` + `same = b`, and OPTIONALLY a second `scx_other.bss`
1330    /// with `cross = c` + `same = d`. Mirrors the post-
1331    /// `Op::ReplaceScheduler` shape where two scheduler obj bss
1332    /// copies coexist in the same snapshot and `live_vars_via`'s
1333    /// picker resolves which one is live by max-sum.
1334    fn two_bss_report(primary: (u64, u64), secondary: Option<(u64, u64)>) -> FailureDumpReport {
1335        fn make_bss(name: &str, cross: u64, same: u64) -> FailureDumpMap {
1336            FailureDumpMap {
1337                name: name.into(),
1338                map_kva: 0,
1339                map_type: 2,
1340                value_size: 16,
1341                max_entries: 1,
1342                value: Some(RenderedValue::Struct {
1343                    type_name: Some(name.into()),
1344                    members: vec![
1345                        RenderedMember {
1346                            name: "cross".into(),
1347                            value: RenderedValue::Uint {
1348                                bits: 64,
1349                                value: cross,
1350                            },
1351                        },
1352                        RenderedMember {
1353                            name: "same".into(),
1354                            value: RenderedValue::Uint {
1355                                bits: 64,
1356                                value: same,
1357                            },
1358                        },
1359                    ],
1360                }),
1361                entries: Vec::new(),
1362                array_entries: Vec::new(),
1363                percpu_entries: Vec::new(),
1364                percpu_hash_entries: Vec::new(),
1365                arena: None,
1366                ringbuf: None,
1367                stack_trace: None,
1368                fd_array: None,
1369                error: None,
1370            }
1371        }
1372        let mut maps = vec![make_bss("scx_obj.bss", primary.0, primary.1)];
1373        if let Some((c, s)) = secondary {
1374            maps.push(make_bss("scx_other.bss", c, s));
1375        }
1376        FailureDumpReport {
1377            schema: SCHEMA_SINGLE.to_string(),
1378            active_map_kvas: Vec::new(),
1379            maps,
1380            ..Default::default()
1381        }
1382    }
1383
1384    /// Single-candidate map: `live_bpf_vars_via` should resolve
1385    /// both names from `scx_obj.bss` per sample and produce two
1386    /// parallel `SeriesField<u64>`s carrying the per-sample
1387    /// `cross` and `same` values.
1388    #[test]
1389    fn live_bpf_vars_via_single_map_co_picks_both_names() {
1390        let drained = vec![
1391            (
1392                "periodic_000".to_string(),
1393                two_bss_report((10, 20), None),
1394                None,
1395                Some(100),
1396            ),
1397            (
1398                "periodic_001".to_string(),
1399                two_bss_report((30, 40), None),
1400                None,
1401                Some(200),
1402            ),
1403        ];
1404        let series = SampleSeries::from_drained(drained, None);
1405        let [cross, same] = series.live_bpf_vars_via(
1406            ["cross", "same"],
1407            crate::scenario::snapshot::pickers::max_by_sum_u64,
1408        );
1409        let cross_values: Vec<u64> = cross
1410            .values_iter()
1411            .filter_map(|r| r.as_ref().ok().copied())
1412            .collect();
1413        let same_values: Vec<u64> = same
1414            .values_iter()
1415            .filter_map(|r| r.as_ref().ok().copied())
1416            .collect();
1417        assert_eq!(cross_values, vec![10, 30]);
1418        assert_eq!(same_values, vec![20, 40]);
1419    }
1420
1421    /// Placeholder-mid-series: when one snapshot's report is a
1422    /// placeholder (freeze rendezvous failed, walker unavailable),
1423    /// EVERY field slot for that row gets the same
1424    /// `PlaceholderSample` error — not just one. Pins that the
1425    /// per-field substitution (`live_bpf_vars_via`'s
1426    /// `if row.report.is_placeholder` `for slot in &mut per_field` push loop)
1427    /// doesn't silently
1428    /// drop a sample from one field while keeping it in another.
1429    #[test]
1430    fn live_bpf_vars_via_placeholder_substitutes_into_all_field_slots() {
1431        // Build a synthetic placeholder report: is_placeholder=true,
1432        // no maps populated. The construction mirrors what
1433        // freeze_coord stores when a rendezvous times out.
1434        let placeholder = FailureDumpReport {
1435            schema: SCHEMA_SINGLE.to_string(),
1436            is_placeholder: true,
1437            scx_walker_unavailable: Some("rendezvous timed out".to_string()),
1438            ..Default::default()
1439        };
1440        let drained = vec![
1441            (
1442                "periodic_000".to_string(),
1443                two_bss_report((10, 20), None),
1444                None,
1445                Some(100),
1446            ),
1447            ("periodic_001".to_string(), placeholder, None, Some(200)),
1448            (
1449                "periodic_002".to_string(),
1450                two_bss_report((30, 40), None),
1451                None,
1452                Some(300),
1453            ),
1454        ];
1455        let series = SampleSeries::from_drained(drained, None);
1456        let [cross, same] = series.live_bpf_vars_via(
1457            ["cross", "same"],
1458            crate::scenario::snapshot::pickers::max_by_sum_u64,
1459        );
1460        let cross_results: Vec<bool> = cross.values_iter().map(|r| r.is_ok()).collect();
1461        let same_results: Vec<bool> = same.values_iter().map(|r| r.is_ok()).collect();
1462        // Sample 0 + 2: ok. Sample 1 (placeholder): err in BOTH
1463        // fields. The two fields' Ok/Err patterns must match —
1464        // otherwise the per-field split lost coherence.
1465        assert_eq!(cross_results, vec![true, false, true]);
1466        assert_eq!(same_results, vec![true, false, true]);
1467        // The placeholder slot's error must carry the
1468        // PlaceholderSample variant (not a generic catch-all).
1469        let cross_err = cross
1470            .values_iter()
1471            .nth(1)
1472            .unwrap()
1473            .as_ref()
1474            .expect_err("placeholder row produces Err");
1475        assert!(
1476            matches!(
1477                cross_err,
1478                crate::scenario::snapshot::SnapshotError::PlaceholderSample { .. }
1479            ),
1480            "placeholder row must surface PlaceholderSample; got {cross_err:?}",
1481        );
1482    }
1483
1484    /// When `live_vars_via` itself fails for a row (no candidate
1485    /// map has all the names, or the picker returned None), the
1486    /// SAME error MUST be substituted into all N field slots for
1487    /// that row — not split or dropped. Pins `live_bpf_vars_via`'s
1488    /// `Err(e) =>` `for slot in &mut per_field` error-substitution loop.
1489    #[test]
1490    fn live_bpf_vars_via_picker_none_substitutes_into_all_field_slots() {
1491        let drained = vec![(
1492            "periodic_000".to_string(),
1493            two_bss_report((10, 20), Some((30, 40))),
1494            None,
1495            Some(100),
1496        )];
1497        let series = SampleSeries::from_drained(drained, None);
1498        // Picker that always returns None — forces live_vars_via
1499        // to surface ProjectionFailed for the row.
1500        let always_none =
1501            |_rows: &[(&str, Vec<crate::scenario::snapshot::SnapshotField<'_>>)]| None;
1502        let [a, b] = series.live_bpf_vars_via(["cross", "same"], always_none);
1503        let a_err = a
1504            .values_iter()
1505            .next()
1506            .unwrap()
1507            .as_ref()
1508            .expect_err("picker-None must surface as Err");
1509        let b_err = b
1510            .values_iter()
1511            .next()
1512            .unwrap()
1513            .as_ref()
1514            .expect_err("picker-None must surface as Err — same row → same Err");
1515        // The two field slots' errors must carry the SAME variant.
1516        assert!(
1517            matches!(
1518                a_err,
1519                crate::scenario::snapshot::SnapshotError::ProjectionFailed { .. }
1520            ),
1521            "field 0 must carry ProjectionFailed; got {a_err:?}",
1522        );
1523        assert!(
1524            matches!(
1525                b_err,
1526                crate::scenario::snapshot::SnapshotError::ProjectionFailed { .. }
1527            ),
1528            "field 1 must carry ProjectionFailed; got {b_err:?}",
1529        );
1530    }
1531
1532    /// When the picker returns an out-of-range index, `live_vars_via`
1533    /// returns `ProjectionFailed` and the SAME error is substituted
1534    /// into every field slot for that row. Sibling of the
1535    /// picker-None case, distinct underlying failure mode.
1536    #[test]
1537    fn live_bpf_vars_via_picker_oor_substitutes_into_all_field_slots() {
1538        let drained = vec![(
1539            "periodic_000".to_string(),
1540            two_bss_report((10, 20), Some((30, 40))),
1541            None,
1542            Some(100),
1543        )];
1544        let series = SampleSeries::from_drained(drained, None);
1545        // Picker that returns an index way past the candidate count.
1546        let always_oor =
1547            |_rows: &[(&str, Vec<crate::scenario::snapshot::SnapshotField<'_>>)]| Some(999_usize);
1548        let [a, b] = series.live_bpf_vars_via(["cross", "same"], always_oor);
1549        let a_err = a.values_iter().next().unwrap().as_ref().err().unwrap();
1550        let b_err = b.values_iter().next().unwrap().as_ref().err().unwrap();
1551        assert!(
1552            matches!(
1553                a_err,
1554                crate::scenario::snapshot::SnapshotError::ProjectionFailed { .. }
1555            ),
1556            "picker-OOR must surface ProjectionFailed in field 0; got {a_err:?}",
1557        );
1558        assert!(
1559            matches!(
1560                b_err,
1561                crate::scenario::snapshot::SnapshotError::ProjectionFailed { .. }
1562            ),
1563            "picker-OOR must surface ProjectionFailed in field 1; got {b_err:?}",
1564        );
1565    }
1566
1567    /// Duplicate names in the request slice: `live_vars_via` pushes
1568    /// one field per name (no dedup), so the resulting per-field
1569    /// SeriesFields each carry the SAME projected values. Both
1570    /// fields are still well-formed (length matches sample count);
1571    /// the only "skew" is the trivial one where dup names produce
1572    /// dup values. Pins that the per-field split honors `names.len()`
1573    /// rather than a deduplicated set.
1574    #[test]
1575    fn live_bpf_vars_via_duplicate_names_yields_parallel_duplicates() {
1576        let drained = vec![(
1577            "periodic_000".to_string(),
1578            two_bss_report((10, 20), None),
1579            None,
1580            Some(100),
1581        )];
1582        let series = SampleSeries::from_drained(drained, None);
1583        let [a, b] = series.live_bpf_vars_via(
1584            ["cross", "cross"],
1585            crate::scenario::snapshot::pickers::max_by_sum_u64,
1586        );
1587        let av: Vec<u64> = a
1588            .values_iter()
1589            .filter_map(|r| r.as_ref().ok().copied())
1590            .collect();
1591        let bv: Vec<u64> = b
1592            .values_iter()
1593            .filter_map(|r| r.as_ref().ok().copied())
1594            .collect();
1595        assert_eq!(av, vec![10], "first slot carries 'cross' = 10");
1596        assert_eq!(bv, vec![10], "second slot (duplicate) carries 'cross' = 10");
1597        // Pin field-count parity with names.len(): no silent drop.
1598        assert_eq!(
1599            av.len(),
1600            bv.len(),
1601            "duplicate-names must not skew per-field length"
1602        );
1603    }
1604
1605    /// Multi-candidate map: `live_bpf_vars_via` must route both
1606    /// names through the SAME picker-selected candidate so the
1607    /// downstream ratio's numerator and denominator can't be
1608    /// split across two different scheduler obj bss copies. The
1609    /// `max_by_sum_u64` picker selects whichever bss has the
1610    /// larger `cross + same` sum.
1611    #[test]
1612    fn live_bpf_vars_via_two_maps_picker_routes_both_through_winner() {
1613        let drained = vec![
1614            // Sample 0: primary sum 30, secondary sum 1100 → secondary wins
1615            (
1616                "periodic_000".to_string(),
1617                two_bss_report((10, 20), Some((500, 600))),
1618                None,
1619                Some(100),
1620            ),
1621            // Sample 1: primary sum 10000, secondary sum 100 → primary wins
1622            (
1623                "periodic_001".to_string(),
1624                two_bss_report((4000, 6000), Some((50, 50))),
1625                None,
1626                Some(200),
1627            ),
1628        ];
1629        let series = SampleSeries::from_drained(drained, None);
1630        let [cross, same] = series.live_bpf_vars_via(
1631            ["cross", "same"],
1632            crate::scenario::snapshot::pickers::max_by_sum_u64,
1633        );
1634        let cross_values: Vec<u64> = cross
1635            .values_iter()
1636            .filter_map(|r| r.as_ref().ok().copied())
1637            .collect();
1638        let same_values: Vec<u64> = same
1639            .values_iter()
1640            .filter_map(|r| r.as_ref().ok().copied())
1641            .collect();
1642        // Sample 0: secondary wins → (500, 600). Sample 1: primary
1643        // wins → (4000, 6000). Both names came from the SAME map
1644        // per sample, never split.
1645        assert_eq!(cross_values, vec![500, 4000]);
1646        assert_eq!(same_values, vec![600, 6000]);
1647    }
1648
1649    /// Build a single-bss report stamped with the given
1650    /// `active_map_kvas` so phase-stability tests can simulate the
1651    /// walker resolving to one specific KVA set per snapshot.
1652    fn single_bss_report_with_kvas(value: u64, active_kvas: Vec<u64>) -> FailureDumpReport {
1653        let bss = FailureDumpMap {
1654            name: "scx_obj.bss".into(),
1655            map_kva: active_kvas.first().copied().unwrap_or(0),
1656            map_type: 2,
1657            value_size: 8,
1658            max_entries: 1,
1659            value: Some(RenderedValue::Struct {
1660                type_name: Some("scx_obj.bss".into()),
1661                members: vec![RenderedMember {
1662                    name: "counter".into(),
1663                    value: RenderedValue::Uint { bits: 64, value },
1664                }],
1665            }),
1666            entries: Vec::new(),
1667            array_entries: Vec::new(),
1668            percpu_entries: Vec::new(),
1669            percpu_hash_entries: Vec::new(),
1670            arena: None,
1671            ringbuf: None,
1672            stack_trace: None,
1673            fd_array: None,
1674            error: None,
1675        };
1676        FailureDumpReport {
1677            schema: crate::monitor::dump::SCHEMA_SINGLE.to_string(),
1678            active_obj_name: Some("scx_obj".to_string()),
1679            active_map_kvas: active_kvas,
1680            maps: vec![bss],
1681            ..Default::default()
1682        }
1683    }
1684
1685    fn drained_entry(
1686        tag: &str,
1687        report: FailureDumpReport,
1688        step_index: Option<u16>,
1689        elapsed_ms: u64,
1690    ) -> crate::scenario::snapshot::DrainedSnapshotEntry {
1691        crate::scenario::snapshot::DrainedSnapshotEntry {
1692            tag: tag.to_string(),
1693            report,
1694            stats: Err(crate::scenario::snapshot::MissingStatsReason::NoSchedulerBinary),
1695            elapsed_ms: Some(elapsed_ms),
1696            boundary_offset_ms: None,
1697            step_index,
1698        }
1699    }
1700
1701    /// Walker drift within a phase: sample 0 and sample 1 are in
1702    /// the same phase but their `active_map_kvas` differ (the
1703    /// walker re-published mid-phase, simulating the post-swap
1704    /// settle window). Sample 0 pins the phase to its KVA set;
1705    /// sample 1 must surface `WalkerDriftedWithinPhase` so the
1706    /// downstream counter-delta reducer sees a single-source
1707    /// monotonic series.
1708    #[test]
1709    fn bpf_live_u64_walker_drift_within_phase_surfaces_drift_error() {
1710        let drained = vec![
1711            drained_entry(
1712                "p001",
1713                single_bss_report_with_kvas(100, vec![0x1000]),
1714                Some(1),
1715                100,
1716            ),
1717            drained_entry(
1718                "p002",
1719                single_bss_report_with_kvas(200, vec![0x2000]),
1720                Some(1),
1721                200,
1722            ),
1723        ];
1724        let series = SampleSeries::from_drained_typed(drained, None);
1725        let f = series.bpf_live_u64("counter");
1726        let results: Vec<&SnapshotResult<u64>> = f.values_iter().collect();
1727        assert_eq!(results.len(), 2);
1728        assert!(
1729            matches!(results[0], Ok(100)),
1730            "first sample pins, got {:?}",
1731            results[0]
1732        );
1733        match results[1] {
1734            Err(crate::scenario::snapshot::SnapshotError::WalkerDriftedWithinPhase {
1735                pinned_kvas,
1736                sample_kvas,
1737                requested,
1738                ..
1739            }) => {
1740                assert_eq!(pinned_kvas, &vec![0x1000]);
1741                assert_eq!(sample_kvas, &vec![0x2000]);
1742                assert_eq!(requested, "counter");
1743            }
1744            other => panic!("expected WalkerDriftedWithinPhase, got {other:?}"),
1745        }
1746    }
1747
1748    /// Walker re-publishes the SAME KVA across same-phase
1749    /// samples — no drift. Both samples pass through with Ok
1750    /// values.
1751    #[test]
1752    fn bpf_live_u64_walker_stable_within_phase_passes_through() {
1753        let drained = vec![
1754            drained_entry(
1755                "p001",
1756                single_bss_report_with_kvas(100, vec![0x1000]),
1757                Some(1),
1758                100,
1759            ),
1760            drained_entry(
1761                "p002",
1762                single_bss_report_with_kvas(150, vec![0x1000]),
1763                Some(1),
1764                200,
1765            ),
1766        ];
1767        let series = SampleSeries::from_drained_typed(drained, None);
1768        let f = series.bpf_live_u64("counter");
1769        let results: Vec<&SnapshotResult<u64>> = f.values_iter().collect();
1770        assert!(matches!(results[0], Ok(100)));
1771        assert!(matches!(results[1], Ok(150)));
1772    }
1773
1774    /// Walker output is empty (pre-walker capture) for both
1775    /// samples — no pin established, no drift detection, both
1776    /// pass through unchanged (the per-snapshot AmbiguousVar
1777    /// guard at the Snapshot::var layer covers the
1778    /// multi-bss-with-empty-walker case separately).
1779    #[test]
1780    fn bpf_live_u64_empty_walker_output_passes_through() {
1781        let drained = vec![
1782            drained_entry(
1783                "p001",
1784                single_bss_report_with_kvas(100, vec![]),
1785                Some(1),
1786                100,
1787            ),
1788            drained_entry(
1789                "p002",
1790                single_bss_report_with_kvas(150, vec![]),
1791                Some(1),
1792                200,
1793            ),
1794        ];
1795        let series = SampleSeries::from_drained_typed(drained, None);
1796        let f = series.bpf_live_u64("counter");
1797        let results: Vec<&SnapshotResult<u64>> = f.values_iter().collect();
1798        assert!(matches!(results[0], Ok(100)));
1799        assert!(matches!(results[1], Ok(150)));
1800    }
1801
1802    /// Different phases get independent pins — drift detection
1803    /// resets at phase boundaries. Phase 1 pins to 0x1000;
1804    /// phase 2 pins to 0x2000 fresh.
1805    #[test]
1806    fn bpf_live_u64_pins_reset_at_phase_boundaries() {
1807        let drained = vec![
1808            drained_entry(
1809                "p001",
1810                single_bss_report_with_kvas(100, vec![0x1000]),
1811                Some(1),
1812                100,
1813            ),
1814            drained_entry(
1815                "p002",
1816                single_bss_report_with_kvas(150, vec![0x1000]),
1817                Some(1),
1818                200,
1819            ),
1820            drained_entry(
1821                "p003",
1822                single_bss_report_with_kvas(50, vec![0x2000]),
1823                Some(2),
1824                300,
1825            ),
1826            drained_entry(
1827                "p004",
1828                single_bss_report_with_kvas(75, vec![0x2000]),
1829                Some(2),
1830                400,
1831            ),
1832        ];
1833        let series = SampleSeries::from_drained_typed(drained, None);
1834        let f = series.bpf_live_u64("counter");
1835        let results: Vec<&SnapshotResult<u64>> = f.values_iter().collect();
1836        assert!(matches!(results[0], Ok(100)));
1837        assert!(matches!(results[1], Ok(150)));
1838        assert!(matches!(results[2], Ok(50)));
1839        assert!(matches!(results[3], Ok(75)));
1840    }
1841}