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}