ktstr/scenario/sample/
host.rs

1//! Per-sample, per-CPU host-side timeline projection.
2//!
3//! The host-capture pipeline (see [`crate::monitor::dump`]) populates
4//! each [`FailureDumpReport::per_cpu_time`](crate::monitor::dump::FailureDumpReport::per_cpu_time)
5//! with a slice of [`PerCpuTimeStats`] taken at sample time. This
6//! module exposes those slices as a borrowed-view timeline
7//! ([`HostView`]) keyed by CPU id, with a closure-based projector
8//! that emits a [`SeriesField<u64>`] compatible with the temporal-
9//! assertion patterns in [`crate::assert::temporal`].
10//!
11//! Orthogonal to [`super::monitor`]: this view is the per-SAMPLE
12//! per-CPU TIMELINE source; the monitor view exposes the per-VM-RUN
13//! cross-CPU AGGREGATE. The two never overlap — they draw from
14//! different fields on the captured reports
15//! (`FailureDumpReport::per_cpu_time` here vs `MonitorReport.summary`
16//! for the monitor view).
17
18use crate::assert::temporal::SeriesField;
19use crate::monitor::dump::PerCpuTimeStats;
20
21use super::{SampleRow, SampleSeries, build_series_field};
22
23/// Borrowed view over the per-sample per-CPU [`PerCpuTimeStats`] data
24/// that the host capture pipeline populates into each
25/// [`FailureDumpReport::per_cpu_time`](crate::monitor::dump::FailureDumpReport::per_cpu_time). Returned by
26/// [`SampleSeries::host`]; exposes a per-CPU timeline (rows sorted
27/// ascending by elapsed-ms, stable on ties) plus a closure-based
28/// projector that emits a [`SeriesField<u64>`] compatible with the
29/// temporal-assertion patterns in [`crate::assert::temporal`].
30///
31/// Orthogonal to [`super::MonitorView`]: this view is the per-sample
32/// per-CPU TIMELINE source; `MonitorView` exposes the per-VM-run
33/// cross-CPU AGGREGATE. The two draw from different fields on the
34/// captured reports (`FailureDumpReport::per_cpu_time` here vs
35/// `MonitorReport.summary` for the monitor view) and never overlap.
36///
37/// Placeholder samples (the freeze rendezvous timed out, the
38/// capture pipeline otherwise failed) carry an empty `per_cpu_time`
39/// slice and naturally drop out of every per-CPU timeline without
40/// an explicit filter — temporal-assertion patterns see the
41/// surrounding non-placeholder samples in order.
42#[derive(Debug, Clone, Copy)]
43#[must_use = "HostView is a borrowed view; call .per_cpu_time_timeline() / .per_cpu_field_u64() / .cpus() to project"]
44#[non_exhaustive]
45pub struct HostView<'a> {
46    rows: &'a [SampleRow],
47}
48
49impl<'a> HostView<'a> {
50    /// Discover every CPU id that appears in at least one sample's
51    /// `per_cpu_time` slice. Returned in ascending order, deduped.
52    /// Useful for "fan-out over every captured CPU" assertion
53    /// loops: `for cpu in host.cpus() { ... }`.
54    pub fn cpus(&self) -> Vec<u32> {
55        let mut seen = std::collections::BTreeSet::new();
56        for row in self.rows {
57            for entry in &row.report.per_cpu_time {
58                seen.insert(entry.cpu);
59            }
60        }
61        seen.into_iter().collect()
62    }
63
64    /// Per-CPU timeline: every sample that captured `cpu`, sorted
65    /// ascending by `elapsed_ms`. Ties retain insertion order
66    /// (stable sort). Samples whose `per_cpu_time` slice didn't
67    /// include `cpu` (placeholder reports, or a kernel without
68    /// per-CPU stats) are absent from the returned timeline rather
69    /// than producing a default-zero row that would silently advance
70    /// counter-style assertions.
71    ///
72    /// Returns an empty Vec when `cpu` was not captured in any
73    /// sample. Test authors that need explicit per-sample
74    /// coverage discrimination iterate via
75    /// [`SampleSeries::iter_samples`] and consult
76    /// [`crate::scenario::snapshot::Snapshot::per_cpu_time_at`] per
77    /// sample.
78    ///
79    /// Inherits the first-match-wins contract for duplicate-cpu
80    /// entries from
81    /// [`crate::scenario::snapshot::Snapshot::per_cpu_time_at`]:
82    /// production walker (`collect_per_cpu_time`) enforces one
83    /// entry per cpu per sample, but the lookup leaves the contract
84    /// first-match for graceful degradation on a malformed report.
85    /// Samples whose `elapsed_ms` is `None` (the bridge recorded no
86    /// timestamp) are EXCLUDED: a timestamp-less sample has
87    /// no position on a time-ordered axis, and placing it at a
88    /// fabricated `0` would corrupt the ascending-by-time contract.
89    pub fn per_cpu_time_timeline(&self, cpu: u32) -> Vec<(u64, &'a PerCpuTimeStats)> {
90        let mut entries: Vec<(u64, &'a PerCpuTimeStats)> = Vec::new();
91        for row in self.rows {
92            let Some(elapsed_ms) = row.elapsed_ms else {
93                continue;
94            };
95            if let Some(stats) = row.report.per_cpu_time.iter().find(|c| c.cpu == cpu) {
96                entries.push((elapsed_ms, stats));
97            }
98        }
99        entries.sort_by_key(|(elapsed_ms, _)| *elapsed_ms);
100        entries
101    }
102
103    /// Project a single u64 field out of each per-sample
104    /// `PerCpuTimeStats` row for `cpu` into a [`SeriesField<u64>`]
105    /// suitable for the temporal-assertion patterns
106    /// (`nondecreasing`, `rate_within`, `steady_within`,
107    /// `converges_to`, etc.) in [`crate::assert::temporal`]. Mirrors
108    /// the shape of [`SampleSeries::bpf`] so identical assertion
109    /// pipelines compose against either axis.
110    ///
111    /// Samples whose `per_cpu_time` slice didn't include `cpu`
112    /// surface as a per-sample
113    /// [`SnapshotError::HostFieldUnavailable`](crate::scenario::snapshot::SnapshotError::HostFieldUnavailable)
114    /// slot — gap-tolerant temporal patterns skip with a rendered
115    /// Note, strict patterns fail the assertion so coverage gaps
116    /// can never silently slip past the call site.
117    pub fn per_cpu_field_u64(
118        &self,
119        cpu: u32,
120        label: impl Into<String>,
121        project: impl Fn(&PerCpuTimeStats) -> u64,
122    ) -> SeriesField<u64> {
123        build_series_field(self.rows, label, |row| {
124            // Placeholder reports surface as the dedicated
125            // PlaceholderSample variant — matching the series.bpf
126            // pattern so temporal-assertion sites route placeholder
127            // samples through their per-sample skip handling rather
128            // than treating them as cpu-coverage gaps. A strict
129            // pattern (always_true / each.at_least) would otherwise
130            // FAIL on placeholders instead of skipping; gap-tolerant
131            // patterns render the right diagnostic Note.
132            if row.report.is_placeholder {
133                return Err(
134                    crate::scenario::snapshot::SnapshotError::PlaceholderSample {
135                        tag: row.tag.clone(),
136                        reason: row
137                            .report
138                            .scx_walker_unavailable
139                            .clone()
140                            .unwrap_or_else(|| "placeholder report".to_string()),
141                    },
142                );
143            }
144            // Inherits the first-match-wins contract from
145            // [`crate::scenario::snapshot::Snapshot::per_cpu_time_at`]:
146            // production walker (`collect_per_cpu_time` at
147            // `crate::monitor::dump`) enforces one entry per cpu,
148            // but the closure leaves the contract first-match for
149            // graceful degradation on a malformed report.
150            match row.report.per_cpu_time.iter().find(|c| c.cpu == cpu) {
151                Some(stats) => Ok(project(stats)),
152                None => Err(
153                    crate::scenario::snapshot::SnapshotError::HostFieldUnavailable {
154                        tag: row.tag.clone(),
155                        cpu,
156                    },
157                ),
158            }
159        })
160    }
161}
162
163impl SampleSeries {
164    /// Borrowed view over the per-sample host-side per-CPU snapshot
165    /// data captured into each [`FailureDumpReport::per_cpu_time`](crate::monitor::dump::FailureDumpReport::per_cpu_time).
166    /// Returns `None` when the series is empty; otherwise yields a
167    /// [`HostView`] that exposes the per-CPU timeline (rows sorted
168    /// by elapsed-ms) and a closure-based projector compatible with
169    /// the temporal-assertion patterns in
170    /// [`crate::assert::temporal`].
171    ///
172    /// Orthogonal to [`Self::monitor`]: `host()` is the per-sample
173    /// per-CPU TIMELINE; `monitor()` is the per-VM-run cross-CPU
174    /// AGGREGATE. Tests that want both perspectives chain them
175    /// independently from the same series.
176    ///
177    /// The returned `HostView<'_>` borrows from this series, so the
178    /// series must outlive any projection chained off the view.
179    pub fn host(&self) -> Option<HostView<'_>> {
180        if self.rows.is_empty() {
181            None
182        } else {
183            Some(HostView { rows: &self.rows })
184        }
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::monitor::dump::FailureDumpReport;
192
193    #[test]
194    fn series_host_empty_series_returns_none() {
195        let series = SampleSeries::from_drained(vec![], None);
196        assert!(series.host().is_none());
197    }
198
199    /// Single-sample series with N captured CPUs:
200    /// `per_cpu_time_timeline(cpu)` returns exactly 1 row for each
201    /// captured cpu, empty Vec for any other cpu. Pins the
202    /// per-CPU filter — placeholder-or-absent CPUs MUST NOT
203    /// surface default-zero rows that would silently advance
204    /// counter-style assertions.
205    #[test]
206    fn series_host_per_cpu_time_timeline_single_sample() {
207        let report = FailureDumpReport {
208            per_cpu_time: vec![
209                PerCpuTimeStats {
210                    cpu: 0,
211                    cpustat_user_ns: 100,
212                    ..Default::default()
213                },
214                PerCpuTimeStats {
215                    cpu: 3,
216                    cpustat_user_ns: 300,
217                    ..Default::default()
218                },
219            ],
220            ..Default::default()
221        };
222        let series = SampleSeries::from_drained(
223            vec![("periodic_000".to_string(), report, None, Some(50u64))],
224            None,
225        );
226        let host = series.host().expect("non-empty series");
227        let t0 = host.per_cpu_time_timeline(0);
228        assert_eq!(t0.len(), 1);
229        assert_eq!(t0[0].0, 50);
230        assert_eq!(t0[0].1.cpustat_user_ns, 100);
231        let t3 = host.per_cpu_time_timeline(3);
232        assert_eq!(t3.len(), 1);
233        assert_eq!(t3[0].1.cpustat_user_ns, 300);
234        let t99 = host.per_cpu_time_timeline(99);
235        assert!(
236            t99.is_empty(),
237            "cpu not captured in any sample MUST yield empty timeline (not default-zero)"
238        );
239        assert_eq!(host.cpus(), vec![0, 3]);
240    }
241
242    /// REGRESSION: a sample whose `elapsed_ms` is `None` (the
243    /// bridge recorded no timestamp) is DROPPED from the time-ordered
244    /// timeline — it has no position on a time axis and must not be
245    /// placed at a fabricated `0` (which would sort first and corrupt the
246    /// ascending contract).
247    #[test]
248    fn series_host_per_cpu_time_timeline_drops_none_elapsed() {
249        let mk = |user_ns: u64| FailureDumpReport {
250            per_cpu_time: vec![PerCpuTimeStats {
251                cpu: 0,
252                cpustat_user_ns: user_ns,
253                ..Default::default()
254            }],
255            ..Default::default()
256        };
257        let series = SampleSeries::from_drained(
258            vec![
259                ("a".to_string(), mk(10), None, Some(100u64)),
260                // No recorded timestamp: must be dropped from the timeline.
261                ("b".to_string(), mk(20), None, None),
262                ("c".to_string(), mk(30), None, Some(300u64)),
263            ],
264            None,
265        );
266        let host = series.host().expect("non-empty series");
267        let t0 = host.per_cpu_time_timeline(0);
268        assert_eq!(
269            t0.len(),
270            2,
271            "the None-elapsed row must be dropped, not placed at a fabricated 0",
272        );
273        assert_eq!(t0[0].0, 100);
274        assert_eq!(t0[0].1.cpustat_user_ns, 10);
275        assert_eq!(t0[1].0, 300);
276        assert_eq!(t0[1].1.cpustat_user_ns, 30);
277    }
278
279    /// Multi-sample series with NON-monotonic elapsed_ms:
280    /// `per_cpu_time_timeline` returns rows sorted ascending by
281    /// elapsed_ms; ties retain insertion order (stable sort).
282    /// Pins the sort contract against drift to unstable sort or
283    /// reverse order.
284    #[test]
285    fn series_host_per_cpu_time_timeline_sorts_by_elapsed_ms_stable() {
286        let mk = |val: u64| FailureDumpReport {
287            per_cpu_time: vec![PerCpuTimeStats {
288                cpu: 0,
289                cpustat_user_ns: val,
290                ..Default::default()
291            }],
292            ..Default::default()
293        };
294        let series = SampleSeries::from_drained(
295            vec![
296                ("a".to_string(), mk(100), None, Some(100u64)),
297                ("b".to_string(), mk(200), None, Some(50u64)),
298                ("c".to_string(), mk(300), None, Some(100u64)),
299                ("d".to_string(), mk(400), None, Some(25u64)),
300            ],
301            None,
302        );
303        let host = series.host().expect("non-empty");
304        let timeline = host.per_cpu_time_timeline(0);
305        assert_eq!(timeline.len(), 4);
306        assert_eq!(timeline[0].0, 25);
307        assert_eq!(timeline[0].1.cpustat_user_ns, 400);
308        assert_eq!(timeline[1].0, 50);
309        assert_eq!(timeline[1].1.cpustat_user_ns, 200);
310        assert_eq!(
311            timeline[2].0, 100,
312            "first of the tied-elapsed-ms pair: insertion order = 'a'"
313        );
314        assert_eq!(timeline[2].1.cpustat_user_ns, 100);
315        assert_eq!(
316            timeline[3].0, 100,
317            "second of the tied-elapsed-ms pair: insertion order = 'c'"
318        );
319        assert_eq!(timeline[3].1.cpustat_user_ns, 300);
320    }
321
322    /// Placeholder samples (empty per_cpu_time) naturally drop
323    /// from the timeline without an explicit filter. Pins the
324    /// "no explicit placeholder-skip needed" contract: a
325    /// placeholder mid-stream MUST NOT inject a default-zero
326    /// row that would silently advance counter-style assertions.
327    #[test]
328    fn series_host_placeholder_naturally_drops_without_explicit_filter() {
329        let mk_real = |val: u64| FailureDumpReport {
330            per_cpu_time: vec![PerCpuTimeStats {
331                cpu: 0,
332                cpustat_user_ns: val,
333                ..Default::default()
334            }],
335            ..Default::default()
336        };
337        let placeholder = FailureDumpReport::placeholder("freeze rendezvous timed out");
338        let series = SampleSeries::from_drained(
339            vec![
340                ("real_pre".to_string(), mk_real(10), None, Some(10u64)),
341                (
342                    "placeholder_mid".to_string(),
343                    placeholder,
344                    None,
345                    Some(20u64),
346                ),
347                ("real_post".to_string(), mk_real(30), None, Some(30u64)),
348            ],
349            None,
350        );
351        let host = series.host().expect("non-empty");
352        let timeline = host.per_cpu_time_timeline(0);
353        assert_eq!(
354            timeline.len(),
355            2,
356            "placeholder MUST drop from the timeline naturally — pins the no-explicit-filter contract"
357        );
358        assert_eq!(timeline[0].0, 10);
359        assert_eq!(timeline[1].0, 30);
360    }
361
362    /// Closure-based `per_cpu_field_u64` projector emits a
363    /// [`SeriesField<u64>`] with one slot per sample. Samples
364    /// where `cpu` was captured produce `Ok(value)`; samples where
365    /// `cpu` was absent surface as
366    /// [`SnapshotError::HostFieldUnavailable`] (NOT silently
367    /// dropped, NOT default-zero) so coverage gaps reach the
368    /// temporal-assertion layer.
369    #[test]
370    fn series_host_per_cpu_field_u64_closure_projection() {
371        let mk = |val: u64| FailureDumpReport {
372            per_cpu_time: vec![PerCpuTimeStats {
373                cpu: 1,
374                cpustat_system_ns: val,
375                ..Default::default()
376            }],
377            ..Default::default()
378        };
379        let mk_missing = || FailureDumpReport {
380            per_cpu_time: vec![PerCpuTimeStats {
381                cpu: 0,
382                cpustat_system_ns: 999,
383                ..Default::default()
384            }],
385            ..Default::default()
386        };
387        let series = SampleSeries::from_drained(
388            vec![
389                ("a".to_string(), mk(100), None, Some(10u64)),
390                ("b".to_string(), mk_missing(), None, Some(20u64)),
391                ("c".to_string(), mk(300), None, Some(30u64)),
392            ],
393            None,
394        );
395        let host = series.host().expect("non-empty");
396        let field = host.per_cpu_field_u64(1, "system_ns_cpu1", |stats| stats.cpustat_system_ns);
397        let slots: Vec<_> = field.values_iter().collect();
398        assert_eq!(slots.len(), 3);
399        assert_eq!(*slots[0].as_ref().expect("cpu 1 captured in sample a"), 100);
400        match slots[1] {
401            Err(crate::scenario::snapshot::SnapshotError::HostFieldUnavailable { tag, cpu }) => {
402                assert_eq!(tag, "b");
403                assert_eq!(*cpu, 1);
404            }
405            other => panic!(
406                "cpu 1 absent in sample b MUST surface as HostFieldUnavailable, got {other:?}"
407            ),
408        }
409        assert_eq!(*slots[2].as_ref().expect("cpu 1 captured in sample c"), 300);
410    }
411
412    /// `per_cpu_field_u64` on a PLACEHOLDER sample surfaces
413    /// [`SnapshotError::PlaceholderSample`] — NOT
414    /// `HostFieldUnavailable`. Mirrors the [`SampleSeries::bpf`]
415    /// placeholder-gate pattern so temporal-assertion sites route
416    /// placeholders through their per-sample skip handling.
417    #[test]
418    fn series_host_per_cpu_field_u64_placeholder_surfaces_placeholder_sample_variant() {
419        let mk = |val: u64| FailureDumpReport {
420            per_cpu_time: vec![PerCpuTimeStats {
421                cpu: 0,
422                cpustat_user_ns: val,
423                ..Default::default()
424            }],
425            ..Default::default()
426        };
427        let placeholder = FailureDumpReport::placeholder("freeze rendezvous timed out");
428        let series = SampleSeries::from_drained(
429            vec![
430                ("real".to_string(), mk(100), None, Some(10u64)),
431                ("placeholder".to_string(), placeholder, None, Some(20u64)),
432            ],
433            None,
434        );
435        let host = series.host().expect("non-empty");
436        let field = host.per_cpu_field_u64(0, "user_ns_cpu0", |s| s.cpustat_user_ns);
437        let slots: Vec<_> = field.values_iter().collect();
438        assert_eq!(slots.len(), 2);
439        assert_eq!(*slots[0].as_ref().expect("real sample Ok"), 100);
440        match slots[1] {
441            Err(crate::scenario::snapshot::SnapshotError::PlaceholderSample { tag, .. }) => {
442                assert_eq!(tag, "placeholder");
443            }
444            other => panic!(
445                "placeholder sample MUST surface as PlaceholderSample (not HostFieldUnavailable), got {other:?}"
446            ),
447        }
448    }
449
450    /// `cpus()` returns an empty Vec on a series where every
451    /// sample is a placeholder (rows non-empty, every per_cpu_time
452    /// is empty). Pins the all-placeholder edge case at unit-test
453    /// granularity.
454    #[test]
455    fn series_host_cpus_empty_when_all_samples_are_placeholders() {
456        let series = SampleSeries::from_drained(
457            vec![
458                (
459                    "p0".to_string(),
460                    FailureDumpReport::placeholder("t1"),
461                    None,
462                    Some(10u64),
463                ),
464                (
465                    "p1".to_string(),
466                    FailureDumpReport::placeholder("t2"),
467                    None,
468                    Some(20u64),
469                ),
470                (
471                    "p2".to_string(),
472                    FailureDumpReport::placeholder("t3"),
473                    None,
474                    Some(30u64),
475                ),
476            ],
477            None,
478        );
479        let host = series.host().expect("rows non-empty");
480        assert!(
481            host.cpus().is_empty(),
482            "all-placeholder series MUST surface cpus() as empty (no per_cpu_time data anywhere)"
483        );
484    }
485
486    /// Multi-sample × multi-CPU with VARIABLE per-sample coverage
487    /// (sample A: cpus 0,1; sample B: cpus 1,2; sample C: cpus 0,2).
488    /// Pins the BTreeSet-dedup union from `cpus()` AND per-CPU
489    /// filtering in `per_cpu_time_timeline` AND mixed Ok/Err
490    /// pattern in `per_cpu_field_u64` simultaneously.
491    #[test]
492    fn series_host_interleaved_multi_cpu_multi_sample_coverage() {
493        let mk = |cpus: &[(u32, u64)]| FailureDumpReport {
494            per_cpu_time: cpus
495                .iter()
496                .map(|(c, v)| PerCpuTimeStats {
497                    cpu: *c,
498                    cpustat_user_ns: *v,
499                    ..Default::default()
500                })
501                .collect(),
502            ..Default::default()
503        };
504        let series = SampleSeries::from_drained(
505            vec![
506                ("A".to_string(), mk(&[(0, 10), (1, 100)]), None, Some(10u64)),
507                (
508                    "B".to_string(),
509                    mk(&[(1, 200), (2, 300)]),
510                    None,
511                    Some(20u64),
512                ),
513                ("C".to_string(), mk(&[(0, 50), (2, 600)]), None, Some(30u64)),
514            ],
515            None,
516        );
517        let host = series.host().expect("non-empty");
518        // cpus() union: {0, 1, 2} sorted
519        assert_eq!(host.cpus(), vec![0, 1, 2]);
520        // per_cpu_time_timeline(0): rows from A + C (B has no cpu 0)
521        let t0 = host.per_cpu_time_timeline(0);
522        assert_eq!(t0.len(), 2);
523        assert_eq!(t0[0].0, 10);
524        assert_eq!(t0[0].1.cpustat_user_ns, 10);
525        assert_eq!(t0[1].0, 30);
526        assert_eq!(t0[1].1.cpustat_user_ns, 50);
527        // per_cpu_time_timeline(1): rows from A + B (C has no cpu 1)
528        let t1 = host.per_cpu_time_timeline(1);
529        assert_eq!(t1.len(), 2);
530        assert_eq!(t1[0].1.cpustat_user_ns, 100);
531        assert_eq!(t1[1].1.cpustat_user_ns, 200);
532        // per_cpu_time_timeline(2): rows from B + C (A has no cpu 2)
533        let t2 = host.per_cpu_time_timeline(2);
534        assert_eq!(t2.len(), 2);
535        assert_eq!(t2[0].1.cpustat_user_ns, 300);
536        assert_eq!(t2[1].1.cpustat_user_ns, 600);
537        // per_cpu_field_u64(1): A=Ok(100), B=Ok(200), C=Err(HostFieldUnavailable cpu=1)
538        let field1 = host.per_cpu_field_u64(1, "cpu1_user", |s| s.cpustat_user_ns);
539        let slots: Vec<_> = field1.values_iter().collect();
540        assert_eq!(slots.len(), 3);
541        assert_eq!(*slots[0].as_ref().unwrap(), 100);
542        assert_eq!(*slots[1].as_ref().unwrap(), 200);
543        match slots[2] {
544            Err(crate::scenario::snapshot::SnapshotError::HostFieldUnavailable { tag, cpu }) => {
545                assert_eq!(tag, "C");
546                assert_eq!(*cpu, 1);
547            }
548            other => panic!("expected HostFieldUnavailable for C/cpu=1, got {other:?}"),
549        }
550    }
551
552    /// `cpus()` is sorted ascending (BTreeSet semantic) regardless
553    /// of per_cpu_time insertion order. Pins against a regression
554    /// that switched BTreeSet → HashSet → Vec without an explicit
555    /// sort step.
556    #[test]
557    fn series_host_cpus_sorted_ascending_independent_of_insertion_order() {
558        let report = FailureDumpReport {
559            per_cpu_time: vec![
560                PerCpuTimeStats {
561                    cpu: 5,
562                    ..Default::default()
563                },
564                PerCpuTimeStats {
565                    cpu: 1,
566                    ..Default::default()
567                },
568                PerCpuTimeStats {
569                    cpu: 3,
570                    ..Default::default()
571                },
572            ],
573            ..Default::default()
574        };
575        let series =
576            SampleSeries::from_drained(vec![("s".to_string(), report, None, Some(0u64))], None);
577        let host = series.host().expect("non-empty");
578        assert_eq!(
579            host.cpus(),
580            vec![1, 3, 5],
581            "cpus() MUST return ascending-sorted distinct CPU ids regardless of per_cpu_time insertion order"
582        );
583    }
584
585    /// Duplicate-cpu first-match-wins contract. The production
586    /// walker (`collect_per_cpu_time`) enforces one entry per cpu
587    /// per sample, but `HostView::per_cpu_time_timeline` and
588    /// `HostView::per_cpu_field_u64` both use `iter().find(|c|
589    /// c.cpu == cpu)` which returns the FIRST match — silently
590    /// dropping subsequent entries for the same cpu. Pins the
591    /// first-match-wins contract so a regression to `last_match`,
592    /// panic-on-dup, or any other handling surfaces here.
593    #[test]
594    fn series_host_per_cpu_time_timeline_first_match_wins_on_duplicate_cpu() {
595        let report = FailureDumpReport {
596            per_cpu_time: vec![
597                PerCpuTimeStats {
598                    cpu: 0,
599                    cpustat_user_ns: 100,
600                    ..Default::default()
601                },
602                PerCpuTimeStats {
603                    cpu: 0,
604                    cpustat_user_ns: 200,
605                    ..Default::default()
606                },
607            ],
608            ..Default::default()
609        };
610        let series =
611            SampleSeries::from_drained(vec![("s".to_string(), report, None, Some(0u64))], None);
612        let host = series.host().expect("non-empty");
613        let timeline = host.per_cpu_time_timeline(0);
614        assert_eq!(timeline.len(), 1, "first-match-wins: timeline pushes once");
615        assert_eq!(
616            timeline[0].1.cpustat_user_ns, 100,
617            "first-match-wins: timeline returns FIRST entry (100), not second (200)"
618        );
619        let field = host.per_cpu_field_u64(0, "user_ns", |s| s.cpustat_user_ns);
620        let slots: Vec<_> = field.values_iter().collect();
621        assert_eq!(slots.len(), 1);
622        assert_eq!(
623            *slots[0].as_ref().expect("Ok(first match value)"),
624            100,
625            "first-match-wins: per_cpu_field_u64 also returns FIRST entry"
626        );
627    }
628
629    /// elapsed_ms plumbing through `per_cpu_field_u64` is verified
630    /// via `iter_full()` — pins both the elapsed_ms VALUE per slot
631    /// AND the tag string per slot against value-corruption
632    /// regressions (e.g. `elapsed.push(0)` instead of
633    /// `elapsed.push(row.elapsed_ms)`, or tag/elapsed vec swap).
634    #[test]
635    fn series_host_per_cpu_field_u64_iter_full_threads_tag_and_elapsed_correctly() {
636        let mk = |val: u64| FailureDumpReport {
637            per_cpu_time: vec![PerCpuTimeStats {
638                cpu: 0,
639                cpustat_user_ns: val,
640                ..Default::default()
641            }],
642            ..Default::default()
643        };
644        let series = SampleSeries::from_drained(
645            vec![
646                ("alpha".to_string(), mk(10), None, Some(100u64)),
647                ("beta".to_string(), mk(20), None, Some(200u64)),
648                ("gamma".to_string(), mk(30), None, Some(300u64)),
649            ],
650            None,
651        );
652        let host = series.host().expect("non-empty");
653        let field = host.per_cpu_field_u64(0, "user_ns", |s| s.cpustat_user_ns);
654        let full: Vec<_> = field.iter_full().collect();
655        assert_eq!(full.len(), 3);
656        assert_eq!(full[0].0, "alpha");
657        assert_eq!(full[0].1, Some(100));
658        assert_eq!(*full[0].2.as_ref().unwrap(), 10);
659        assert_eq!(full[1].0, "beta");
660        assert_eq!(full[1].1, Some(200));
661        assert_eq!(*full[1].2.as_ref().unwrap(), 20);
662        assert_eq!(full[2].0, "gamma");
663        assert_eq!(full[2].1, Some(300));
664        assert_eq!(*full[2].2.as_ref().unwrap(), 30);
665    }
666
667    /// Non-placeholder sample with EMPTY per_cpu_time (real capture
668    /// succeeded, BPF axis populated, but CpuTimeCapture didn't
669    /// run or returned an empty Vec) MUST surface as
670    /// `HostFieldUnavailable`, NOT `PlaceholderSample`. Pins the
671    /// `is_placeholder` gate predicate against drift to
672    /// `per_cpu_time.is_empty() || is_placeholder` (which would
673    /// mis-classify "real but no data" as a placeholder).
674    #[test]
675    fn series_host_per_cpu_field_u64_non_placeholder_empty_per_cpu_time_surfaces_host_field_unavailable()
676     {
677        // FailureDumpReport::default() has is_placeholder=false +
678        // empty per_cpu_time.
679        let report = FailureDumpReport::default();
680        let series = SampleSeries::from_drained(
681            vec![("real_no_cpu_data".to_string(), report, None, Some(10u64))],
682            None,
683        );
684        let host = series.host().expect("non-empty");
685        let field = host.per_cpu_field_u64(0, "user_ns", |s| s.cpustat_user_ns);
686        let slots: Vec<_> = field.values_iter().collect();
687        assert_eq!(slots.len(), 1);
688        match slots[0] {
689            Err(crate::scenario::snapshot::SnapshotError::HostFieldUnavailable { tag, cpu }) => {
690                assert_eq!(tag, "real_no_cpu_data");
691                assert_eq!(*cpu, 0);
692            }
693            Err(crate::scenario::snapshot::SnapshotError::PlaceholderSample { .. }) => {
694                panic!(
695                    "non-placeholder sample with empty per_cpu_time MUST surface as HostFieldUnavailable, NOT PlaceholderSample (regression: empty-per_cpu_time gating as placeholder)"
696                )
697            }
698            other => panic!("expected HostFieldUnavailable, got {other:?}"),
699        }
700    }
701}