ktstr/assert/
types.rs

1use super::*;
2
3thread_local! {
4    /// Thread-local active phase label. Set by the [`PhaseGuard`]
5    /// scope helper at scenario-driver `run_step` entry and read by
6    /// [`AssertDetail::new`] / [`PassDetail::binary`] /
7    /// [`PassDetail::unary`] / [`InfoNote::new`] producers so every
8    /// detail constructed under a guarded scope auto-stamps its
9    /// `phase` field with the active label without the producer
10    /// having to thread context through every `with_phase` chain.
11    /// `None` outside any guarded scope (boot, BASELINE settle,
12    /// non-scenario test fixtures).
13    static ACTIVE_PHASE: RefCell<Option<std::borrow::Cow<'static, str>>> =
14        const { RefCell::new(None) };
15}
16
17/// Snapshot the active phase label installed by the most recent
18/// [`PhaseGuard::install`] on this thread. `None` outside any
19/// guarded scope. Construction sites for [`AssertDetail`] /
20/// [`PassDetail`] / [`InfoNote`] call this to auto-stamp the
21/// `phase` field; the test author can still override via the
22/// builder `with_phase(...)` chain when an explicit value is
23/// preferred.
24pub fn current_phase_label() -> Option<std::borrow::Cow<'static, str>> {
25    ACTIVE_PHASE.with(|p| p.borrow().clone())
26}
27
28/// RAII scope guard for the `ACTIVE_PHASE` thread-local. Install
29/// at scenario-driver `run_step` entry; the guard's `Drop` restores
30/// the prior phase label, supporting cleanly-nested scenario
31/// dispatch (sub-scenarios layer over a parent's phase context
32/// without leaking).
33///
34/// ```ignore
35/// let _guard = PhaseGuard::install_step(0); // Step[0] → "Step[0]"
36/// // ... apply_ops + hold, every assert constructed here stamps
37/// //     phase = Some("Step[0]") automatically ...
38/// // drop on scope exit restores the prior label (BASELINE outside
39/// // any nested Step).
40/// ```
41#[must_use = "PhaseGuard restores the prior phase on Drop — bind it to a local"]
42pub struct PhaseGuard {
43    /// The phase label that was active before this guard installed.
44    /// Restored on Drop so nested guards stack cleanly.
45    previous: Option<std::borrow::Cow<'static, str>>,
46}
47
48impl PhaseGuard {
49    /// Install `label` as the active phase. Captures the
50    /// previously-active label for restoration on Drop. Use
51    /// [`Self::install_step`] / [`Self::install_baseline`] for the
52    /// scenario-driver call sites — they produce the standard
53    /// `"Step[k]"` / `"BASELINE"` labels matching the rest of the
54    /// pipeline.
55    pub fn install(label: impl Into<std::borrow::Cow<'static, str>>) -> Self {
56        let previous = ACTIVE_PHASE.with(|p| p.replace(Some(label.into())));
57        Self { previous }
58    }
59
60    /// Convenience: install the `"Step[k]"` label for the
61    /// `zero_indexed`-th scenario Step. Matches the label
62    /// [`PhaseBucket`] embeds + the [`Phase::step`] display
63    /// (`Step[0]`, `Step[1]`, ...).
64    pub fn install_step(zero_indexed: u16) -> Self {
65        Self::install(format!("Step[{}]", zero_indexed))
66    }
67
68    /// Convenience: install the `"BASELINE"` label for the
69    /// pre-first-Step settle window. Matches the label
70    /// [`PhaseBucket`] uses for `step_index = 0`.
71    pub fn install_baseline() -> Self {
72        Self::install(std::borrow::Cow::Borrowed("BASELINE"))
73    }
74}
75
76impl Drop for PhaseGuard {
77    fn drop(&mut self) {
78        ACTIVE_PHASE.with(|p| {
79            *p.borrow_mut() = self.previous.take();
80        });
81    }
82}
83
84/// Per-VMA entry parsed from `/proc/self/numa_maps`.
85#[derive(Debug, Clone, Default)]
86pub struct NumaMapsEntry {
87    /// Virtual address of the VMA.
88    pub addr: u64,
89    /// Per-node page counts (node_id -> page_count).
90    pub node_pages: BTreeMap<usize, u64>,
91}
92
93/// Parse `/proc/self/numa_maps` content into per-VMA entries.
94///
95/// Each line has the format:
96///   `<hex_addr> <policy> [key=val ...]`
97/// where per-node page counts appear as `N<node>=<count>`.
98pub fn parse_numa_maps(content: &str) -> Vec<NumaMapsEntry> {
99    let mut entries = Vec::new();
100    for line in content.lines() {
101        let line = line.trim();
102        if line.is_empty() {
103            continue;
104        }
105        let mut parts = line.split_whitespace();
106        let addr = match parts.next().and_then(|s| u64::from_str_radix(s, 16).ok()) {
107            Some(a) => a,
108            None => continue,
109        };
110        // Skip policy field.
111        let _ = parts.next();
112
113        let mut entry = NumaMapsEntry {
114            addr,
115            ..Default::default()
116        };
117
118        for token in parts {
119            if let Some(rest) = token.strip_prefix('N')
120                && let Some((node_str, count_str)) = rest.split_once('=')
121                && let (Ok(node), Ok(count)) = (node_str.parse::<usize>(), count_str.parse::<u64>())
122            {
123                *entry.node_pages.entry(node).or_insert(0) += count;
124            }
125        }
126
127        if !entry.node_pages.is_empty() {
128            entries.push(entry);
129        }
130    }
131    entries
132}
133
134/// Compute page locality fraction from parsed numa_maps entries.
135///
136/// Returns the fraction of pages residing on any node in
137/// `expected_nodes` (0.0-1.0). Returns 0.0 when no pages are observed
138/// — a zero-allocation workload is not vacuously local; reporting 1.0
139/// would let `min_page_locality` thresholds silently pass on broken
140/// runs that produced no NUMA signal. The expected node set is the
141/// cgroup's cpuset NUMA-node set (the nodes whose CPUs the worker is
142/// confined to), supplied by the caller — see `assert_cgroup` in
143/// `crate::assert::plan` / `crate::assert::reductions`, not the
144/// worker's [`MemPolicy`](crate::workload::MemPolicy).
145pub fn page_locality(entries: &[NumaMapsEntry], expected_nodes: &BTreeSet<usize>) -> f64 {
146    let mut total: u64 = 0;
147    let mut local: u64 = 0;
148    for entry in entries {
149        for (&node, &count) in &entry.node_pages {
150            total += count;
151            if expected_nodes.contains(&node) {
152                local += count;
153            }
154        }
155    }
156    if total > 0 {
157        local as f64 / total as f64
158    } else {
159        0.0
160    }
161}
162
163/// Extract `numa_pages_migrated` from `/proc/vmstat` content.
164///
165/// Returns `None` if the counter is not present. The counter is
166/// cumulative; callers diff pre- and post-workload snapshots to
167/// get migration count during the test.
168pub fn parse_vmstat_numa_pages_migrated(content: &str) -> Option<u64> {
169    for line in content.lines() {
170        let line = line.trim();
171        if let Some(rest) = line.strip_prefix("numa_pages_migrated") {
172            let rest = rest.trim();
173            if let Ok(v) = rest.parse::<u64>() {
174                return Some(v);
175            }
176        }
177    }
178    None
179}
180
181pub(crate) fn gap_threshold_ms() -> u64 {
182    // Unoptimized debug builds have higher scheduling overhead.
183    if cfg!(debug_assertions) { 3000 } else { 2000 }
184}
185
186pub(crate) fn spread_threshold_pct() -> f64 {
187    // Debug builds in small VMs (especially under EEVDF) show higher
188    // spread than optimized builds under sched_ext schedulers.
189    if cfg!(debug_assertions) { 35.0 } else { 15.0 }
190}