ktstr/assert/
builder.rs

1use super::*;
2
3/// Unified assertion configuration. Carries both worker checks and
4/// monitor thresholds as a single composable type. Each `Option` field
5/// acts as an override — `None` means "inherit from parent layer".
6///
7/// Construct via [`Assert::NO_OVERRIDES`] (preferred const baseline)
8/// or [`Assert::default_checks`] (currently aliases NO_OVERRIDES);
9/// chain builder methods on either base (all builders are `const fn`
10/// except [`Assert::expect_scx_bpf_error_matches`], which compiles a
11/// regex at construction). Use the resulting `Assert` value as the
12/// `assert` field of a [`Scheduler`](crate::test_support::Scheduler)
13/// declared via [`declare_scheduler!`](crate::declare_scheduler) — the
14/// macro accepts `assert = Assert::NO_OVERRIDES.foo()`-style chains
15/// at the scheduler level. The `#[ktstr_test]` proc macro does NOT
16/// accept an `assert = …` attribute on test entries; per-field
17/// attribute shortcuts (`max_gap_ms = N`, `not_starved = true`, …)
18/// compose into the equivalent struct literal at expansion time.
19///
20/// Merge order: `Assert::default_checks()` -> `Scheduler.assert` -> per-test `assert`.
21/// `default_checks()` is `NO_OVERRIDES` — all assertions are opt-in.
22///
23/// ```
24/// # use ktstr::assert::Assert;
25/// // Scheduler opts into imbalance checking.
26/// let sched_assert = Assert::NO_OVERRIDES.max_imbalance_ratio(5.0);
27///
28/// // Merge: defaults <- scheduler <- test.
29/// let merged = Assert::default_checks()
30///     .merge(&sched_assert)
31///     .merge(&Assert::NO_OVERRIDES.max_gap_ms(5000));
32///
33/// assert_eq!(merged.not_starved, None);              // not opted in
34/// assert_eq!(merged.max_imbalance_ratio, Some(5.0)); // from sched
35/// assert_eq!(merged.max_gap_ms, Some(5000));         // from test
36/// ```
37///
38/// # Serde roundtrip — covers the 20 threshold/check fields only
39///
40/// The serde derive at the struct level covers the 20 threshold +
41/// flag fields (every `Option<bool/u64/f64/u32/usize>` plus the bare
42/// `enforce_monitor_thresholds: bool`). The two reproducer-matcher
43/// fields ([`Self::expect_scx_bpf_error_contains`] and
44/// [`Self::expect_scx_bpf_error_matches`]) carry `#[serde(skip)]`
45/// because their `&'static str` shape cannot round-trip through a
46/// borrowed deserializer — see each field's doc for the rationale.
47/// Sidecar consumers comparing
48/// threshold config across runs treat reproducer matcher strings as
49/// part of the test identity (encoded by name in the sidecar key),
50/// not part of the threshold payload, so the skip is operationally
51/// transparent today.
52#[must_use = "builder methods return a new Assert; discard means config is lost"]
53#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
54pub struct Assert {
55    // Worker checks
56    /// Enable starvation, fairness spread, and gap checks across
57    /// worker reports. `Some(true)` enables, `Some(false)` explicitly
58    /// disables (overriding any enabling merge from a lower layer),
59    /// `None` inherits from the merge parent.
60    pub not_starved: Option<bool>,
61    /// Enable per-worker CPU isolation checks (ensure workers remain
62    /// within their assigned cpuset). Same tri-state semantics as
63    /// `not_starved`.
64    pub isolation: Option<bool>,
65    /// Max per-worker scheduling gap in milliseconds. Fails the
66    /// assertion if any worker's longest off-CPU stretch exceeds this.
67    pub max_gap_ms: Option<u64>,
68    /// Max per-cgroup fairness spread as a percentage. Fails if the
69    /// range between the most- and least-served workers exceeds this
70    /// fraction of their mean.
71    pub max_spread_pct: Option<f64>,
72
73    // Throughput checks
74    /// Max coefficient of variation for work_units/cpu_time across workers.
75    /// Catches placement unfairness where some workers get less CPU than others.
76    pub max_throughput_cv: Option<f64>,
77    /// Minimum work_units per CPU-second. Catches cases where all workers
78    /// are equally slow (CV passes but absolute throughput is too low).
79    pub min_work_rate: Option<f64>,
80
81    // Benchmarking checks
82    /// Max p99 wake latency in NANOSECONDS. Fails if the pooled
83    /// p99 across every worker's `wake_latencies_ns` exceeds this.
84    ///
85    /// # Unit-name gotcha
86    ///
87    /// The threshold is `_ns`, but the paired reporting field on
88    /// [`CgroupStats::p99_wake_latency_us`] and the re-pooled run-level
89    /// `worst_p99_wake_latency_us` ext metric (a `MetricKind::Distribution`)
90    /// are MICROSECONDS. The two surfaces are intentionally split:
91    ///   - the threshold uses NS for precision (typical scheduler
92    ///     wake latencies are single-digit µs, so sub-µs resolution
93    ///     matters for regression gates);
94    ///   - the reporting fields use US for readability in
95    ///     `perf-delta` / dashboard output.
96    ///
97    /// Both are computed from the same underlying
98    /// [`WorkerReport::wake_latencies_ns`] samples — see
99    /// [`assert_benchmarks`] for the threshold path and
100    /// [`assert_not_starved`] for the reporting path. A bare
101    /// comparison of `max_p99_wake_latency_ns` against
102    /// `CgroupStats::p99_wake_latency_us` is a unit-mismatch bug;
103    /// `assert_benchmarks` never does this — it consumes the raw
104    /// `wake_latencies_ns` directly — and
105    /// `assert_p99_ns_threshold_compares_against_ns_latencies` pins
106    /// that contract.
107    pub max_p99_wake_latency_ns: Option<u64>,
108    /// Max wake latency coefficient of variation. Fails if CV exceeds this.
109    pub max_wake_latency_cv: Option<f64>,
110    /// Minimum iterations per wall-clock second. Fails if any worker is below.
111    pub min_iteration_rate: Option<f64>,
112    /// Max migration ratio (migrations/iterations). Fails if any cgroup exceeds this.
113    pub max_migration_ratio: Option<f64>,
114
115    // Monitor checks
116    /// Max `nr_running` / LLC imbalance ratio observed by the monitor.
117    /// Fails if the worst sample's imbalance exceeds this.
118    pub max_imbalance_ratio: Option<f64>,
119    /// Max local DSQ depth observed by the monitor. Fails if any
120    /// sampled CPU's local DSQ grew beyond this.
121    pub max_local_dsq_depth: Option<u32>,
122    /// Treat a stall verdict from the monitor as a hard failure. Same
123    /// tri-state semantics as `not_starved`.
124    pub fail_on_stall: Option<bool>,
125    /// Minimum number of consecutive samples that must exceed the
126    /// monitor threshold before a verdict is raised. Smooths out
127    /// single-sample spikes.
128    pub sustained_samples: Option<usize>,
129    /// Max `select_cpu_fallback` rate (events/sec). Fails if the
130    /// scx event counter delta over the run exceeds this rate.
131    pub max_fallback_rate: Option<f64>,
132    /// Max `keep_last` rate (events/sec). Fails if the scx event
133    /// counter delta over the run exceeds this rate.
134    pub max_keep_last_rate: Option<f64>,
135    /// Promote monitor threshold violations from report-only to
136    /// pass/fail. When `false` (the default), the monitor still walks
137    /// every sample and records every violation in the verdict's
138    /// `details`, but the verdict's `passed` stays `true`. Tests that
139    /// want monitor violations to fail the run call
140    /// [`Self::with_monitor_defaults`], which populates each monitor
141    /// threshold from `MonitorThresholds::new()`
142    /// and sets this flag to `true`.
143    pub enforce_monitor_thresholds: bool,
144
145    // NUMA checks
146    /// Minimum fraction of pages on the expected NUMA node(s) (0.0-1.0).
147    /// Expected nodes are derived from the worker's
148    /// [`MemPolicy`](crate::workload::MemPolicy) at evaluation time.
149    /// Fails if the observed locality fraction falls below this.
150    pub min_page_locality: Option<f64>,
151    /// Maximum ratio of NUMA-node-migrated pages to total allocated
152    /// pages (0.0-1.0). Distinct from [`max_migration_ratio`](Self::max_migration_ratio)
153    /// which measures CPU migrations per iteration. Fails if the
154    /// observed migration ratio exceeds this.
155    pub max_cross_node_migration_ratio: Option<f64>,
156    /// Maximum fraction of pages on slow-tier (memory-only) NUMA nodes
157    /// (0.0-1.0). For CXL memory tiering tests: fails if more than
158    /// this fraction of pages land on memory-only nodes. Requires
159    /// `slow_tier_nodes` to be set at evaluation time.
160    pub max_slow_tier_ratio: Option<f64>,
161
162    /// Reproducer-mode literal-substring matcher for the captured
163    /// `scx_bpf_error` text. When `Some(literal)`, the eval pipeline
164    /// scans the combined scheduler log + sched_ext dump for a
165    /// substring match against `literal` and fails the test if the
166    /// substring is absent.
167    ///
168    /// Use this for the common case of pinning an exact error
169    /// fragment like `apply_cell_config returned -EINVAL` without
170    /// having to escape regex metacharacters. For pattern matching
171    /// with anchors / character classes / wildcards, use
172    /// [`Self::expect_scx_bpf_error_matches`] instead — the two
173    /// fields are orthogonal and can both be set (both must match).
174    ///
175    /// Requires the entry's `expect_err = true` — a reproducer
176    /// matcher only fires on expected-error tests; setting this on
177    /// a passing test would assert "the test passed AND contained
178    /// this error text," which is contradictory. The eval-time
179    /// validation rejects that combination with a clear diagnostic.
180    ///
181    /// Stored as `&'static str` so the const-fn `Self::merge`
182    /// composes without cloning. Empty strings are rejected at
183    /// evaluation (an empty literal would silently match every
184    /// message and turn this assertion into a no-op).
185    /// `#[serde(skip)]` because the field's `&'static str` shape
186    /// cannot round-trip through a borrowed deserializer (no source-
187    /// string lifetime to bind to). Reproducer matcher strings are
188    /// test-author static literals carried in the test definition
189    /// itself, not per-run data the sidecar needs to roundtrip — so
190    /// skipping them on the wire keeps the JSON shape clean without
191    /// losing any sidecar-consumer functionality. Skipped fields
192    /// default to `None` on deserialize per `Option::default()`.
193    ///
194    /// The `Option<Cow<'static, str>>` alternative that WOULD
195    /// roundtrip is rejected because it cascade-breaks
196    /// `Scheduler::assert`'s const-fn (`Cow` has a heap destructor,
197    /// which breaks the const-fn assignment path used by
198    /// `declare_scheduler!` macro statics). A future decomposition
199    /// into a `ReproducerMatchers` sub-config could revisit this if
200    /// sidecar-loaded test definitions ever need the strings to
201    /// survive end-to-end.
202    #[serde(skip)]
203    pub expect_scx_bpf_error_contains: Option<&'static str>,
204
205    /// Reproducer-mode regex matcher for the captured `scx_bpf_error`
206    /// text. When `Some(pattern)`, the eval pipeline compiles the
207    /// pattern via the `regex` crate, scans the combined scheduler
208    /// log + sched_ext dump, and fails the test if the regex does
209    /// not match anywhere in the corpus.
210    ///
211    /// The pattern is a full regex — special characters
212    /// (`. * + ? ( ) [ ] { } | ^ $ \`) carry their regex meaning.
213    /// For literal-substring matching, prefer
214    /// [`Self::expect_scx_bpf_error_contains`] to avoid escape
215    /// footguns. The captured corpus is the multi-line concatenation
216    /// of the scheduler log and `--- sched_ext dump ---`; the regex
217    /// crate's default flags apply: `^` and `$` anchor to the start /
218    /// end of the WHOLE corpus (not individual lines), and `.` does
219    /// NOT cross `\n`. Opt in to line-level anchoring with `(?m)`
220    /// (e.g. `(?m)^apply_cell_config$`) and to newline-spanning
221    /// `.` with `(?s)`. A bare `apply_cell_config` matches the
222    /// token anywhere in the corpus.
223    ///
224    /// Requires the entry's `expect_err = true` — same rationale
225    /// as [`Self::expect_scx_bpf_error_contains`]. Patterns are
226    /// validated at construction: empty literals, invalid regex
227    /// syntax, and any pattern satisfying `is_match("")` all
228    /// panic via the [`Self::expect_scx_bpf_error_matches`]
229    /// builder. The `is_match("")` predicate catches two
230    /// no-op classes with one check: patterns that match every
231    /// position (e.g. `a?`, `.*`, `(?:)`) trivially pass against
232    /// any corpus, and patterns that match only the empty string
233    /// (e.g. `^$`) trivially fail against any non-empty corpus —
234    /// real captured scheduler-output corpora are non-empty, so
235    /// both classes are equally no-op pins. Bare `\b` (word
236    /// boundary) slips the gate because the empty string
237    /// contains no word characters; see the builder docstring
238    /// for the operator-direction.
239    /// `#[serde(skip)]` for the same reason as
240    /// [`Self::expect_scx_bpf_error_contains`] above: `&'static str`
241    /// doesn't roundtrip + the matcher pattern is test-definition
242    /// data, not sidecar-roundtrip data. Skipped fields default to
243    /// `None` on deserialize.
244    #[serde(skip)]
245    pub expect_scx_bpf_error_matches: Option<&'static str>,
246}
247
248impl Assert {
249    /// Human-readable multi-line dump of every threshold field. Each
250    /// field renders as `  name: value` (`none` when the option is
251    /// `None`, i.e. inherited or unset). Used by
252    /// `cargo ktstr show-thresholds <test>` to expose the resolved
253    /// merged `Assert` (`default_checks().merge(&entry.scheduler.assert).
254    /// merge(&entry.assert)`) without forcing the operator to read
255    /// the Debug impl or source. Output is a sequence of indented
256    /// `row` lines ending with a newline; the caller owns any
257    /// outer section header (the `show-thresholds` CLI already
258    /// prints `Test: ...` / `Scheduler: ...` / `Resolved assertion
259    /// thresholds:` lines above the threshold block, and
260    /// `format_human` emits no outer header of its own so it does
261    /// not duplicate that banner).
262    pub fn format_human(&self) -> String {
263        use std::fmt::Write;
264        let mut out = String::new();
265        fn row<T: std::fmt::Display>(out: &mut String, name: &str, v: &Option<T>) {
266            match v {
267                Some(x) => writeln!(out, "  {name:<38}: {x}").unwrap(),
268                None => writeln!(out, "  {name:<38}: none").unwrap(),
269            }
270        }
271        row(&mut out, "not_starved", &self.not_starved);
272        row(&mut out, "isolation", &self.isolation);
273        row(&mut out, "max_gap_ms", &self.max_gap_ms);
274        row(&mut out, "max_spread_pct", &self.max_spread_pct);
275        row(&mut out, "max_throughput_cv", &self.max_throughput_cv);
276        row(&mut out, "min_work_rate", &self.min_work_rate);
277        row(
278            &mut out,
279            "max_p99_wake_latency_ns",
280            &self.max_p99_wake_latency_ns,
281        );
282        row(&mut out, "max_wake_latency_cv", &self.max_wake_latency_cv);
283        row(&mut out, "min_iteration_rate", &self.min_iteration_rate);
284        row(&mut out, "max_migration_ratio", &self.max_migration_ratio);
285        row(&mut out, "max_imbalance_ratio", &self.max_imbalance_ratio);
286        row(&mut out, "max_local_dsq_depth", &self.max_local_dsq_depth);
287        row(&mut out, "fail_on_stall", &self.fail_on_stall);
288        row(&mut out, "sustained_samples", &self.sustained_samples);
289        row(&mut out, "max_fallback_rate", &self.max_fallback_rate);
290        row(&mut out, "max_keep_last_rate", &self.max_keep_last_rate);
291        row(&mut out, "min_page_locality", &self.min_page_locality);
292        row(
293            &mut out,
294            "max_cross_node_migration_ratio",
295            &self.max_cross_node_migration_ratio,
296        );
297        row(&mut out, "max_slow_tier_ratio", &self.max_slow_tier_ratio);
298        row(
299            &mut out,
300            "expect_scx_bpf_error_contains",
301            &self.expect_scx_bpf_error_contains,
302        );
303        row(
304            &mut out,
305            "expect_scx_bpf_error_matches",
306            &self.expect_scx_bpf_error_matches,
307        );
308        out
309    }
310
311    /// Identity element for [`Assert::merge`]: every Option field is
312    /// `None` and `enforce_monitor_thresholds` is `false`, so neither
313    /// side of a merge with `NO_OVERRIDES` is altered.
314    /// Identical to the value returned by [`Self::default_checks`] —
315    /// the const is for spread-into-struct-literal composition, the
316    /// const fn is the method-style entry point.
317    pub const NO_OVERRIDES: Assert = Assert {
318        not_starved: None,
319        isolation: None,
320        max_gap_ms: None,
321        max_spread_pct: None,
322        max_throughput_cv: None,
323        min_work_rate: None,
324        max_p99_wake_latency_ns: None,
325        max_wake_latency_cv: None,
326        min_iteration_rate: None,
327        max_migration_ratio: None,
328        max_imbalance_ratio: None,
329        max_local_dsq_depth: None,
330        fail_on_stall: None,
331        sustained_samples: None,
332        max_fallback_rate: None,
333        max_keep_last_rate: None,
334        enforce_monitor_thresholds: false,
335        min_page_locality: None,
336        max_cross_node_migration_ratio: None,
337        max_slow_tier_ratio: None,
338        expect_scx_bpf_error_contains: None,
339        expect_scx_bpf_error_matches: None,
340    };
341
342    /// Baseline of the runtime merge chain
343    /// `default_checks().merge(&scheduler.assert).merge(&entry.assert)`.
344    ///
345    /// All checks are off by default — tests opt in to the assertions
346    /// they care about via scheduler-level or per-test `Assert`
347    /// overrides.
348    ///
349    /// For spread-into-struct-literal composition
350    /// (`Assert { not_starved: Some(true), ..Assert::NO_OVERRIDES }`)
351    /// use the equivalent const [`Self::NO_OVERRIDES`]; this const fn
352    /// is the method-style entry point that pairs with `.verdict()`
353    /// and the builder setters.
354    pub const fn default_checks() -> Assert {
355        Self::NO_OVERRIDES
356    }
357
358    /// Build a fresh [`Verdict`] under this `Assert`'s threshold
359    /// config. The returned accumulator carries no claim records; call
360    /// the typed `claim_<field>` methods generated by
361    /// [`#[derive(Claim)]`](ktstr_macros::Claim) on stats structs as
362    /// `stats.claim_<field>(&mut verdict)`, or use the
363    /// [`claim!`](crate::claim) macro on a local/expression, then
364    /// call [`Verdict::into_result`] to produce the final
365    /// [`AssertResult`].
366    ///
367    /// This is the entry point of the pointwise-claim API. The
368    /// `Assert` itself remains pure threshold config and stays
369    /// `Copy`; per-test claims accumulate on the returned `Verdict`,
370    /// which owns its own buffers (details, stats).
371    ///
372    /// ```
373    /// # use ktstr::assert::Assert;
374    /// let r = Assert::default_checks().verdict().into_result();
375    /// assert!(r.is_pass(), "no claims means passing verdict");
376    /// ```
377    pub fn verdict(self) -> Verdict {
378        Verdict::with_assert(self)
379    }
380
381    pub const fn check_not_starved(mut self) -> Self {
382        self.not_starved = Some(true);
383        self
384    }
385
386    pub const fn check_isolation(mut self) -> Self {
387        self.isolation = Some(true);
388        self
389    }
390
391    pub const fn max_gap_ms(mut self, ms: u64) -> Self {
392        self.max_gap_ms = Some(ms);
393        self
394    }
395
396    pub const fn max_spread_pct(mut self, pct: f64) -> Self {
397        self.max_spread_pct = Some(pct);
398        self
399    }
400
401    pub const fn max_throughput_cv(mut self, v: f64) -> Self {
402        self.max_throughput_cv = Some(v);
403        self
404    }
405
406    pub const fn min_work_rate(mut self, v: f64) -> Self {
407        self.min_work_rate = Some(v);
408        self
409    }
410
411    pub const fn max_p99_wake_latency_ns(mut self, v: u64) -> Self {
412        self.max_p99_wake_latency_ns = Some(v);
413        self
414    }
415
416    pub const fn max_wake_latency_cv(mut self, v: f64) -> Self {
417        self.max_wake_latency_cv = Some(v);
418        self
419    }
420
421    pub const fn min_iteration_rate(mut self, v: f64) -> Self {
422        self.min_iteration_rate = Some(v);
423        self
424    }
425
426    pub const fn max_migration_ratio(mut self, v: f64) -> Self {
427        self.max_migration_ratio = Some(v);
428        self
429    }
430
431    pub const fn max_imbalance_ratio(mut self, v: f64) -> Self {
432        self.max_imbalance_ratio = Some(v);
433        self
434    }
435
436    pub const fn max_local_dsq_depth(mut self, v: u32) -> Self {
437        self.max_local_dsq_depth = Some(v);
438        self
439    }
440
441    /// Control whether a monitor stall verdict fails the assertion.
442    pub const fn fail_on_stall(mut self, v: bool) -> Self {
443        self.fail_on_stall = Some(v);
444        self
445    }
446
447    /// Set the number of consecutive over-threshold samples required
448    /// before the monitor raises a verdict.
449    pub const fn sustained_samples(mut self, v: usize) -> Self {
450        self.sustained_samples = Some(v);
451        self
452    }
453
454    pub const fn max_fallback_rate(mut self, v: f64) -> Self {
455        self.max_fallback_rate = Some(v);
456        self
457    }
458
459    pub const fn max_keep_last_rate(mut self, v: f64) -> Self {
460        self.max_keep_last_rate = Some(v);
461        self
462    }
463
464    pub const fn min_page_locality(mut self, v: f64) -> Self {
465        self.min_page_locality = Some(v);
466        self
467    }
468
469    pub const fn max_cross_node_migration_ratio(mut self, v: f64) -> Self {
470        self.max_cross_node_migration_ratio = Some(v);
471        self
472    }
473
474    pub const fn max_slow_tier_ratio(mut self, v: f64) -> Self {
475        self.max_slow_tier_ratio = Some(v);
476        self
477    }
478
479    /// True when any worker-level check field is `Some`. A pure query on
480    /// the configured checks — it no longer gates per-cgroup telemetry
481    /// (telemetry is built unconditionally per handle in
482    /// [`crate::scenario`]'s collect path; worker-check assertions are the
483    /// only opt-in part). Retained as public API for callers composing an
484    /// `Assert` who need to know whether any worker check is set.
485    pub const fn has_worker_checks(&self) -> bool {
486        self.not_starved.is_some()
487            || self.isolation.is_some()
488            || self.max_gap_ms.is_some()
489            || self.max_spread_pct.is_some()
490            || self.max_throughput_cv.is_some()
491            || self.min_work_rate.is_some()
492            || self.max_p99_wake_latency_ns.is_some()
493            || self.max_wake_latency_cv.is_some()
494            || self.min_iteration_rate.is_some()
495            || self.max_migration_ratio.is_some()
496            || self.min_page_locality.is_some()
497            || self.max_cross_node_migration_ratio.is_some()
498            || self.max_slow_tier_ratio.is_some()
499    }
500
501    /// Merge `other` on top of `self`. Each `Some` field in `other`
502    /// overrides the corresponding field in `self`; `None` fields
503    /// inherit from `self`.
504    ///
505    /// [`Assert::NO_OVERRIDES`] is the two-sided identity:
506    /// `x.merge(&NO_OVERRIDES)` and `NO_OVERRIDES.merge(&x)` both yield
507    /// `x`. The runtime composes scheduler- and test-level overrides as
508    /// `Assert::default_checks().merge(&scheduler.assert).merge(&test.assert)`,
509    /// so a `NO_OVERRIDES` at either override layer leaves the defaults
510    /// untouched -- which means "no override," not "no checks."
511    pub const fn merge(&self, other: &Assert) -> Assert {
512        // `Option::or` is not yet const-stable, so each field expands
513        // a match rather than calling `other.x.or(self.x)`. Keep it
514        // this way until `const fn` can call `Option::or`; at that
515        // point the 21 match blocks collapse to 21 `.or()` calls.
516        Assert {
517            not_starved: match other.not_starved {
518                Some(v) => Some(v),
519                None => self.not_starved,
520            },
521            isolation: match other.isolation {
522                Some(v) => Some(v),
523                None => self.isolation,
524            },
525            max_gap_ms: match other.max_gap_ms {
526                Some(v) => Some(v),
527                None => self.max_gap_ms,
528            },
529            max_spread_pct: match other.max_spread_pct {
530                Some(v) => Some(v),
531                None => self.max_spread_pct,
532            },
533            max_throughput_cv: match other.max_throughput_cv {
534                Some(v) => Some(v),
535                None => self.max_throughput_cv,
536            },
537            min_work_rate: match other.min_work_rate {
538                Some(v) => Some(v),
539                None => self.min_work_rate,
540            },
541            max_p99_wake_latency_ns: match other.max_p99_wake_latency_ns {
542                Some(v) => Some(v),
543                None => self.max_p99_wake_latency_ns,
544            },
545            max_wake_latency_cv: match other.max_wake_latency_cv {
546                Some(v) => Some(v),
547                None => self.max_wake_latency_cv,
548            },
549            min_iteration_rate: match other.min_iteration_rate {
550                Some(v) => Some(v),
551                None => self.min_iteration_rate,
552            },
553            max_migration_ratio: match other.max_migration_ratio {
554                Some(v) => Some(v),
555                None => self.max_migration_ratio,
556            },
557            max_imbalance_ratio: match other.max_imbalance_ratio {
558                Some(v) => Some(v),
559                None => self.max_imbalance_ratio,
560            },
561            max_local_dsq_depth: match other.max_local_dsq_depth {
562                Some(v) => Some(v),
563                None => self.max_local_dsq_depth,
564            },
565            fail_on_stall: match other.fail_on_stall {
566                Some(v) => Some(v),
567                None => self.fail_on_stall,
568            },
569            sustained_samples: match other.sustained_samples {
570                Some(v) => Some(v),
571                None => self.sustained_samples,
572            },
573            max_fallback_rate: match other.max_fallback_rate {
574                Some(v) => Some(v),
575                None => self.max_fallback_rate,
576            },
577            max_keep_last_rate: match other.max_keep_last_rate {
578                Some(v) => Some(v),
579                None => self.max_keep_last_rate,
580            },
581            enforce_monitor_thresholds: self.enforce_monitor_thresholds
582                || other.enforce_monitor_thresholds,
583            min_page_locality: match other.min_page_locality {
584                Some(v) => Some(v),
585                None => self.min_page_locality,
586            },
587            max_cross_node_migration_ratio: match other.max_cross_node_migration_ratio {
588                Some(v) => Some(v),
589                None => self.max_cross_node_migration_ratio,
590            },
591            max_slow_tier_ratio: match other.max_slow_tier_ratio {
592                Some(v) => Some(v),
593                None => self.max_slow_tier_ratio,
594            },
595            expect_scx_bpf_error_contains: match other.expect_scx_bpf_error_contains {
596                Some(v) => Some(v),
597                None => self.expect_scx_bpf_error_contains,
598            },
599            expect_scx_bpf_error_matches: match other.expect_scx_bpf_error_matches {
600                Some(v) => Some(v),
601                None => self.expect_scx_bpf_error_matches,
602            },
603        }
604    }
605
606    /// Extract an `AssertPlan` for worker-side checks.
607    pub(crate) fn worker_plan(&self) -> AssertPlan {
608        AssertPlan {
609            not_starved: self.not_starved.unwrap_or(false),
610            isolation: self.isolation.unwrap_or(false),
611            max_gap_ms: self.max_gap_ms,
612            max_spread_pct: self.max_spread_pct,
613            max_throughput_cv: self.max_throughput_cv,
614            min_work_rate: self.min_work_rate,
615            max_p99_wake_latency_ns: self.max_p99_wake_latency_ns,
616            max_wake_latency_cv: self.max_wake_latency_cv,
617            min_iteration_rate: self.min_iteration_rate,
618            max_migration_ratio: self.max_migration_ratio,
619            min_page_locality: self.min_page_locality,
620            max_cross_node_migration_ratio: self.max_cross_node_migration_ratio,
621            max_slow_tier_ratio: self.max_slow_tier_ratio,
622        }
623    }
624
625    /// Run the configured worker checks against one cgroup's reports.
626    ///
627    /// `cpuset` is the CPU set for isolation checks. `numa_nodes` is
628    /// the NUMA node IDs covered by the cpuset (for page locality and
629    /// slow-tier checks). Derive via
630    /// [`TestTopology::numa_nodes_for_cpuset`](crate::topology::TestTopology::numa_nodes_for_cpuset).
631    pub fn assert_cgroup(
632        &self,
633        reports: &[crate::workload::WorkerReport],
634        cpuset: Option<&BTreeSet<usize>>,
635    ) -> AssertResult {
636        self.worker_plan().assert_cgroup(reports, cpuset, None)
637    }
638
639    /// Run worker checks with explicit NUMA node set for page locality.
640    pub fn assert_cgroup_with_numa(
641        &self,
642        reports: &[crate::workload::WorkerReport],
643        cpuset: Option<&BTreeSet<usize>>,
644        numa_nodes: Option<&BTreeSet<usize>>,
645    ) -> AssertResult {
646        self.worker_plan()
647            .assert_cgroup(reports, cpuset, numa_nodes)
648    }
649
650    /// Run NUMA page locality check.
651    ///
652    /// `observed` is the fraction of pages on expected nodes (0.0-1.0).
653    /// `total_pages` and `local_pages` are for diagnostics.
654    pub fn assert_page_locality(
655        &self,
656        observed: f64,
657        total_pages: u64,
658        local_pages: u64,
659    ) -> AssertResult {
660        assert_page_locality(observed, self.min_page_locality, total_pages, local_pages)
661    }
662
663    /// Run cross-node migration ratio check.
664    ///
665    /// `migrated_pages` is the `/proc/vmstat` `numa_pages_migrated` delta.
666    /// `total_pages` is total allocated pages from numa_maps.
667    pub fn assert_cross_node_migration(
668        &self,
669        migrated_pages: u64,
670        total_pages: u64,
671    ) -> AssertResult {
672        assert_cross_node_migration(
673            migrated_pages,
674            total_pages,
675            self.max_cross_node_migration_ratio,
676        )
677    }
678
679    /// Extract `MonitorThresholds` for monitor-side evaluation.
680    pub(crate) fn has_monitor_thresholds(&self) -> bool {
681        self.max_imbalance_ratio.is_some()
682            || self.max_local_dsq_depth.is_some()
683            || self.fail_on_stall.is_some()
684            || self.sustained_samples.is_some()
685            || self.max_fallback_rate.is_some()
686            || self.max_keep_last_rate.is_some()
687    }
688
689    pub(crate) fn monitor_thresholds(&self) -> crate::monitor::MonitorThresholds {
690        use crate::monitor::MonitorThresholds;
691        let d = MonitorThresholds::new();
692        MonitorThresholds {
693            max_imbalance_ratio: self.max_imbalance_ratio.unwrap_or(d.max_imbalance_ratio),
694            max_local_dsq_depth: self.max_local_dsq_depth.unwrap_or(d.max_local_dsq_depth),
695            fail_on_stall: self.fail_on_stall.unwrap_or(d.fail_on_stall),
696            sustained_samples: self.sustained_samples.unwrap_or(d.sustained_samples),
697            max_fallback_rate: self.max_fallback_rate.unwrap_or(d.max_fallback_rate),
698            max_keep_last_rate: self.max_keep_last_rate.unwrap_or(d.max_keep_last_rate),
699            enforce: self.enforce_monitor_thresholds,
700        }
701    }
702
703    /// Opt into pass/fail enforcement for monitor thresholds. Without
704    /// this call, monitor violations are reported in the verdict's
705    /// `details` but do not fail the test. With it, any monitor
706    /// threshold violation fails the test.
707    ///
708    /// Also populates any unset monitor-threshold field with the
709    /// canonical default from `MonitorThresholds::new()`
710    /// — so a test that only cares about `max_keep_last_rate` can chain
711    /// `.max_keep_last_rate(N).with_monitor_defaults()` and get the
712    /// other five enforced at their canonical defaults.
713    pub const fn with_monitor_defaults(mut self) -> Self {
714        use crate::monitor::MonitorThresholds;
715        let d = MonitorThresholds::new();
716        if self.max_imbalance_ratio.is_none() {
717            self.max_imbalance_ratio = Some(d.max_imbalance_ratio);
718        }
719        if self.max_local_dsq_depth.is_none() {
720            self.max_local_dsq_depth = Some(d.max_local_dsq_depth);
721        }
722        if self.fail_on_stall.is_none() {
723            self.fail_on_stall = Some(d.fail_on_stall);
724        }
725        if self.sustained_samples.is_none() {
726            self.sustained_samples = Some(d.sustained_samples);
727        }
728        if self.max_fallback_rate.is_none() {
729            self.max_fallback_rate = Some(d.max_fallback_rate);
730        }
731        if self.max_keep_last_rate.is_none() {
732            self.max_keep_last_rate = Some(d.max_keep_last_rate);
733        }
734        self.enforce_monitor_thresholds = true;
735        self
736    }
737
738    /// Const-fn builder for [`Self::expect_scx_bpf_error_contains`].
739    /// Chains with the other const-fn setters so a scheduler-def or
740    /// per-test assertion block can compose
741    /// `Assert::NO_OVERRIDES.expect_scx_bpf_error_contains(...).check_not_starved()`.
742    ///
743    /// Empty strings panic at construction (an empty literal would
744    /// silently match every message and turn this assertion into a
745    /// no-op); pass a non-empty fragment that should appear in the
746    /// expected `scx_bpf_error` message.
747    ///
748    /// # Panics
749    /// When `literal` is empty.
750    #[must_use = "builder methods consume self; bind the result"]
751    pub const fn expect_scx_bpf_error_contains(mut self, literal: &'static str) -> Self {
752        assert!(
753            !literal.is_empty(),
754            "Assert::expect_scx_bpf_error_contains: literal must be non-empty",
755        );
756        self.expect_scx_bpf_error_contains = Some(literal);
757        self
758    }
759
760    /// Builder for [`Self::expect_scx_bpf_error_matches`]. The
761    /// pattern is a regex; special characters retain their regex
762    /// meaning. For literal-substring matching, prefer
763    /// [`Self::expect_scx_bpf_error_contains`] to avoid escape
764    /// footguns.
765    ///
766    /// Validates the pattern at construction: rejects empty
767    /// patterns, rejects invalid regex syntax, and rejects any
768    /// pattern that satisfies `is_match("")`. The empty-string
769    /// match predicate catches two related no-op classes:
770    /// patterns that match every position (e.g. `a?`, `.*`,
771    /// `(?:)`) trivially pass against any corpus, and patterns
772    /// that match only the empty string (e.g. `^$`) trivially
773    /// fail against any non-empty corpus — every real captured
774    /// scheduler-output corpus is non-empty, so the latter is
775    /// equally a no-op pin in practice. Both are useless;
776    /// `is_match("")` catches both with one check.
777    ///
778    /// Bare `\b` (word boundary) slips this gate because the
779    /// empty string contains no word characters, so `\b` finds
780    /// no transition and `is_match("")` returns false; yet `\b`
781    /// matches the first word boundary in any real corpus,
782    /// turning a bare-`\b` pin into a vacuous "any non-empty
783    /// log passes" assertion. Use a substring of the expected
784    /// error text rather than a standalone boundary assertion.
785    /// All other documented assertions (`\A`, `\z`, `^`, `$`,
786    /// `\B`) match the empty string at position 0 and ARE
787    /// caught by the gate.
788    ///
789    /// Unlike the sibling [`Self::expect_scx_bpf_error_contains`]
790    /// (which is `const fn`), this builder is non-const because
791    /// the construction-time regex compilation requires heap
792    /// allocation. Callers needing a const builder for a regex
793    /// matcher must build the `Assert` via struct literal —
794    /// the evaluator's defense-in-depth catches invalid syntax
795    /// reached via that bypass at first evaluation, but the
796    /// vacuous-pattern gate only fires on the builder path.
797    ///
798    /// # Panics
799    /// When `pattern` is empty, is invalid regex syntax, or
800    /// matches the empty string.
801    #[must_use = "builder methods consume self; bind the result"]
802    pub fn expect_scx_bpf_error_matches(mut self, pattern: &'static str) -> Self {
803        assert!(
804            !pattern.is_empty(),
805            "Assert::expect_scx_bpf_error_matches: pattern must be non-empty",
806        );
807        let compiled = regex::Regex::new(pattern).unwrap_or_else(|e| {
808            panic!(
809                "Assert::expect_scx_bpf_error_matches: pattern {pattern:?} is not valid regex: {e}",
810            )
811        });
812        assert!(
813            !compiled.is_match(""),
814            "Assert::expect_scx_bpf_error_matches: pattern {pattern:?} matches the empty \
815             string (e.g. `a?`, `.*`, `(?:)`, `^$`); such patterns vacuously match any \
816             corpus and turn the matcher into a no-op — use a meaningful pattern that \
817             requires at least one character",
818        );
819        self.expect_scx_bpf_error_matches = Some(pattern);
820        self
821    }
822
823    /// Evaluate the reproducer-mode `scx_bpf_error` matchers against
824    /// the captured text corpus. Returns an empty Vec when no matcher
825    /// is configured or when every configured matcher matches; returns
826    /// a non-empty Vec of `AssertDetail` entries on failure.
827    ///
828    /// Each configured matcher contributes at most one detail. Both
829    /// fields can be set simultaneously (`AND` semantics — both must
830    /// match).
831    ///
832    /// Preconditions enforced by the evaluator:
833    /// 1. `expect_err = true` must be set when either matcher is
834    ///    configured. Setting the matcher on a passing-test contract
835    ///    is a misuse — surfaced with an `expect_err = true` reminder
836    ///    diagnostic.
837    /// 2. The regex pattern in
838    ///    [`Self::expect_scx_bpf_error_matches`] must compile via
839    ///    the `regex` crate. Invalid syntax surfaces as a diagnostic
840    ///    naming the pattern and the compile error.
841    ///
842    /// `captured_text` is the concatenation of the raw scheduler-log
843    /// stream (the bulk-port merged `SchedLog` frames, or the test
844    /// process `output` fallback when no frames arrived) and the
845    /// `--- sched_ext dump ---` extract — both surfaces where
846    /// `scx_bpf_error` printk lands. The matcher sees the WHOLE
847    /// stream, not the marker-extracted section; lines outside the
848    /// `SCHED_OUTPUT_START..SCHED_OUTPUT_END` markers are included.
849    pub fn evaluate_scx_bpf_error_match(
850        &self,
851        captured_text: &str,
852        expect_err: bool,
853    ) -> Vec<AssertDetail> {
854        let mut details = Vec::new();
855        if self.expect_scx_bpf_error_contains.is_none()
856            && self.expect_scx_bpf_error_matches.is_none()
857        {
858            return details;
859        }
860        if !expect_err {
861            details.push(AssertDetail::new(
862                DetailKind::Other,
863                "expect_scx_bpf_error_contains or expect_scx_bpf_error_matches \
864                 requires expect_err = true on the test entry — the matcher narrows \
865                 which failure counts as the expected bug, and only applies to \
866                 expected-error tests; set #[ktstr_test(expect_err = true, ...)] or \
867                 drop the matcher",
868            ));
869            return details;
870        }
871        // Truncate at a UTF-8 char boundary at or below 400 bytes so
872        // the excerpt length stays within the "up to 400 bytes follow:"
873        // budget. `chars().take(400)` would count codepoints instead,
874        // and a multi-byte corpus would exceed the byte budget — the
875        // boundary walk steps back to the nearest char start.
876        let excerpt = || -> &str {
877            let len = captured_text.len();
878            if len <= 400 {
879                captured_text
880            } else {
881                let mut end = 400;
882                while end > 0 && !captured_text.is_char_boundary(end) {
883                    end -= 1;
884                }
885                &captured_text[..end]
886            }
887        };
888        if let Some(literal) = self.expect_scx_bpf_error_contains
889            && !captured_text.contains(literal)
890        {
891            details.push(AssertDetail::new(
892                DetailKind::Other,
893                format!(
894                    "expect_scx_bpf_error_contains({literal:?}): substring not found \
895                     in the scheduler log + sched_ext dump corpus (the expected bug \
896                     did not fire, or its message text changed). Captured corpus \
897                     {} bytes; up to 400 bytes follow:\n{}",
898                    captured_text.len(),
899                    excerpt(),
900                ),
901            ));
902        }
903        if let Some(pattern) = self.expect_scx_bpf_error_matches {
904            match regex::Regex::new(pattern) {
905                Ok(re) => {
906                    if !re.is_match(captured_text) {
907                        details.push(AssertDetail::new(
908                            DetailKind::Other,
909                            format!(
910                                "expect_scx_bpf_error_matches({pattern:?}): regex did \
911                                 not match the scheduler log + sched_ext dump corpus \
912                                 (the expected bug did not fire, or its message text \
913                                 changed). Captured corpus {} bytes; up to 400 bytes \
914                                 follow:\n{}",
915                                captured_text.len(),
916                                excerpt(),
917                            ),
918                        ));
919                    }
920                }
921                Err(e) => {
922                    details.push(AssertDetail::new(
923                        DetailKind::Other,
924                        format!(
925                            "expect_scx_bpf_error_matches({pattern:?}): regex \
926                             compilation failed: {e}. Fix the pattern at the test \
927                             declaration site — the matcher cannot evaluate against an \
928                             invalid pattern",
929                        ),
930                    ));
931                }
932            }
933        }
934        details
935    }
936}