ktstr/scenario/sample/mod.rs
1//! Unified periodic-sample bundle and series projection.
2//!
3//! At every periodic boundary (see [`super::snapshot`] and the
4//! freeze coordinator's periodic-capture loop), the framework
5//! captures a coupled [`FailureDumpReport`] + scx_stats JSON pair.
6//! [`Sample`] is the borrowed-view tuple over that pair plus the
7//! per-sample tag and elapsed-millisecond timestamp;
8//! [`SampleSeries`] is the ordered sequence of samples drained
9//! from a `SnapshotBridge` after VM exit.
10//!
11//! Test authors do not construct samples manually — they call
12//! [`SampleSeries::from_drained`] on the periodic bundle the
13//! bridge surfaces via
14//! `SnapshotBridge::drain_ordered_with_stats`, then project the
15//! series along one of four orthogonal axes:
16//!
17//! - **bpf** — kernel BPF state through
18//! [`SampleSeries::bpf`] / the typed
19//! [`SampleSeries::bpf_map`] helper.
20//! - **stats** — userspace scx_stats JSON through
21//! [`SampleSeries::stats`] / the typed
22//! [`SampleSeries::stats_path`] helper.
23//! - **host** — per-sample per-CPU host timeline through
24//! [`SampleSeries::host`] (sourced from
25//! `FailureDumpReport::per_cpu_time`).
26//! - **monitor** — per-VM-run cross-CPU host monitor aggregate
27//! through [`SampleSeries::monitor`] (sourced from
28//! `MonitorReport::summary`).
29//!
30//! Each projection yields a
31//! [`crate::assert::temporal::SeriesField`] that
32//! flows into the temporal-assertion patterns
33//! (`nondecreasing`, `rate_within`, `steady_within`,
34//! `converges_to`, `always_true`, `ratio_within`) defined in
35//! [`crate::assert::temporal`].
36//!
37//! # Lifetime model
38//!
39//! `SampleSeries` owns the drained `Vec<SampleRow>` (each row:
40//! tag, report, stats, elapsed_ms, boundary_offset_ms, step_index)
41//! so projection closures can borrow into the
42//! reports / stats without copying. Constructing a `Sample` only
43//! borrows; [`SampleSeries::iter_samples`] yields `Sample<'_>`
44//! bound by the series' own lifetime.
45
46use crate::monitor::MonitorReport;
47use crate::monitor::dump::FailureDumpReport;
48
49use super::snapshot::{Snapshot, SnapshotResult};
50use crate::assert::temporal::SeriesField;
51
52mod bpf;
53mod host;
54mod monitor;
55mod stats;
56
57pub use bpf::{BpfMapCpuProjector, BpfMapProjector};
58pub use host::HostView;
59pub use monitor::{ERROR_CLASS_NAMES, MonitorView, ScxEventsView};
60pub use stats::{StatsPathProjector, StatsValue};
61
62/// One captured periodic sample: a frozen BPF snapshot paired with
63/// the scx_stats JSON observed just before the freeze rendezvous,
64/// labelled with the periodic tag (`periodic_000` …
65/// `periodic_NNN`) and tagged with the elapsed milliseconds since
66/// `run_start`.
67///
68/// Constructed by [`SampleSeries::iter_samples`] — test authors do
69/// not invoke `Sample::new` directly. The `'a` lifetime ties the
70/// borrowed `tag`, `snapshot`, and `stats` references back to the
71/// owning [`SampleSeries`].
72#[derive(Debug)]
73#[non_exhaustive]
74pub struct Sample<'a> {
75 /// Periodic tag the freeze coordinator stamped onto this
76 /// sample. Always begins with `"periodic_"` followed by a
77 /// zero-padded ordinal — see
78 /// `crate::vmm::freeze_coord::periodic_tag`.
79 pub tag: &'a str,
80 /// Wall-clock elapsed milliseconds (pause-adjusted: the
81 /// coordinator subtracts cumulative ScenarioPause/Resume
82 /// pause time and any in-flight pause window) since the
83 /// coordinator's `run_start` instant at stats-request
84 /// completion time, pre-freeze. The coordinator captures
85 /// this timestamp AFTER the scx_stats request returns
86 /// (or fails) and BEFORE entering the freeze rendezvous,
87 /// so the value reflects when the running scheduler's
88 /// stats were observed. BPF state is observed up to
89 /// `FREEZE_RENDEZVOUS_TIMEOUT` later than this anchor.
90 /// `None` when the bridge could not record a timestamp
91 /// (legacy stores without elapsed metadata, or
92 /// non-periodic captures surfaced through the same drain) —
93 /// distinct from a measured `Some(0)`.
94 pub elapsed_ms: Option<u64>,
95 /// Frozen BPF state captured at this boundary. The view is
96 /// cheap to build — accessor methods walk the underlying
97 /// [`FailureDumpReport`] in place.
98 pub snapshot: Snapshot<'a>,
99 /// scx_stats JSON observed by a stats request issued just
100 /// BEFORE the freeze rendezvous. `Err(reason)` when the stats
101 /// client was not wired (`scheduler_binary` is absent) or the
102 /// request failed — the carried
103 /// [`MissingStatsReason`](crate::scenario::snapshot::MissingStatsReason)
104 /// identifies the specific failure mode (no scheduler, relay
105 /// rejected, watchdog cancelled, scheduler errno, etc.).
106 /// [`SampleSeries::stats`] surfaces this `Err` as a per-sample
107 /// [`SnapshotError::MissingStats`](crate::scenario::snapshot::SnapshotError::MissingStats)
108 /// slot in the resulting [`SeriesField`] rather than vacuously
109 /// skipping; temporal patterns handle that error per their own
110 /// policy (gap-tolerant patterns like `nondecreasing`,
111 /// `rate_within`, `steady_within`, `converges_to`, and
112 /// `ratio_within` skip the sample with a rendered Note, while
113 /// strict patterns like `always_true` and `each` fail the
114 /// assertion so a stats-coverage gap can never silently slip
115 /// past the call site).
116 pub stats: Result<&'a serde_json::Value, &'a crate::scenario::snapshot::MissingStatsReason>,
117 /// Scenario phase index the freeze coordinator stamped onto
118 /// this sample at capture time. Encoded per the framework's
119 /// 1-indexed phase convention — `0` is the BASELINE settle
120 /// window, `1..=N` align with scenario Step ordinals. `None`
121 /// for fixture-injected samples that took the unstamped legacy
122 /// bridge paths
123 /// ([`super::snapshot::SnapshotBridge::capture`] /
124 /// [`super::snapshot::SnapshotBridge::store`] /
125 /// [`super::snapshot::SnapshotBridge::store_with_stats`]);
126 /// production captures via the periodic-fire path and the
127 /// on-demand `Op::CaptureSnapshot` / `Op::WatchSnapshot` apply
128 /// arms always carry `Some(idx)`. Read by
129 /// [`SampleSeries::by_stamped_phase`] (and as the offset-less
130 /// fallback in [`SampleSeries::by_stimulus_phase`]) to bucket
131 /// samples per scenario phase for the phase-aware aggregator.
132 pub step_index: Option<u16>,
133 /// Workload-relative boundary offset (ms) this periodic capture
134 /// was scheduled for (`boundary_ns - scenario_anchor_ns`), or
135 /// `None` for non-periodic / on-demand captures. Distinct from
136 /// `elapsed_ms` (run_start-relative fire time, ~uniform across a
137 /// deferred-fire burst). Read by
138 /// [`crate::assert::build_phase_buckets`] /
139 /// [`crate::assert::build_phase_buckets_with_stimulus`] to
140 /// attribute the capture to the guest step whose stimulus window
141 /// contains this offset, and as the workload-relative bucket
142 /// start/end. `None` falls back to `elapsed_ms` + the stored
143 /// `step_index` (today's behavior for on-demand captures).
144 pub boundary_offset_ms: Option<u64>,
145}
146
147/// Ordered collection of [`Sample`]s drained from a
148/// [`SnapshotBridge`](super::snapshot::SnapshotBridge) after a VM
149/// run completes. Owns the underlying tuples so projection
150/// closures can borrow into the reports / stats without copying.
151///
152/// Test authors construct a `SampleSeries` from
153/// [`super::snapshot::SnapshotBridge::drain_ordered_with_stats`]
154/// via [`Self::from_drained`]; non-periodic tags (e.g. `Op::CaptureSnapshot`
155/// captures) coexist in the drain output and are tolerated by the
156/// projection helpers — the typical pattern is to pre-filter to
157/// periodic tags via [`Self::periodic_only`] before asserting.
158#[derive(Debug, Clone)]
159pub struct SampleSeries {
160 rows: Vec<SampleRow>,
161 /// Host-side monitor report for the VM run that produced this
162 /// series. `None` when the monitor did not run (host-only tests,
163 /// early VM failure, or `from_drained` was called with `None`
164 /// for the monitor argument). Aggregates inside the report refer
165 /// to THAT series' monitoring window only — no cross-series
166 /// merge is supported. Surfaced via [`Self::monitor`] which wraps
167 /// it in a borrowed [`MonitorView`] for typed projection.
168 monitor: Option<MonitorReport>,
169}
170
171/// Owned tuple stored inside [`SampleSeries`]. Mirrors the shape of
172/// [`super::snapshot::SnapshotBridge::drain_ordered_with_stats`]
173/// but carries the timestamp as `Option<u64>` — `None` preserves the
174/// bridge's "no timestamp recorded" signal so a not-measured sample
175/// stays distinct from a measured `Some(0)`.
176#[derive(Debug, Clone)]
177struct SampleRow {
178 tag: String,
179 report: FailureDumpReport,
180 stats: Result<serde_json::Value, crate::scenario::snapshot::MissingStatsReason>,
181 elapsed_ms: Option<u64>,
182 /// Workload-relative boundary offset (ms) for periodic captures;
183 /// `None` for non-periodic / on-demand. Mirrored from
184 /// [`super::snapshot::DrainedSnapshotEntry::boundary_offset_ms`].
185 boundary_offset_ms: Option<u64>,
186 /// Scenario phase index stamped at capture time by the
187 /// step-aware bridge entry points, mirrored from
188 /// [`super::snapshot::DrainedSnapshotEntry::step_index`].
189 /// `None` for unstamped legacy / fixture captures (see
190 /// [`Sample::step_index`] for the surfaced semantic).
191 step_index: Option<u16>,
192}
193
194/// Common scaffolding shared by every projector axis (bpf / stats /
195/// host per-CPU). Iterates `rows` once, threads each row's
196/// `tag` and `elapsed_ms` into the resulting [`SeriesField`], and
197/// invokes `row_to_slot` to compute the per-sample value or per-
198/// sample `SnapshotError`. Keeps the `tags`/`elapsed`/`values`
199/// vec lengths in lock-step so the [`SeriesField::from_parts`]
200/// length-parity invariant never triggers.
201fn build_series_field<T>(
202 rows: &[SampleRow],
203 label: impl Into<String>,
204 mut row_to_slot: impl FnMut(&SampleRow) -> SnapshotResult<T>,
205) -> SeriesField<T> {
206 let mut values: Vec<SnapshotResult<T>> = Vec::with_capacity(rows.len());
207 let mut tags: Vec<String> = Vec::with_capacity(rows.len());
208 let mut elapsed: Vec<Option<u64>> = Vec::with_capacity(rows.len());
209 let mut phases: Vec<Option<crate::assert::Phase>> = Vec::with_capacity(rows.len());
210 for row in rows {
211 tags.push(row.tag.clone());
212 elapsed.push(row.elapsed_ms);
213 // The drained-bridge step_index is already in the 1-indexed
214 // encoding `crate::assert::Phase` wraps (BASELINE = 0, Step[k]
215 // = k + 1). Thread it through so `SeriesField::phase` /
216 // `value_at_phase` / `last_per_phase` / `ratio_across_phases`
217 // see live phase stamps. Synthetic rows (from `from_drained`
218 // test path) carry `step_index = None` and stay None here.
219 phases.push(row.step_index.map(crate::assert::Phase::from));
220 values.push(row_to_slot(row));
221 }
222 SeriesField::from_parts_with_phases_opt(label, tags, elapsed, values, phases)
223}
224
225impl SampleSeries {
226 /// Build a series from the bridge's drained tuple. Every entry
227 /// is preserved in the order the bridge surfaced, including
228 /// non-periodic tags — callers that want the periodic-only
229 /// view chain `.periodic_only()`.
230 ///
231 /// `monitor` is the per-VM-run `MonitorReport` (typically
232 /// `result.monitor.clone()` from a `VmResult`). Pass `None`
233 /// when the monitor did not run (host-only tests, early VM
234 /// failure). Surfaced via [`Self::monitor`] for typed projection
235 /// of the summary + scx_events + per-sample timelines.
236 pub fn from_drained(
237 drained: Vec<(
238 String,
239 FailureDumpReport,
240 Option<serde_json::Value>,
241 Option<u64>,
242 )>,
243 monitor: Option<MonitorReport>,
244 ) -> Self {
245 let rows = drained
246 .into_iter()
247 .map(|(tag, report, stats, elapsed_ms)| SampleRow {
248 tag,
249 report,
250 // Test/synthetic caller convention: `None` collapses to
251 // the `NoSchedulerBinary` reason because that's the
252 // shape every fixture has historically modelled — no
253 // scheduler client wired, no stats. Production callers
254 // that have a typed [`SchedStatsError`] use
255 // [`Self::from_drained_typed`] instead, which preserves
256 // the specific failure mode.
257 stats: stats.map(Ok).unwrap_or(Err(
258 crate::scenario::snapshot::MissingStatsReason::NoSchedulerBinary,
259 )),
260 elapsed_ms,
261 // Fixture/tuple path carries no scheduled boundary offset.
262 boundary_offset_ms: None,
263 // Unstamped fixture path: samples surface with
264 // `step_index = None` and fall under the
265 // by_stamped_phase fallback bucket. Production callers
266 // thread the bridge-stamped index via from_drained_typed.
267 step_index: None,
268 })
269 .collect();
270 Self { rows, monitor }
271 }
272
273 /// Production-path constructor: takes the typed
274 /// [`Result<serde_json::Value, MissingStatsReason>`](crate::scenario::snapshot::MissingStatsReason)
275 /// shape returned by
276 /// [`SnapshotBridge::drain_ordered_with_stats`](crate::scenario::snapshot::SnapshotBridge::drain_ordered_with_stats),
277 /// preserving the specific failure mode (relay error, scheduler
278 /// errno, watchdog cancellation, etc.). Use this when the caller
279 /// has access to the bridge drain output; tests prefer
280 /// [`Self::from_drained`] which accepts the simpler `Option`
281 /// shape and collapses absent → `NoSchedulerBinary`.
282 pub fn from_drained_typed(
283 drained: Vec<crate::scenario::snapshot::DrainedSnapshotEntry>,
284 monitor: Option<MonitorReport>,
285 ) -> Self {
286 let rows = drained
287 .into_iter()
288 .map(|entry| {
289 let crate::scenario::snapshot::DrainedSnapshotEntry {
290 tag,
291 report,
292 stats,
293 elapsed_ms,
294 boundary_offset_ms,
295 step_index,
296 ..
297 } = entry;
298 SampleRow {
299 tag,
300 report,
301 stats,
302 elapsed_ms,
303 boundary_offset_ms,
304 step_index,
305 }
306 })
307 .collect();
308 Self { rows, monitor }
309 }
310
311 /// Empty series. Useful for tests and for the no-periodic-
312 /// capture case where every assertion vacuously passes.
313 pub fn empty() -> Self {
314 Self {
315 rows: Vec::new(),
316 monitor: None,
317 }
318 }
319
320 /// True when no samples are present.
321 pub fn is_empty(&self) -> bool {
322 self.rows.is_empty()
323 }
324
325 /// Number of samples in the series.
326 pub fn len(&self) -> usize {
327 self.rows.len()
328 }
329
330 /// Filter the series to entries whose tag begins with
331 /// `"periodic_"`. Periodic captures are the only entries the
332 /// temporal-assertion patterns are designed for; on-demand
333 /// `Op::CaptureSnapshot` and watchpoint-fire captures share the
334 /// bridge's tag namespace and would otherwise mix into the
335 /// timeline as off-cadence outliers. Consumes `self` because
336 /// the filter rebuilds the owning row vec — when a borrowed
337 /// view is needed instead, see [`Self::periodic_ref`] which
338 /// iterates the same rows without taking ownership.
339 #[must_use = "periodic_only returns a filtered series; bind the result"]
340 pub fn periodic_only(self) -> Self {
341 Self {
342 rows: self
343 .rows
344 .into_iter()
345 .filter(|r| r.tag.starts_with("periodic_"))
346 .collect(),
347 monitor: self.monitor,
348 }
349 }
350
351 /// Borrowed equivalent of [`Self::periodic_only`]: yields a
352 /// borrowed-view iterator over [`Sample`]s whose tag starts
353 /// with `"periodic_"`, without consuming the series. Use when
354 /// a single test asserts on both periodic-only and
355 /// all-captures views from the same series.
356 pub fn periodic_ref(&self) -> impl Iterator<Item = Sample<'_>> {
357 self.iter_samples()
358 .filter(|s| s.tag.starts_with("periodic_"))
359 }
360
361 /// Iterate over [`Sample`] views borrowing into this series.
362 /// Each yielded `Sample<'_>` carries the tag, elapsed-ms,
363 /// borrowed [`Snapshot`], borrowed
364 /// `Result<&Value, &MissingStatsReason>` stats, the per-sample
365 /// phase step index, and the workload-relative boundary offset.
366 pub fn iter_samples(&self) -> impl Iterator<Item = Sample<'_>> {
367 self.rows.iter().map(|r| Sample {
368 tag: r.tag.as_str(),
369 elapsed_ms: r.elapsed_ms,
370 snapshot: Snapshot::new(&r.report),
371 stats: r.stats.as_ref(),
372 step_index: r.step_index,
373 boundary_offset_ms: r.boundary_offset_ms,
374 })
375 }
376
377 /// Group samples by the RAW bridge-stamped scenario phase. The
378 /// returned map is keyed by `step_index` (1-indexed phase encoding
379 /// — `0` is BASELINE, `1..=N` align with scenario Step ordinals);
380 /// each entry is the ordered run of samples that fell in that
381 /// phase, preserving the iteration order produced by
382 /// [`Self::iter_samples`].
383 ///
384 /// Samples that lack a stamped step index (the unstamped
385 /// fixture path via
386 /// [`super::snapshot::SnapshotBridge::capture`] /
387 /// [`super::snapshot::SnapshotBridge::store`] /
388 /// [`super::snapshot::SnapshotBridge::store_with_stats`]) fall
389 /// under key `0` per the "no stamped index" fallback — the same
390 /// bucket BASELINE samples land in. The fixture / BASELINE
391 /// collision is acceptable because both flavours represent
392 /// pre-first-Step (or unstamped) state from the bucketer's
393 /// perspective; production callers that need to distinguish
394 /// can inspect `Sample::step_index` directly.
395 ///
396 /// CAVEAT — prefer [`Self::by_stimulus_phase`] when a stimulus
397 /// timeline is available: the bridge stamp is the step active at
398 /// (deferred) FIRE time, so under the dump-prerequisite gate a
399 /// burst of captures can all stamp the same late `CURRENT_STEP`
400 /// and collapse every sample into one phase. `by_stimulus_phase`
401 /// re-derives the phase from each sample's timing-independent
402 /// `boundary_offset_ms`, which is immune to the burst.
403 ///
404 /// The phase-aware aggregator consumes this map to compute
405 /// per-phase metric reductions (Counter `last - first` delta,
406 /// Gauge / Peak / Timestamp via `crate::stats::aggregate_samples`).
407 pub fn by_stamped_phase(&self) -> std::collections::BTreeMap<u16, Vec<Sample<'_>>> {
408 let mut by_phase: std::collections::BTreeMap<u16, Vec<Sample<'_>>> =
409 std::collections::BTreeMap::new();
410 for sample in self.iter_samples() {
411 let key = sample.step_index.unwrap_or(0);
412 by_phase.entry(key).or_default().push(sample);
413 }
414 by_phase
415 }
416
417 /// Group samples by the guest step whose stimulus window contains
418 /// each sample's workload-relative `boundary_offset_ms`, rather
419 /// than the raw bridge-stamped `step_index`
420 /// ([`Self::by_stamped_phase`]). The returned map uses the same
421 /// 1-indexed phase key (`0` = BASELINE, `1..=N` = Step ordinals)
422 /// and preserves [`Self::iter_samples`] order within each bucket.
423 ///
424 /// This is the correct grouping whenever a stimulus timeline is
425 /// available: `boundary_offset_ms` is derived from the scheduled
426 /// boundary, NOT the (deferred) fire time, so it survives the
427 /// dump-prerequisite-gate burst that makes every periodic capture
428 /// stamp the same late `CURRENT_STEP` (the `phases.len() == 1`
429 /// collapse `by_stamped_phase` is subject to). Samples with no
430 /// `boundary_offset_ms` (on-demand / fixture captures) fall back
431 /// to their stamped `step_index`.
432 ///
433 /// Unlike the folded scalar [`crate::assert::PhaseBucket`]s that
434 /// [`crate::assert::build_phase_buckets_with_stimulus`] returns,
435 /// this keeps the per-sample [`Sample`] views (full Snapshot / dsq
436 /// access) per phase. `build_phase_buckets_with_stimulus` itself
437 /// is built on this method.
438 pub fn by_stimulus_phase(
439 &self,
440 stimulus_events: &[crate::timeline::StimulusEvent],
441 ) -> std::collections::BTreeMap<u16, Vec<Sample<'_>>> {
442 // Step-start timeline in scenario-relative (guest monotonic) ms
443 // — the same frame as `boundary_offset_ms`. See
444 // [`step_starts_from_stimulus`] for the step-START selection.
445 let step_starts = step_starts_from_stimulus(stimulus_events);
446 let mut by_phase: std::collections::BTreeMap<u16, Vec<Sample<'_>>> =
447 std::collections::BTreeMap::new();
448 for sample in self.iter_samples() {
449 let key = match sample.boundary_offset_ms {
450 Some(offset) => remap_offset_to_step(offset, &step_starts),
451 None => sample.step_index.unwrap_or(0),
452 };
453 by_phase.entry(key).or_default().push(sample);
454 }
455 by_phase
456 }
457}
458
459/// Step-start timeline in scenario-relative (guest monotonic) ms — the
460/// frame shared with `boundary_offset_ms`. Only step-START stimulus
461/// events anchor a step window: the terminal scenario-end event
462/// (`step_index` `None`) is dropped by the `filter_map`, and per-step
463/// StepEnd events (`is_step_end`, which carry their step's `step_index`)
464/// are excluded so a step's window is anchored by its start, not its
465/// end-of-hold marker. Returned sorted ascending by `elapsed_ms`.
466///
467/// Shared by [`SampleSeries::by_stimulus_phase`] (which remaps each
468/// capture to the step active at its offset) and
469/// [`crate::assert::build_phase_buckets_with_stimulus`] (which enumerates
470/// the steps that must have a bucket even when they captured no samples)
471/// so the two step-START selections cannot drift.
472pub(crate) fn step_starts_from_stimulus(
473 stimulus_events: &[crate::timeline::StimulusEvent],
474) -> Vec<(u64, u16)> {
475 let mut step_starts: Vec<(u64, u16)> = stimulus_events
476 .iter()
477 .filter(|e| !e.is_step_end)
478 .filter_map(|e| e.step_index.map(|k| (e.elapsed_ms, k)))
479 .collect();
480 step_starts.sort_by_key(|(ms, _)| *ms);
481 step_starts
482}
483
484/// Map a capture's workload-relative boundary offset (ms since scenario
485/// start) to the guest step active at that instant: the `step_index` of
486/// the latest stimulus step-start at or before the offset, or `0`
487/// (BASELINE) when the offset precedes the first step-start.
488/// `step_starts` must be sorted ascending by elapsed_ms.
489///
490/// This is the timing-independent attribution at the heart of the
491/// deferred-fire fix: the scheduled boundary offset is computed from the
492/// boundary schedule (not the fire time), so it survives a burst of
493/// captures that all fire — and would all stamp the same late
494/// CURRENT_STEP — after the dump-prerequisite gate clears.
495fn remap_offset_to_step(offset_ms: u64, step_starts: &[(u64, u16)]) -> u16 {
496 let mut step = 0u16;
497 for (start_ms, k) in step_starts {
498 if *start_ms <= offset_ms {
499 step = *k;
500 } else {
501 break;
502 }
503 }
504 step
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use crate::monitor::btf_render::{RenderedMember, RenderedValue};
511 use crate::monitor::dump::{FailureDumpMap, FailureDumpReport, SCHEMA_SINGLE};
512
513 fn synthetic_report(value: u64) -> FailureDumpReport {
514 let bss_value = RenderedValue::Struct {
515 type_name: Some(".bss".into()),
516 members: vec![
517 RenderedMember {
518 name: "nr_dispatched".into(),
519 value: RenderedValue::Uint { bits: 64, value },
520 },
521 RenderedMember {
522 name: "stall".into(),
523 value: RenderedValue::Uint { bits: 8, value: 0 },
524 },
525 ],
526 };
527 let bss_map = FailureDumpMap {
528 name: "scx_obj.bss".into(),
529 map_kva: 0,
530 map_type: 2,
531 value_size: 16,
532 max_entries: 1,
533 value: Some(bss_value),
534 entries: Vec::new(),
535 array_entries: Vec::new(),
536 percpu_entries: Vec::new(),
537 percpu_hash_entries: Vec::new(),
538 arena: None,
539 ringbuf: None,
540 stack_trace: None,
541 fd_array: None,
542 error: None,
543 };
544 FailureDumpReport {
545 schema: SCHEMA_SINGLE.to_string(),
546 active_map_kvas: Vec::new(),
547 maps: vec![bss_map],
548 ..Default::default()
549 }
550 }
551
552 fn synthetic_stats(busy: f64) -> serde_json::Value {
553 serde_json::json!({
554 "busy": busy,
555 "antistall": 0,
556 "layers": {
557 "batch": { "util": busy * 0.5 }
558 }
559 })
560 }
561
562 #[test]
563 fn from_drained_preserves_order() {
564 let drained = vec![
565 (
566 "periodic_000".to_string(),
567 synthetic_report(10),
568 Some(synthetic_stats(50.0)),
569 Some(100),
570 ),
571 (
572 "periodic_001".to_string(),
573 synthetic_report(20),
574 Some(synthetic_stats(60.0)),
575 Some(200),
576 ),
577 ];
578 let series = SampleSeries::from_drained(drained, None);
579 assert_eq!(series.len(), 2);
580 let tags: Vec<&str> = series.iter_samples().map(|s| s.tag).collect();
581 assert_eq!(tags, vec!["periodic_000", "periodic_001"]);
582 }
583
584 #[test]
585 fn bpf_member_names_union_not_blinded_by_placeholder_first_sample() {
586 // Sample 0 is a placeholder (no maps); sample 1 carries the bss
587 // struct. member_names must discover the struct's fields by unioning
588 // across samples, not return empty because sample 0 lacked the map
589 // (which would silently blind a blanket u64_fields/f64_fields
590 // projection).
591 let drained = vec![
592 (
593 "periodic_000".to_string(),
594 crate::monitor::dump::FailureDumpReport::default(),
595 None,
596 Some(100),
597 ),
598 (
599 "periodic_001".to_string(),
600 synthetic_report(10),
601 None,
602 Some(200),
603 ),
604 ];
605 let series = SampleSeries::from_drained(drained, None);
606 let names = series.bpf_map("scx_obj.bss").member_names();
607 assert!(
608 names.contains(&"nr_dispatched".to_string()),
609 "must discover nr_dispatched from sample 1 despite placeholder sample 0; got {names:?}",
610 );
611 assert!(
612 names.contains(&"stall".to_string()),
613 "must discover stall from sample 1; got {names:?}",
614 );
615 }
616
617 #[test]
618 fn stats_key_names_union_not_blinded_by_errored_first_sample() {
619 // Sample 0 has no stats (Err); sample 1 carries the scx_stats
620 // object. key_names must union across samples so the object's keys
621 // are discoverable, not empty because sample 0's stats was Err.
622 let drained = vec![
623 (
624 "periodic_000".to_string(),
625 synthetic_report(10),
626 None,
627 Some(100),
628 ),
629 (
630 "periodic_001".to_string(),
631 synthetic_report(20),
632 Some(synthetic_stats(60.0)),
633 Some(200),
634 ),
635 ];
636 let series = SampleSeries::from_drained(drained, None);
637 let names = series.stats_path("").key_names();
638 assert!(
639 names.contains(&"busy".to_string()),
640 "must discover the scx_stats keys from sample 1 despite sample 0 having no stats; got {names:?}",
641 );
642 }
643
644 #[test]
645 fn periodic_only_filters_non_periodic_tags() {
646 let drained = vec![
647 (
648 "periodic_000".to_string(),
649 synthetic_report(10),
650 None,
651 Some(100),
652 ),
653 (
654 "user_watchpoint_kind".to_string(),
655 synthetic_report(99),
656 None,
657 Some(150),
658 ),
659 (
660 "periodic_001".to_string(),
661 synthetic_report(20),
662 None,
663 Some(200),
664 ),
665 ];
666 let series = SampleSeries::from_drained(drained, None).periodic_only();
667 assert_eq!(series.len(), 2);
668 }
669}