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}