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}