ktstr/stats/row.rs
1use super::*;
2
3/// Per-scenario result row for gauntlet analysis and run-to-run comparison.
4///
5/// Populated by [`sidecar_to_row`] from on-disk [`SidecarResult`](crate::test_support::SidecarResult)s. The
6/// comparison pipeline reads metric values through [`MetricDef::read`]
7/// / [`METRICS`] rather than dereferencing fields directly so new
8/// metrics can land through the registry without touching every
9/// reader.
10///
11/// # NaN-ambiguity on direct f64 fields
12///
13/// All direct f64 fields on this struct are sanitized via
14/// `finite_or_zero` at [`sidecar_to_row`] ingress. A `0.0` on any
15/// direct f64 field may represent either a genuine zero measurement
16/// or a sanitized non-finite upstream value (NaN / ±Infinity). See
17/// [`sidecar_to_row`]'s NaN-ambiguity doc for the full policy;
18/// `tracing::warn!` is the disambiguation channel — the sanitizer
19/// warns on every non-finite it rewrites to zero, so the log
20/// timeline tells you which run's zeroes were real. Consumers that
21/// cannot accept the ambiguity should prefer metric paths that
22/// flow through `ext_metrics` (a `BTreeMap<String, f64>` — see the
23/// field definition below): non-finite entries are DROPPED at
24/// [`sidecar_to_row`] ingress rather than stored. A subsequent
25/// `ext_metrics.get(name)` returns `None` because the key is
26/// absent, not because an `Option::None` sentinel is stored — the
27/// map's value type is `f64`, which cannot represent "missing".
28/// Absent-key and zero-valued metrics therefore remain distinguishable
29/// for downstream consumers.
30///
31/// # `#[non_exhaustive]` migration note
32///
33/// Downstream code that pattern-matches a `GauntletRow` must end
34/// the match with `..`; future fields added alongside new metrics
35/// otherwise break every matcher. Prefer reading values via
36/// [`MetricDef::read`] / the registry — the point of the
37/// registry indirection is that new metrics do not touch
38/// existing readers.
39#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
40#[non_exhaustive]
41pub struct GauntletRow {
42 pub scenario: String,
43 pub topology: String,
44 pub work_type: String,
45 /// Effective host-CPU budget the run's vCPU threads ran on
46 /// (`SidecarResult::cpu_budget`); `None` for skip rows (budget 0).
47 /// Drives the [`Dimension::CpuBudget`] pairing so cross-budget runs
48 /// are never compared (confining 32 vCPUs to 4 host CPUs measures
49 /// something different), and (with [`vcpus`](Self::vcpus)) feeds the
50 /// compare-path overcommit warning in [`render_overcommit_warning`].
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub cpu_budget: Option<u32>,
53 /// Guest vCPU count (`SidecarResult::vcpus`); `None` for skip rows.
54 /// NOT a Dimension — it rides alongside [`cpu_budget`](Self::cpu_budget)
55 /// so [`render_overcommit_warning`] can flag a compared run whose host
56 /// time-sliced its vCPUs (`cpu_budget < vcpus`), whose guest-scheduler
57 /// timing metrics are then host-contention-confounded.
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub vcpus: Option<u32>,
60 /// Scheduler binary name carried from the source sidecar
61 /// (`SidecarResult::scheduler`). Surfaced through the substring
62 /// filter in [`compare_rows_by`] and the typed
63 /// `RowFilter::schedulers` so users can narrow A/B comparisons
64 /// by scheduler name.
65 pub scheduler: String,
66 /// Kernel version carried from the source sidecar
67 /// (`SidecarResult::kernel_version`). `None` when the sidecar
68 /// writer could not extract a version (e.g. a raw kernel image
69 /// path with no metadata.json sibling, or a dirty source tree
70 /// where HEAD does not describe the build). Surfaced via the
71 /// typed [`RowFilter::kernels`] for narrowing — when the user
72 /// passes `--kernel 6.14.2` (repeatable), rows with `None` are
73 /// dropped to preserve the operator's intent ("only these
74 /// kernels"); a `None`-as-wildcard would silently dilute the
75 /// filtered set.
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub kernel_version: Option<String>,
78 /// ktstr project git commit carried from the source sidecar
79 /// (`SidecarResult::project_commit`). Short hex with optional
80 /// `-dirty` suffix (e.g. `"abcdef1"` or `"abcdef1-dirty"`).
81 /// `None` when the sidecar writer could not probe a git repo
82 /// at write time (cwd not inside a checkout, or
83 /// `crate::test_support::sidecar::detect_project_commit`
84 /// failed for any reason). Surfaced via the typed
85 /// [`RowFilter::project_commits`] for narrowing — when the
86 /// user passes `--project-commit abcdef1` (repeatable), rows
87 /// with `None` are dropped to preserve the operator's intent
88 /// ("only these commits"); a `None`-as-wildcard would silently
89 /// dilute the filtered set, mirroring the [`RowFilter::kernels`]
90 /// policy.
91 ///
92 /// Sourced from `SidecarResult::project_commit`; shortened to
93 /// `commit` on the row because the project commit is the
94 /// most-frequently-narrowed-on of the three commit dimensions
95 /// on [`SidecarResult`](crate::test_support::SidecarResult). The other two commit fields —
96 /// `SidecarResult::scheduler_commit` and
97 /// `SidecarResult::kernel_commit` — get fully-qualified names
98 /// here (`scheduler_commit` is reserved and not yet exposed,
99 /// `kernel_commit` is the typed filter `RowFilter::kernel_commits`
100 /// applies). The bare `commit` shortening is internal to
101 /// `GauntletRow`; the CLI flag is the disambiguated
102 /// `--project-commit` form so an operator never has to guess
103 /// which "commit" dimension a bare `--commit` would have meant.
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub commit: Option<String>,
106 /// Kernel SOURCE TREE git commit carried from the source
107 /// sidecar (`SidecarResult::kernel_commit`). Short hex with
108 /// optional `-dirty` suffix (e.g. `"abcdef1"` or
109 /// `"abcdef1-dirty"`). `None` when the sidecar writer could
110 /// not probe a git repo for the kernel directory at write
111 /// time (KTSTR_KERNEL points at a non-git path, the
112 /// underlying source is `Tarball` / `Git` rather than
113 /// `Local`, or
114 /// `crate::test_support::sidecar::detect_kernel_commit`
115 /// failed for any reason).
116 ///
117 /// Distinct from [`GauntletRow::commit`]: that field tracks
118 /// the ktstr framework HEAD ("which version of the harness
119 /// produced this sidecar?"); this field tracks the kernel
120 /// tree HEAD ("which kernel commit did this run boot?"). Two
121 /// runs with the same `commit` but different `kernel_commit`
122 /// values are typical when the kernel under test is updated
123 /// without re-checking out the harness; two runs with the
124 /// same `kernel_commit` but different `commit` values are
125 /// typical when the harness is bumped without rebuilding the
126 /// kernel.
127 ///
128 /// Surfaced via the typed [`RowFilter::kernel_commits`] for
129 /// narrowing — same opt-in policy as [`RowFilter::project_commits`]:
130 /// rows with `None` never match a populated filter.
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub kernel_commit: Option<String>,
133 /// Run-environment provenance tag carried from
134 /// `SidecarResult::run_source` (`"local"` for developer runs,
135 /// `"ci"` when `crate::test_support::sidecar::KTSTR_CI_ENV`
136 /// was set at write time, `"archive"` when the consumer pulled
137 /// the pool from a non-default `--dir`). `None` for sidecars
138 /// produced before the field existed (pre-1.0 disposable
139 /// schema; re-running the test regenerates the entry).
140 /// Surfaced via the typed [`RowFilter::run_sources`] for
141 /// narrowing — when the user passes `--run-source local`
142 /// (repeatable), rows with `None` are dropped to preserve the
143 /// operator's intent ("only these environments"); a
144 /// `None`-as-wildcard would silently dilute the filtered set,
145 /// mirroring the [`RowFilter::kernels`] /
146 /// [`RowFilter::project_commits`] / [`RowFilter::kernel_commits`]
147 /// policy.
148 ///
149 /// Field name `run_source` (renamed from `source`) disambiguates
150 /// from [`crate::cache::KernelSource`] / `KernelMetadata.source`
151 /// — those describe the kernel build's input (tarball / git /
152 /// local), this describes the run-environment provenance.
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub run_source: Option<String>,
155 /// Scheduler-resolution provenance carried from
156 /// [`crate::test_support::SidecarResult::resolve_source`] —
157 /// the snake_case discovery-path tag (`"auto_built"`,
158 /// `"target_debug"`, `"path"`, ...). `None` for sidecars produced
159 /// before the field existed (pre-1.0 disposable schema) and for skip
160 /// rows (no binary resolved). Surfaced via the typed
161 /// [`RowFilter::resolve_sources`] (`--resolve-source`) for narrowing +
162 /// pairing — same opt-in policy as `run_source` — and listed by
163 /// `stats list-values`. Provenance, not identity: distinct
164 /// from `scheduler` / `kernel_commit` — it records HOW the scheduler
165 /// binary was found, so a reader can tell an auto-built-from-HEAD run
166 /// from a possibly-stale `target/` or `$PATH` binary.
167 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub resolve_source: Option<String>,
169 /// True when the underlying [`crate::assert::AssertResult::is_pass`] returned
170 /// true at sidecar emission time — a real pass with at least one
171 /// observed outcome and no Fail/Inconclusive/Skip. Mutually
172 /// exclusive with [`Self::skipped`] and [`Self::inconclusive`]:
173 /// the three bits encode a strict 4-state verdict where exactly
174 /// one of (Pass, Skip, Inconclusive, Fail) is set per row.
175 pub passed: bool,
176 /// True when the run was skipped (topology mismatch, missing
177 /// resource). Mutually exclusive with [`Self::passed`] (Skip is
178 /// not Pass; the empty / all-Skip outcomes vec maps to Skip,
179 /// not Pass) and with [`Self::inconclusive`]. Lets stats tooling
180 /// exclude these from pass counts so skipped runs don't inflate
181 /// the apparent pass rate.
182 pub skipped: bool,
183 /// True when at least one assertion recorded
184 /// [`crate::assert::Outcome::Inconclusive`] — the run ran but a
185 /// zero-denominator ratio gate could not be evaluated. Mutually
186 /// exclusive with [`Self::passed`] and [`Self::skipped`]; in the
187 /// `Fail > Inconclusive > Pass > Skip` lattice, Inconclusive
188 /// dominates Pass/Skip but loses to Fail, so a row with both
189 /// Inconclusive and Fail outcomes records `inconclusive = false,
190 /// passed = false` (Fail wins). Surfaced as a distinct bit so
191 /// `is_fail` can exclude these from hard-fail counts and
192 /// dashboards can triage zero-denominator runs separately from
193 /// real regressions.
194 #[serde(default)]
195 pub inconclusive: bool,
196 /// True when this row's source run was an `expect_err` /
197 /// `expect_auto_repro` test whose actual scenario failure was
198 /// inverted to a pass — its telemetry is failure-mode-dominated
199 /// (short / stalled run), so [`compare_rows_by`] excludes it from
200 /// the regression math even though [`passed`](Self::passed) is now
201 /// `true`. Carried from `SidecarResult::expected_failure`;
202 /// OR-folded across a cohort in `group_and_average_by`.
203 #[serde(default)]
204 pub expected_failure: bool,
205 /// Number of monitor samples this run was averaged over —
206 /// the natural per-RUN weight for `Gauge(Avg)` metrics when
207 /// folded across multiple runs at cross-RUN comparison time
208 /// (`group_and_average_by`). Sourced from
209 /// `MonitorSummary::total_samples` at sidecar-write time;
210 /// `0` when the monitor did not run for this scenario
211 /// (host-only test, early VM failure). A `0` weight
212 /// degenerates to unweighted mean per the fallback at
213 /// `aggregate_samples`'s zero-total-weight branch.
214 ///
215 /// The field exists because the cross-RUN aggregator
216 /// previously computed unweighted arithmetic mean for every
217 /// metric — biased for `Gauge(Avg)` when runs in a cohort
218 /// had different sample populations (a 5-sample run and a
219 /// 50-sample run contributing equally to the cohort mean).
220 /// Carrying the per-RUN count here lets the aggregator dispatch
221 /// per-`MetricKind` weighted folds via the helper.
222 #[serde(default)]
223 pub run_sample_count: usize,
224 /// Worst-case per-cgroup spread across the run. Four names
225 /// describe the same quantity across the pipeline:
226 /// - [`ScenarioStats::worst_spread`](crate::assert::ScenarioStats::worst_spread)
227 /// — the upstream source. `sidecar_to_row` reads it and
228 /// writes the value into this field via `finite_or_zero`.
229 /// - `GauntletRow.spread` (this field) — the Rust-side
230 /// struct access path inside the comparison pipeline.
231 /// - `MetricDef.name == "worst_spread"` — the [`METRICS`]
232 /// registry key, which is the domain-level name that appears
233 /// in sidecars, CI gates, and `cargo ktstr perf-delta`
234 /// output.
235 /// - DataFrame column `"spread"` — the polars column name used
236 /// when the rows are projected into a DataFrame for group /
237 /// aggregate operations.
238 ///
239 /// The registry name is not renamed to match the field name
240 /// because existing sidecars and CI regression gates reference
241 /// `"worst_spread"` by string and a rename would silently
242 /// invalidate them. The DataFrame column stays `"spread"` for
243 /// terseness and to match the field; consumers that cross
244 /// the registry / DataFrame boundary translate via
245 /// [`MetricDef::read`] rather than by string comparison.
246 pub spread: f64,
247 /// Worst-case per-cgroup scheduling gap (ms). Surfaced in
248 /// [`METRICS`] under registry name `worst_gap_ms`; the
249 /// field / registry / DataFrame-column divergence is catalogued
250 /// in the triples table on [`METRICS`].
251 pub gap_ms: u64,
252 /// Total CPU migrations across the run. Surfaced in [`METRICS`]
253 /// under registry name `total_migrations`; see the triples
254 /// table on [`METRICS`] for the rationale behind the
255 /// field / registry / DataFrame-column divergence.
256 pub migrations: u64,
257 /// Worst-case per-cgroup migrations-per-iteration ratio.
258 /// Surfaced in [`METRICS`] under registry name
259 /// `worst_migration_ratio`; see the triples table on
260 /// [`METRICS`] for the field / registry / DataFrame-column
261 /// divergence.
262 pub migration_ratio: f64,
263 // Monitor fields (host-side telemetry from guest memory reads).
264 /// Worst per-sample cgroup imbalance ratio. Surfaced in
265 /// [`METRICS`] under registry name `max_imbalance_ratio`
266 /// (DataFrame column `imbalance`); see the triples table on
267 /// [`METRICS`] for the registry/field/column rationale.
268 pub imbalance_ratio: f64,
269 /// Worst observed DSQ queue depth. Registry and field names
270 /// match (`max_dsq_depth`) but the DataFrame column is
271 /// `dsq_depth`; see the triples table on [`METRICS`] for the
272 /// column-level rename rationale.
273 pub max_dsq_depth: u32,
274 /// Stuck-sample count across the run (CPUs whose `rq_clock`
275 /// failed to advance between consecutive samples). Distinct from
276 /// the sched_ext watchdog stall (`SCX_EXIT_ERROR_STALL`):
277 /// "stuck" tracks rq_clock not advancing on a CPU, while a
278 /// watchdog stall describes a runnable task that hasn't been
279 /// scheduled within the watchdog timeout. Registry and field
280 /// names match (`stuck_count`) but the DataFrame column is
281 /// `stuck`; see the triples table on [`METRICS`] for the
282 /// column-level rename rationale.
283 ///
284 /// `f64`, not an integer: a per-run row carries an exact integer
285 /// count, but the cross-run average fold (`super::group`'s
286 /// `group_and_average_by`) stores the fractional mean here so the
287 /// A/B comparison reads the true delta. Rounding the mean to an
288 /// integer would let the up-to-1.0 rounding error defeat the
289 /// `stuck_count` metric's `default_abs` of 1.0 and fabricate
290 /// single-stall regressions from sub-integer differences (e.g. an
291 /// A-side mean of 1.4 vs a B-side mean of 1.6 rounds to 1 vs 2).
292 pub stuck_count: f64,
293 /// Fallback-dispatch count across the run. Carried as-is from
294 /// `MonitorSummary::event_deltas.total_fallback` — an integer
295 /// event count, NOT a rate. Surfaced in [`METRICS`] under
296 /// registry name `total_fallback` (DataFrame column `fallback`);
297 /// see the triples table on [`METRICS`] for the registry / field /
298 /// column rationale.
299 pub fallback_count: i64,
300 /// Keep-last dispatch count across the run. Carried as-is from
301 /// `MonitorSummary::event_deltas.total_dispatch_keep_last` — an
302 /// integer event count, NOT a rate. Surfaced in [`METRICS`] under
303 /// registry name `total_keep_last` (DataFrame column `keep_last`);
304 /// see the triples table on [`METRICS`] for the registry / field /
305 /// column rationale.
306 pub keep_last_count: i64,
307 // Benchmarking fields. The wake-latency (p99 / median / CV) and
308 // run-delay (mean / worst) roll-ups are NO LONGER typed fields: they are
309 // `MetricKind::Distribution`, re-pooled into `ext_metrics` post-merge by
310 // `crate::assert::populate_run_distribution_metrics`; `MetricDef::read`
311 // surfaces them via the ext fallback (their accessors are `|_| None`).
312 pub total_iterations: u64,
313 // worst_wake_latency_tail_ratio is NO LONGER a typed field: it is
314 // `MetricKind::WakeLatencyTailRatio`, re-selected into `ext_metrics`
315 // post-merge by `crate::assert::populate_run_distribution_metrics` (max
316 // over the per-cgroup p99/median ratios, floor-gated below
317 // [`WAKE_LATENCY_TAIL_RATIO_MIN_ITERATIONS`]); `MetricDef::read` surfaces
318 // it via the ext fallback (accessor `|_| None`).
319 // worst_iterations_per_worker / worst_iterations_per_cpu_sec are NO
320 // LONGER typed fields: they are `MetricKind::WorstLowest`, re-selected
321 // into `ext_metrics` post-merge by
322 // `crate::assert::populate_run_distribution_metrics` (lowest-wins over
323 // the per-cgroup counters); `MetricDef::read` surfaces them via the ext
324 // fallback. The `worst_` naming convention is documented on [`METRICS`].
325 // NUMA fields.
326 // Neither NUMA roll-up is a typed GauntletRow column any longer:
327 // worst_page_locality is `MetricKind::WorstLowest` (NumaLocal/NumaTotal) and
328 // worst_cross_node_migration_ratio is `MetricKind::WorstCrossNodeRatio`, both
329 // ext-sourced (re-pooled from the per-phase NUMA carriers by
330 // populate_run_distribution_metrics); `MetricDef::read` surfaces them via the
331 // ext fallback, so no typed columns.
332 /// Extensible metrics populated by scenarios and processed by the
333 /// comparison pipeline. Keyed by metric name; looked up via
334 /// [`metric_def`] when a matching entry exists in [`METRICS`].
335 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
336 pub ext_metrics: BTreeMap<String, f64>,
337 /// The subset of [`Self::ext_metrics`] keys that are Dynamic monotonic
338 /// counters (the level-suffixed `lb_*`/`alb_*` schedstat deltas + any
339 /// `ScalarCounter` watched bpf field). These keys are not in the static
340 /// `METRICS` registry, so the cross-run aggregator (`fold_ext_metrics`)
341 /// consults this set to SUM-fold them instead of averaging — matching the
342 /// registered-Counter cross-run convention. Empty when the run has no
343 /// monitor or no Dynamic counter keys.
344 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
345 pub ext_counter_keys: BTreeSet<String>,
346 /// Per-phase metric buckets carried verbatim from the source
347 /// sidecar's [`crate::assert::ScenarioStats::phases`]. Each
348 /// [`crate::assert::PhaseBucket`] surfaces the metric values
349 /// reduced over one scenario phase (BASELINE at
350 /// `step_index = 0`, Step ordinals at `step_index = 1..=N`
351 /// per the 1-indexed phase convention) so the per-phase
352 /// comparison renderer at [`compare_partitions_noise`] can pair
353 /// matched phases across two sidecars by `step_index` and
354 /// emit per-phase spread rows without re-deriving phase
355 /// boundaries.
356 ///
357 /// Empty when the source sidecar had no phase data — single-
358 /// phase scenarios that didn't drive an explicit Step vec,
359 /// or legacy sidecars from before the phase-aware pipeline
360 /// shipped. Per the pre-1.0 disposability contract, the
361 /// expected response to a legacy sidecar is to re-run the
362 /// test and regenerate, NOT to back-fill the field on read.
363 /// `serde(default, skip_serializing_if = "Vec::is_empty")`
364 /// keeps the serialized shape compact: a row with no phase
365 /// data omits the field entirely on the wire rather than
366 /// carrying an empty array.
367 #[serde(default, skip_serializing_if = "Vec::is_empty")]
368 pub phases: Vec<crate::assert::PhaseBucket>,
369 /// Per-test [`crate::test_support::PerfDeltaAssertion`] declarations (owned
370 /// records) carried from the sidecar so the perf-delta noise compare can
371 /// enforce the declared gate. Empty for rows with none; `serde(default)`
372 /// tolerates legacy sidecars (pre-1.0 disposability). NOT a pairing/slicing
373 /// dimension — per-test gate metadata only.
374 #[serde(default, skip_serializing_if = "Vec::is_empty")]
375 pub perf_delta_assertions: Vec<crate::test_support::PerfDeltaAssertionRecord>,
376}
377
378impl GauntletRow {
379 /// Convenience accessor mirroring [`crate::assert::AssertResult::is_pass`]
380 /// so the is_pass / is_fail vocabulary applies uniformly across both
381 /// verdict surfaces. GauntletRow is the sidecar-wire shape; its
382 /// `passed` bool is populated from `AssertResult::is_pass()` at
383 /// sidecar emission time.
384 ///
385 /// Returns true only when the row reached a real Pass — neither
386 /// skipped, inconclusive, nor failed. The triple-conjunct guard
387 /// matches the strict 4-state mutex encoded with three stored
388 /// bits `(passed, skipped, inconclusive)` (Fail is the all-false
389 /// derived state, no dedicated bit), so a manually-constructed
390 /// row that sets `passed = true, skipped = true` (which would
391 /// violate the mutex) still reads as not-pass here.
392 ///
393 /// Part of the `is_pass` / `is_fail` / `is_inconclusive` /
394 /// `is_skip` vocabulary uniform across the verdict surfaces:
395 /// [`crate::assert::AssertResult::is_pass`] /
396 /// [`crate::test_support::SidecarResult::is_pass`] /
397 /// [`crate::assert::Outcome::is_pass`] / `MonitorVerdict::is_pass`
398 /// (in the `monitor` module, which is `pub(crate)`) /
399 /// `Verdict::is_pass` (re-exported at [`crate::assert::Verdict`])
400 /// / `Self::is_pass`.
401 pub fn is_pass(&self) -> bool {
402 self.passed && !self.skipped && !self.inconclusive
403 }
404 /// Convenience accessor mirroring
405 /// [`crate::assert::AssertResult::is_fail`]. True only when the
406 /// row is a real failure — not a skip, not an inconclusive
407 /// (zero-denominator) run. Excludes `inconclusive` so a stats
408 /// gate that counts "real regressions" does not conflate
409 /// inconclusive runs with hard failures.
410 pub fn is_fail(&self) -> bool {
411 !self.passed && !self.skipped && !self.inconclusive
412 }
413 /// Convenience accessor mirroring
414 /// [`crate::assert::AssertResult::is_skip`].
415 pub fn is_skip(&self) -> bool {
416 self.skipped
417 }
418 /// Convenience accessor mirroring
419 /// [`crate::assert::AssertResult::is_inconclusive`]. True when
420 /// the row reflects a zero-denominator ratio gate that could
421 /// not be evaluated.
422 pub fn is_inconclusive(&self) -> bool {
423 self.inconclusive
424 }
425}
426
427/// Typed-field filter set for narrowing `GauntletRow` sets in the
428/// `cargo ktstr perf-delta` pipeline. Every field is `None` /
429/// empty by default; populated fields are AND-combined ACROSS
430/// fields, with field-internal OR/AND semantics described per-field
431/// below. Applied via `apply_row_filters` in `compare_partitions`
432/// before the rows reach `compare_rows_by`.
433///
434/// Match semantics:
435/// - `scheduler` / `topology` / `work_type` — STRICT EQUALITY against
436/// the row's corresponding field. The sibling substring filter on
437/// `compare_rows_by` (`-E`) stays as the only fuzzy-match knob;
438/// typed fields are exact so a `--scheduler scx_rusty` filter does
439/// NOT spuriously match `scx_rusty_alt`.
440/// - `kernels` — repeatable, OR-combined: a row matches iff its
441/// `kernel_version` equals ANY entry in `kernels`. Mirrors the
442/// `--kernel` flag on `cargo ktstr test`/`coverage`/`llvm-cov`
443/// so the same flag name carries the same multi-value semantic
444/// across every subcommand.
445/// - `project_commits` — repeatable, OR-combined: a row matches
446/// iff its `commit` equals ANY entry in `project_commits`. Same
447/// multi-value semantic as `kernels`, applied to the ktstr
448/// project commit recorded by `detect_project_commit` at
449/// sidecar-write time. Surfaced as the `--project-commit` CLI
450/// flag.
451/// - `kernel_commits` — repeatable, OR-combined: a row matches
452/// iff its `kernel_commit` equals ANY entry in `kernel_commits`.
453/// Same multi-value semantic as `project_commits`, applied to
454/// the kernel source-tree commit recorded by
455/// `crate::test_support::sidecar::detect_kernel_commit` at
456/// sidecar-write time. Filters on the kernel HEAD, NOT on the
457/// kernel release version (`kernels` is the version filter).
458/// - `run_sources` — repeatable, OR-combined: a row matches iff
459/// its `run_source` equals ANY entry in `run_sources`. Same
460/// multi-value semantic as `kernels` / `project_commits` /
461/// `kernel_commits`, applied to the run-environment provenance
462/// tag (`"local"`, `"ci"`, `"archive"`) recorded by
463/// `crate::test_support::sidecar::detect_run_source` at
464/// sidecar-write time, or rewritten to `"archive"` at load
465/// time when the consumer pulled the pool from a non-default
466/// `--dir`. Surfaced as the `--run-source` CLI flag.
467/// - A `kernels`-populated filter against a row whose
468/// `kernel_version` is `None` ALWAYS fails (no wildcard semantic)
469/// — the operator wrote specific versions and a `None`-row would
470/// silently dilute the set. The same opt-in policy applies to
471/// `project_commits` against rows with `commit == None`, to
472/// `kernel_commits` against rows with `kernel_commit == None`,
473/// and to `run_sources` against rows with `run_source == None`.
474///
475/// Empty `RowFilter` (every field `None`/empty) is the no-op default
476/// and matches every row. Use [`RowFilter::default()`] to build it.
477#[derive(Clone, Debug, Default)]
478pub struct RowFilter {
479 /// Repeatable kernel-version filter, OR-combined: a row matches
480 /// iff its `GauntletRow::kernel_version` equals ANY entry. Empty
481 /// vec disables the filter ("do not filter on kernel"). A row
482 /// whose `kernel_version` is itself `None` never matches a
483 /// non-empty filter.
484 pub kernels: Vec<String>,
485 /// Repeatable project-commit filter, OR-combined: a row matches
486 /// iff its `GauntletRow::commit` equals ANY entry. Empty vec
487 /// disables the filter ("do not filter on commit"). A row whose
488 /// `commit` is itself `None` never matches a non-empty filter
489 /// — same opt-in semantic as `kernels`.
490 ///
491 /// Field name `project_commits` (renamed from `commits`)
492 /// disambiguates from the sibling `kernel_commits` field — both
493 /// describe commit dimensions, so the prefix makes "which
494 /// repository's commit?" obvious at every call site.
495 pub project_commits: Vec<String>,
496 /// Repeatable kernel-source-commit filter, OR-combined: a row
497 /// matches iff its `GauntletRow::kernel_commit` equals ANY
498 /// entry. Empty vec disables the filter ("do not filter on
499 /// kernel commit"). A row whose `kernel_commit` is itself
500 /// `None` never matches a non-empty filter — same opt-in
501 /// semantic as `project_commits`.
502 ///
503 /// Distinct from `project_commits` (the ktstr framework commit)
504 /// and from `kernels` (the kernel release version): two runs
505 /// with the same `kernel_version` but different `kernel_commit`
506 /// values represent the same release rebuilt from different
507 /// trees (e.g. WIP patches on top, a different remote ref).
508 pub kernel_commits: Vec<String>,
509 /// Repeatable run-environment-source filter, OR-combined: a row
510 /// matches iff its `GauntletRow::run_source` equals ANY entry.
511 /// Empty vec disables the filter ("do not filter on
512 /// run_source"). A row whose `run_source` is itself `None`
513 /// (sidecar pre-dates the field) never matches a non-empty
514 /// filter — same opt-in semantic as `kernels` /
515 /// `project_commits` / `kernel_commits`.
516 /// Typical values: `"local"`, `"ci"`, `"archive"`. The schema
517 /// is open: any string is acceptable so a future producer can
518 /// introduce a new tag without a version bump.
519 ///
520 /// Field name `run_sources` (renamed from `sources`)
521 /// disambiguates from `KernelMetadata.source` /
522 /// [`crate::cache::KernelSource`] — those describe the kernel
523 /// build's input, this describes the run-environment provenance.
524 pub run_sources: Vec<String>,
525 /// Repeatable scheduler-resolution-source filter, OR-combined: a
526 /// row matches iff its `GauntletRow::resolve_source` equals ANY
527 /// entry. Empty vec disables the filter. A row whose
528 /// `resolve_source` is `None` (sidecar pre-dates the field, or a
529 /// skip resolved no binary) never matches a non-empty filter —
530 /// same opt-in semantic as `run_sources`. Values are the
531 /// [`crate::test_support::ResolveSource::as_str`] tags
532 /// (`"auto_built"`, `"target_debug"`, `"path"`, ...). Distinct from
533 /// `run_sources` (the run ENVIRONMENT): this is HOW the scheduler
534 /// binary was found. Backs the [`Dimension::ResolveSource`] filter +
535 /// pairing dim (`--resolve-source`).
536 pub resolve_sources: Vec<String>,
537 /// Repeatable cpu-budget filter, OR-combined: a row matches iff its
538 /// `GauntletRow::cpu_budget` (the effective host-CPU budget, as a
539 /// decimal string) equals ANY entry. Empty vec disables the filter.
540 /// Rows with `cpu_budget == None` (skips) are dropped when this filter
541 /// is non-empty, mirroring `kernels` / `run_sources`. Backs the
542 /// [`Dimension::CpuBudget`] filter + pairing dim (`--cpu-budget`).
543 pub cpu_budgets: Vec<String>,
544 /// Repeatable scheduler-name filter, OR-combined: a row matches
545 /// iff its `GauntletRow::scheduler` equals ANY entry. Empty vec
546 /// disables the filter ("do not filter on scheduler"). Strict
547 /// equality on each entry — the substring `-E` filter is the
548 /// only fuzzy-match knob; typed flags exact-match. Mirrors the
549 /// shape of `kernels` / `project_commits` / `kernel_commits` /
550 /// `run_sources` so every typed dimension supports the same
551 /// repeatable OR-combined idiom.
552 pub schedulers: Vec<String>,
553 /// Repeatable topology filter, OR-combined: a row matches iff
554 /// its `GauntletRow::topology` equals ANY entry. The filter
555 /// values are the rendered form (e.g. `"1n2l4c2t"`) that
556 /// `Topology::Display` emits and `cargo ktstr stats list`
557 /// shows. Empty vec disables the filter.
558 pub topologies: Vec<String>,
559 /// Repeatable work-type filter, OR-combined: a row matches iff
560 /// its `GauntletRow::work_type` equals ANY entry. Valid names
561 /// are the PascalCase variants of `WorkType::ALL_NAMES`. Empty
562 /// vec disables the filter.
563 pub work_types: Vec<String>,
564}
565
566impl RowFilter {
567 /// Returns true when every populated filter field matches the
568 /// row. The empty `RowFilter` (default) returns true for every
569 /// row — it's the identity filter.
570 pub fn matches(&self, row: &GauntletRow) -> bool {
571 if !self.kernels.is_empty() {
572 // OR-combined: the row matches iff its kernel version
573 // matches ANY listed kernel. A row with `None`
574 // kernel_version never satisfies a non-empty filter —
575 // same opt-in semantic the original `Option<String>`
576 // field carried.
577 //
578 // Match shape: a filter value with two dot-separated
579 // digit segments (e.g. `6.12`) is a major.minor PREFIX —
580 // the row matches if its `kernel_version` equals
581 // `6.12` exactly, starts with `6.12.` (patch releases
582 // including the `6.12.0-rcN+` kernel banner shape),
583 // or starts with `6.12-` (the no-patch `6.12-rcN`
584 // shape `kernel_path::KernelId::Version` admits). A
585 // filter with three or more segments (e.g. `6.14.2`,
586 // `6.15-rc3`) is strict equality. The two-segment
587 // cutoff matches the shape of `MAJOR.MINOR` versus
588 // `MAJOR.MINOR.PATCH` / `MAJOR.MINOR-rcN` — there is
589 // no shorter form on the sidecar producer side worth
590 // treating as a prefix (`6` alone would match every
591 // 6.x release, which is a less useful cohort than the
592 // per-stable-series narrowing the operator usually
593 // wants).
594 let row_kernel = row.kernel_version.as_deref();
595 let any = self.kernels.iter().any(|want| match row_kernel {
596 Some(rk) => kernel_filter_matches(want, rk),
597 None => false,
598 });
599 if !any {
600 return false;
601 }
602 }
603 if !self.project_commits.is_empty() {
604 // OR-combined match against `GauntletRow::commit`,
605 // mirroring the `kernels` policy: a row whose `commit`
606 // is `None` (the sidecar writer's gix probe failed or
607 // cwd was outside any git repo) never matches a
608 // populated filter, so a `--project-commit` argument is opt-in
609 // to "only rows with this commit" rather than a wildcard.
610 let row_commit = row.commit.as_deref();
611 let any = self
612 .project_commits
613 .iter()
614 .any(|want| row_commit == Some(want.as_str()));
615 if !any {
616 return false;
617 }
618 }
619 if !self.kernel_commits.is_empty() {
620 // OR-combined match against `GauntletRow::kernel_commit`,
621 // mirroring the `project_commits` policy: a row whose
622 // `kernel_commit` is `None` (the sidecar writer's
623 // `detect_kernel_commit` probe failed, or `KTSTR_KERNEL`
624 // pointed at a non-git source) never matches a populated
625 // filter — same opt-in semantic as `--project-commit` /
626 // `--kernel`.
627 let row_kc = row.kernel_commit.as_deref();
628 let any = self
629 .kernel_commits
630 .iter()
631 .any(|want| row_kc == Some(want.as_str()));
632 if !any {
633 return false;
634 }
635 }
636 if !self.run_sources.is_empty() {
637 // OR-combined match against `GauntletRow::run_source`,
638 // mirroring the `kernels` / `project_commits` /
639 // `kernel_commits` opt-in policy: a row whose
640 // `run_source` is `None` (sidecar pre-dates the field)
641 // never matches a populated filter, so a `--run-source`
642 // argument demands a tagged row rather than acting as a
643 // wildcard.
644 let row_run_source = row.run_source.as_deref();
645 let any = self
646 .run_sources
647 .iter()
648 .any(|want| row_run_source == Some(want.as_str()));
649 if !any {
650 return false;
651 }
652 }
653 if !self.resolve_sources.is_empty() {
654 // OR-combined match against `GauntletRow::resolve_source`,
655 // mirroring the `run_sources` opt-in policy: a row whose
656 // `resolve_source` is `None` (sidecar pre-dates the field,
657 // or a skip resolved no binary) never matches a populated
658 // filter, so a `--resolve-source` argument demands a tagged
659 // row rather than acting as a wildcard.
660 let row_resolve_source = row.resolve_source.as_deref();
661 let any = self
662 .resolve_sources
663 .iter()
664 .any(|want| row_resolve_source == Some(want.as_str()));
665 if !any {
666 return false;
667 }
668 }
669 if !self.cpu_budgets.is_empty() {
670 // OR-combined match against `GauntletRow::cpu_budget` rendered
671 // as a decimal string. A row with `cpu_budget == None` (skip)
672 // never matches a populated filter — same opt-in policy as
673 // `run_sources` / `kernels`.
674 let row_budget = row.cpu_budget.map(|n| n.to_string());
675 let any = self
676 .cpu_budgets
677 .iter()
678 .any(|want| row_budget.as_deref() == Some(want.as_str()));
679 if !any {
680 return false;
681 }
682 }
683 if !self.schedulers.is_empty() {
684 // OR-combined match against `GauntletRow::scheduler`
685 // (a `String`, never `None`). Strict equality on each
686 // entry — same shape as the other repeatable typed
687 // filters above.
688 let any = self.schedulers.contains(&row.scheduler);
689 if !any {
690 return false;
691 }
692 }
693 if !self.topologies.is_empty() {
694 // OR-combined match against `GauntletRow::topology`.
695 let any = self.topologies.contains(&row.topology);
696 if !any {
697 return false;
698 }
699 }
700 if !self.work_types.is_empty() {
701 // OR-combined match against `GauntletRow::work_type`.
702 let any = self.work_types.contains(&row.work_type);
703 if !any {
704 return false;
705 }
706 }
707 true
708 }
709}
710
711/// Drop rows from `rows` that do not match every populated filter
712/// field on `filter`. Returns the surviving rows in their original
713/// order. The caller is responsible for any further dedup or
714/// aggregation; this helper preserves duplicates as written.
715///
716/// Used by [`compare_partitions`] before the surviving rows reach
717/// [`compare_rows_by`], so the substring-`-E` filter and the typed
718/// filters compose: typed narrows happen first, substring runs over
719/// the surviving set.
720pub fn apply_row_filters(rows: &[GauntletRow], filter: &RowFilter) -> Vec<GauntletRow> {
721 rows.iter().filter(|r| filter.matches(r)).cloned().collect()
722}
723
724/// Match a single `--kernel` filter value against a row's
725/// `kernel_version`. Major.minor (two-segment) filter values match
726/// any patch release in that series via prefix; longer filter
727/// values use strict equality.
728///
729/// `want` is the user-supplied filter value (e.g. `6.12`,
730/// `6.14.2`, `6.15-rc3`). `row_kernel` is the sidecar-recorded
731/// kernel version (e.g. `6.12.5`). The two-segment cutoff matches
732/// the natural shape of `MAJOR.MINOR` versus
733/// `MAJOR.MINOR.PATCH` / `MAJOR.MINOR-rcN` — `6.12.` is a
734/// stable-series prefix; `6.14.2` is one specific release.
735///
736/// Examples:
737/// - `kernel_filter_matches("6.12", "6.12.5")` → true (prefix)
738/// - `kernel_filter_matches("6.12", "6.12")` → true (exact equal)
739/// - `kernel_filter_matches("6.12", "6.13.0")` → false
740/// - `kernel_filter_matches("6.14", "6.14-rc3")` → true (prefix
741/// admits the `-rcN` pre-release of the same series; per
742/// `kernel_path::decompose_version_for_compare`, `6.14-rc3`
743/// shares the `(major=6, minor=14, patch=0)` tuple with the
744/// `6.14` release, and the operator filtering on the series
745/// wants both)
746/// - `kernel_filter_matches("6.14", "6.14.0-rc3+")` → true
747/// (kernel banner shape — patch=0 plus `-rcN` plus `EXTRAVERSION`)
748/// - `kernel_filter_matches("6.14.2", "6.14.2")` → true
749/// - `kernel_filter_matches("6.14.2", "6.14.20")` → false
750/// (strict equality on three-segment filter — without the
751/// strict path, `6.14.2` would also match `6.14.20`,
752/// `6.14.21`, ..., which is not what the operator asked for)
753pub(crate) fn kernel_filter_matches(want: &str, row_kernel: &str) -> bool {
754 if is_major_minor_prefix(want) {
755 // Three accepted shapes for a major.minor (`MAJOR.MINOR`)
756 // prefix filter, all designed so the prefix is bounded by
757 // a non-digit separator that disambiguates the series:
758 //
759 // 1. Exact equal: `row_kernel == "6.14"`. The row's
760 // recorded version IS the major.minor string itself
761 // (no patch, no rc).
762 // 2. Trailing-dot prefix: `row_kernel.starts_with("6.14.")`.
763 // Covers patch releases (`6.14.0`, `6.14.5`) and
764 // kernel banner shapes (`6.14.0-rc3+`).
765 // 3. Trailing-dash prefix: `row_kernel.starts_with("6.14-")`.
766 // Covers the no-patch pre-release shape (`6.14-rc3`).
767 // Per `kernel_path` (KernelId::Version doc), this is a
768 // valid emitted shape; per
769 // `decompose_version_for_compare` it shares the
770 // `(major, minor, patch=0)` triple with the `6.14`
771 // release and the operator filtering on the series
772 // wants both.
773 //
774 // The non-digit separator after `want` (`.` or `-`)
775 // prevents `6.1` from spuriously matching `6.10.0` or
776 // `6.10-rc3` — both fail because the next character after
777 // `6.1` is `0`, which is neither separator. The `6.140`
778 // case is also rejected for the same reason.
779 row_kernel == want
780 || row_kernel.starts_with(&format!("{want}."))
781 || row_kernel.starts_with(&format!("{want}-"))
782 } else {
783 row_kernel == want
784 }
785}
786
787/// Whether a filter value looks like a major.minor PREFIX. Two
788/// non-empty dot-separated digit segments and nothing else
789/// (no `-rcN`, no third dot). Conservative: anything outside the
790/// `MAJOR.MINOR` shape falls through to strict equality so a typo
791/// like `6.14.2.` or `6.14-something` does not silently turn into
792/// a wildcard.
793fn is_major_minor_prefix(s: &str) -> bool {
794 let parts: Vec<&str> = s.split('.').collect();
795 parts.len() == 2
796 && parts
797 .iter()
798 .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
799}