Skip to main content

majordomus_cli/bench/
baseline.rs

1//! Accepted baselines and the regression policy. A baseline is a tracked result document
2//! of one platform (`os-arch-buildprofile`), promoted explicitly from a local run by
3//! `bench baseline update`; the policy is data in `.ai/repo/benchmarks/rust/policy.yaml`
4//! (the shell tool keeps its own evidence beside it, under `.ai/repo/benchmarks/`). A
5//! check compares each measurement of a run with the baseline's under the policy and
6//! reports every line; a target the baseline knows and the run does not is reported as
7//! stale, never silently attached to something else, and a baseline of another platform
8//! is not compared at all.
9//!
10//! A **budget** is the other half of the policy: an absolute bound on a metric of every
11//! target under a prefix (`budgets:` in the same file). A regression threshold asks whether
12//! a target got slower than it was on this host; a budget asks whether it is fast enough at
13//! all, which no baseline can answer, since a target that was always too slow never
14//! regresses. A budget therefore needs no baseline and is held on every host.
15
16use std::collections::BTreeMap;
17use std::path::PathBuf;
18
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21
22use crate::error::{Error, Result};
23use crate::metadata::yaml;
24use crate::repository::Repository;
25
26use super::results::{write_atomic, CacheMode, ResultDocument};
27
28/// Where the Rust executable's baselines and policy live, relative to the tracked half.
29pub const BASELINE_DIR: &str = "benchmarks/rust";
30
31/// The policy file's name.
32pub const POLICY_FILE: &str = "policy.yaml";
33
34/// One threshold: a relative increase allowed on a metric.
35#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
36pub struct Threshold {
37    /// The allowed relative increase (`0.25` is a quarter).
38    pub relative: f64,
39    /// The metric gates only when the run took at least this many samples; under it the
40    /// comparison is reported as `SHORT` and does not fail. A p99 of twenty samples is the
41    /// slowest sample, which one scheduler hiccup decides.
42    #[serde(default)]
43    pub minimum_samples: usize,
44}
45
46/// The regression policy.
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
48pub struct Policy {
49    /// Thresholds by metric (`p50`, `p95`, `p99`).
50    pub regression: BTreeMap<String, Threshold>,
51    /// Increases under this many microseconds are noise, whatever the ratio.
52    pub minimum_absolute_us: f64,
53    /// Per-target allowances, keyed by the target key as `bench coverage` prints it, then
54    /// by metric (in the file, a `targets:` list of `target: <key>` items); a metric not
55    /// named here keeps the general threshold. The process-cold
56    /// target spawns a process and builds the index, so the machine's load decides most of
57    /// its wall clock, and it gets more room than a request does.
58    #[serde(default)]
59    pub targets: BTreeMap<String, BTreeMap<String, Threshold>>,
60    /// Absolute bounds, held with or without a baseline and on every host. The default
61    /// policy has none: a bound is the repository's to declare, never this crate's.
62    #[serde(default)]
63    pub budgets: Vec<Budget>,
64}
65
66/// An absolute bound on one metric of every target whose key starts with a prefix.
67///
68/// ```
69/// use majordomus_cli::bench::baseline::Budget;
70/// use majordomus_cli::bench::results::CacheMode;
71///
72/// // "a warm screen of the UI answers within 100 ms at p95", as the policy file states it
73/// let budget = Budget {
74///     prefix: "system.http.live.ui.".into(),
75///     cache: CacheMode::Warm,
76///     metric: "p95".into(),
77///     microseconds: 100_000.0,
78///     minimum_samples: 20,
79/// };
80/// assert!("system.http.live.ui.roadmap".starts_with(&budget.prefix));
81/// assert!(!"system.http.cockpit_overview".starts_with(&budget.prefix));
82/// ```
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
84pub struct Budget {
85    /// The targets it bounds: every key that starts with this (`system.http.live.ui.`).
86    pub prefix: String,
87    /// The cache mode it bounds; a measurement in another mode is not judged by it.
88    pub cache: CacheMode,
89    /// The metric (`p50`, `p95`, `p99`, ...).
90    pub metric: String,
91    /// The bound, in microseconds.
92    pub microseconds: f64,
93    /// The bound holds only when the run took at least this many samples; under it the
94    /// line is reported as `SHORT` and does not fail.
95    #[serde(default)]
96    pub minimum_samples: usize,
97}
98
99impl Default for Policy {
100    fn default() -> Self {
101        Policy {
102            regression: [
103                (
104                    "p50".to_string(),
105                    Threshold {
106                        relative: 0.25,
107                        minimum_samples: 0,
108                    },
109                ),
110                (
111                    "p95".to_string(),
112                    Threshold {
113                        relative: 0.30,
114                        minimum_samples: 50,
115                    },
116                ),
117                (
118                    "p99".to_string(),
119                    Threshold {
120                        relative: 0.50,
121                        minimum_samples: 200,
122                    },
123                ),
124            ]
125            .into_iter()
126            .collect(),
127            minimum_absolute_us: 1000.0,
128            targets: BTreeMap::new(),
129            budgets: Vec::new(),
130        }
131    }
132}
133
134impl Policy {
135    /// The policy of a repository: `.ai/repo/benchmarks/rust/policy.yaml`, or the default when
136    /// the file does not exist.
137    pub fn load(repo: &Repository) -> Result<Self> {
138        let path = policy_path(repo);
139        if !path.exists() {
140            return Ok(Policy::default());
141        }
142        let text = std::fs::read_to_string(&path).map_err(|e| Error::io(&path, e))?;
143        let map = yaml::parse_mapping(&text).map_err(|reason| Error::InvalidManifest {
144            path: path.clone(),
145            reason,
146        })?;
147        let v = serde_json::Value::Object(map);
148        let mut policy = Policy::default();
149        if let Some(reg) = v.get("regression").and_then(|r| r.as_object()) {
150            policy.regression.clear();
151            for (metric, t) in reg {
152                let threshold = parse_threshold(t, &path, &format!("regression.{metric}"), 0)?;
153                policy.regression.insert(metric.clone(), threshold);
154            }
155        }
156        if let Some(targets) = v.get("targets").and_then(|r| r.as_array()) {
157            for (i, item) in targets.iter().enumerate() {
158                let Some(fields) = item.as_object() else {
159                    return Err(Error::InvalidManifest {
160                        path: path.clone(),
161                        reason: format!("targets[{i}] is not a mapping"),
162                    });
163                };
164                let Some(key) = fields.get("target").and_then(|k| k.as_str()) else {
165                    return Err(Error::InvalidManifest {
166                        path: path.clone(),
167                        reason: format!("targets[{i}] has no `target` key"),
168                    });
169                };
170                let mut per_metric = BTreeMap::new();
171                for (metric, t) in fields.iter().filter(|(k, _)| k.as_str() != "target") {
172                    let inherited = policy
173                        .regression
174                        .get(metric)
175                        .map(|b| b.minimum_samples)
176                        .unwrap_or(0);
177                    let threshold =
178                        parse_threshold(t, &path, &format!("targets.{key}.{metric}"), inherited)?;
179                    per_metric.insert(metric.clone(), threshold);
180                }
181                policy.targets.insert(key.to_string(), per_metric);
182            }
183        }
184        if let Some(budgets) = v.get("budgets").and_then(|r| r.as_array()) {
185            for (i, item) in budgets.iter().enumerate() {
186                policy.budgets.push(parse_budget(item, &path, i)?);
187            }
188        }
189        if let Some(m) = v.get("minimum_absolute_us") {
190            policy.minimum_absolute_us = m
191                .as_str()
192                .and_then(|s| s.parse::<f64>().ok())
193                .or_else(|| m.as_f64())
194                .ok_or_else(|| Error::InvalidManifest {
195                    path: path.clone(),
196                    reason: "minimum_absolute_us is not a number".into(),
197                })?;
198        }
199        Ok(policy)
200    }
201
202    /// The threshold that applies to one metric of one target: the target's own when the
203    /// policy names it, the general one otherwise.
204    pub fn threshold_for(&self, key: &str, metric: &str) -> Option<&Threshold> {
205        self.targets
206            .get(key)
207            .and_then(|m| m.get(metric))
208            .or_else(|| self.regression.get(metric))
209    }
210}
211
212/// One `{relative, minimum_samples}` mapping; `minimum_samples` falls back to `inherited`.
213fn parse_threshold(
214    t: &serde_json::Value,
215    path: &std::path::Path,
216    at: &str,
217    inherited: usize,
218) -> Result<Threshold> {
219    let relative = t
220        .get("relative")
221        .and_then(|x| {
222            x.as_str()
223                .and_then(|s| s.parse::<f64>().ok())
224                .or_else(|| x.as_f64())
225        })
226        .ok_or_else(|| Error::InvalidManifest {
227            path: path.to_path_buf(),
228            reason: format!("{at}.relative is not a number"),
229        })?;
230    let minimum_samples = match t.get("minimum_samples") {
231        None => inherited,
232        Some(x) => x
233            .as_str()
234            .and_then(|s| s.parse::<usize>().ok())
235            .or_else(|| x.as_u64().map(|n| n as usize))
236            .ok_or_else(|| Error::InvalidManifest {
237                path: path.to_path_buf(),
238                reason: format!("{at}.minimum_samples is not a whole number"),
239            })?,
240    };
241    Ok(Threshold {
242        relative,
243        minimum_samples,
244    })
245}
246
247/// One item of `budgets:`: `prefix`, `cache`, `metric` and `milliseconds`, with an optional
248/// `minimum_samples`. Every key but the last is required: a budget with a default written
249/// here would be a bound the repository never declared.
250fn parse_budget(item: &serde_json::Value, path: &std::path::Path, i: usize) -> Result<Budget> {
251    let invalid = |reason: String| Error::InvalidManifest {
252        path: path.to_path_buf(),
253        reason,
254    };
255    let text = |key: &str| {
256        item.get(key)
257            .and_then(|x| x.as_str())
258            .filter(|s| !s.is_empty())
259            .ok_or_else(|| invalid(format!("budgets[{i}].{key} is missing")))
260    };
261    let prefix = text("prefix")?.to_string();
262    let metric = text("metric")?.to_string();
263    let cache = match text("cache")? {
264        "warm" => CacheMode::Warm,
265        "cold" => CacheMode::Cold,
266        "uncached" => CacheMode::Uncached,
267        "not_applicable" => CacheMode::NotApplicable,
268        other => {
269            return Err(invalid(format!(
270                "budgets[{i}].cache is `{other}`, not one of warm, cold, uncached, not_applicable"
271            )))
272        }
273    };
274    let number = |key: &str| {
275        item.get(key).and_then(|x| {
276            x.as_str()
277                .and_then(|s| s.parse::<f64>().ok())
278                .or_else(|| x.as_f64())
279        })
280    };
281    let milliseconds = number("milliseconds")
282        .filter(|ms| *ms > 0.0)
283        .ok_or_else(|| {
284            invalid(format!(
285                "budgets[{i}].milliseconds is not a positive number"
286            ))
287        })?;
288    let minimum_samples = match item.get("minimum_samples") {
289        None => 0,
290        Some(_) => number("minimum_samples")
291            .filter(|n| *n >= 0.0 && n.fract() == 0.0)
292            .map(|n| n as usize)
293            .ok_or_else(|| {
294                invalid(format!(
295                    "budgets[{i}].minimum_samples is not a whole number"
296                ))
297            })?,
298    };
299    Ok(Budget {
300        prefix,
301        cache,
302        metric,
303        microseconds: milliseconds * 1000.0,
304        minimum_samples,
305    })
306}
307
308/// `.ai/repo/benchmarks/rust/policy.yaml`.
309pub fn policy_path(repo: &Repository) -> PathBuf {
310    repo.root()
311        .join(repo.repo_path())
312        .join(BASELINE_DIR)
313        .join(POLICY_FILE)
314}
315
316/// `.ai/repo/benchmarks/rust/baseline.<platform>.json`.
317pub fn baseline_path(repo: &Repository, platform: &str) -> PathBuf {
318    policy_path(repo)
319        .parent()
320        .map(|d| d.join(format!("baseline.{platform}.json")))
321        .unwrap_or_default()
322}
323
324/// One line of a check.
325#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
326pub struct CheckLine {
327    /// The target's key.
328    pub key: String,
329    /// The cache mode.
330    pub cache_mode: CacheMode,
331    /// The metric.
332    pub metric: String,
333    /// The baseline's value, microseconds.
334    pub baseline_us: f64,
335    /// The run's value, microseconds.
336    pub current_us: f64,
337    /// `current / baseline - 1`.
338    pub delta: f64,
339    /// The allowed relative increase.
340    pub allowed: f64,
341    /// How many samples the run took of this target and mode.
342    #[serde(default)]
343    pub samples: usize,
344    /// How many of them are certainly at or above the run's value of the metric
345    /// ([`super::Statistics::samples_at_or_above`]): the samples a `FAIL` rests on.
346    #[serde(default)]
347    pub agreeing: usize,
348    /// `PASS`, `FAIL`, `NOISE` (the increase is under the absolute floor), `SHORT` (the
349    /// run took fewer samples than the metric's `minimum_samples`), or `WARN` (over the
350    /// threshold on fewer than two agreeing samples: one cold sample on a loaded runner is
351    /// a reason to re-measure, not a regression, and it does not fail).
352    pub verdict: String,
353}
354
355/// The fewest agreeing samples a `FAIL` needs: one sample over a threshold is a `WARN`.
356pub(crate) const AGREEING_SAMPLES: usize = 2;
357
358/// One measurement held against a budget.
359///
360/// ```
361/// use majordomus_cli::bench::baseline::BudgetLine;
362/// use majordomus_cli::bench::results::CacheMode;
363///
364/// let line = BudgetLine {
365///     key: "system.http.live.ui.command".into(),
366///     cache_mode: CacheMode::Warm,
367///     metric: "p95".into(),
368///     current_us: 620_000.0,
369///     budget_us: 100_000.0,
370///     samples: 20,
371///     verdict: "OVER".into(),
372/// };
373/// // the line says what a reader needs to act: which screen, how slow, against what bound
374/// assert!(line.current_us > line.budget_us && line.verdict == "OVER");
375/// ```
376#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
377pub struct BudgetLine {
378    /// The target's key.
379    pub key: String,
380    /// The cache mode.
381    pub cache_mode: CacheMode,
382    /// The metric.
383    pub metric: String,
384    /// The run's value, microseconds.
385    pub current_us: f64,
386    /// The bound, microseconds.
387    pub budget_us: f64,
388    /// How many samples the run took of this target and mode.
389    #[serde(default)]
390    pub samples: usize,
391    /// `PASS`, `OVER` (the value is above the bound), or `SHORT` (the run took fewer
392    /// samples than the budget's `minimum_samples`).
393    pub verdict: String,
394}
395
396/// The outcome of a check.
397#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
398pub struct Check {
399    /// The platform compared.
400    pub platform: String,
401    /// Was a baseline for this platform found?
402    pub baseline_found: bool,
403    /// Every comparison.
404    pub lines: Vec<CheckLine>,
405    /// Targets the run measured and the baseline lacks.
406    pub new_targets: Vec<String>,
407    /// Targets the baseline has and the run did not measure (renamed, removed, or filtered).
408    pub stale_baseline_targets: Vec<String>,
409    /// Did the registry fingerprint change since the baseline?
410    pub registry_changed: bool,
411    /// The host the baseline was recorded on (empty when unknown).
412    pub baseline_host: String,
413    /// The host this run measured on.
414    pub current_host: String,
415    /// The 1-minute load average this run was measured under (empty when unknown).
416    #[serde(default)]
417    pub current_load: String,
418    /// The 1-minute load average the baseline was recorded under (empty when unknown).
419    #[serde(default)]
420    pub baseline_load: String,
421    /// Was the baseline recorded on this host? When not, every line is reported and none
422    /// fails: wall-clock numbers of two machines are not a regression of either.
423    pub comparable: bool,
424    /// Every measurement a budget of the policy bounds, with its verdict. Judged with or
425    /// without a baseline, and on every host: a bound is absolute.
426    #[serde(default)]
427    pub budgets: Vec<BudgetLine>,
428}
429
430impl Check {
431    /// Compare a run with its platform's baseline under the policy.
432    pub fn compare(
433        run: &ResultDocument,
434        baseline: Option<&ResultDocument>,
435        policy: &Policy,
436    ) -> Self {
437        let platform = run.provenance.platform();
438        let budgets = Self::budgets(run, policy);
439        let Some(base) = baseline else {
440            return Check {
441                platform,
442                baseline_found: false,
443                lines: Vec::new(),
444                new_targets: run.results.iter().map(|r| r.key.clone()).collect(),
445                stale_baseline_targets: Vec::new(),
446                registry_changed: false,
447                baseline_host: String::new(),
448                current_host: run.provenance.host.clone(),
449                current_load: run.provenance.load.clone(),
450                baseline_load: String::new(),
451                comparable: false,
452                budgets,
453            };
454        };
455        let mut lines = Vec::new();
456        let mut new_targets = Vec::new();
457        for r in &run.results {
458            let Some(b) = base.find(&r.key, r.cache_mode) else {
459                if !new_targets.contains(&r.key) {
460                    new_targets.push(r.key.clone());
461                }
462                continue;
463            };
464            for metric in policy.regression.keys() {
465                let Some(threshold) = policy.threshold_for(&r.key, metric) else {
466                    continue;
467                };
468                let (Some(cur), Some(bas)) = (r.stats.metric(metric), b.stats.metric(metric))
469                else {
470                    continue;
471                };
472                let delta = if bas > 0.0 { cur / bas - 1.0 } else { 0.0 };
473                let agreeing = r.stats.samples_at_or_above(metric).unwrap_or(0);
474                let verdict = if r.stats.samples < threshold.minimum_samples {
475                    "SHORT"
476                } else if cur - bas < policy.minimum_absolute_us {
477                    "NOISE"
478                } else if delta > threshold.relative && agreeing < AGREEING_SAMPLES {
479                    "WARN"
480                } else if delta > threshold.relative {
481                    "FAIL"
482                } else {
483                    "PASS"
484                };
485                lines.push(CheckLine {
486                    key: r.key.clone(),
487                    cache_mode: r.cache_mode,
488                    metric: metric.clone(),
489                    baseline_us: bas,
490                    current_us: cur,
491                    delta,
492                    allowed: threshold.relative,
493                    samples: r.stats.samples,
494                    agreeing,
495                    verdict: verdict.into(),
496                });
497            }
498        }
499        let measured: std::collections::BTreeSet<&str> =
500            run.results.iter().map(|r| r.key.as_str()).collect();
501        let mut stale: Vec<String> = base
502            .results
503            .iter()
504            .filter(|b| !measured.contains(b.key.as_str()))
505            .map(|b| b.key.clone())
506            .collect();
507        crate::order::canonical(&mut stale);
508        stale.dedup();
509        Check {
510            platform,
511            baseline_found: true,
512            lines,
513            new_targets,
514            stale_baseline_targets: stale,
515            registry_changed: base.provenance.registry_fingerprint
516                != run.provenance.registry_fingerprint,
517            baseline_host: base.provenance.host.clone(),
518            current_host: run.provenance.host.clone(),
519            current_load: run.provenance.load.clone(),
520            baseline_load: base.provenance.load.clone(),
521            comparable: run.provenance.same_host(&base.provenance),
522            budgets,
523        }
524    }
525
526    /// Every measurement of `run` that a budget of the policy bounds, in the run's order.
527    fn budgets(run: &ResultDocument, policy: &Policy) -> Vec<BudgetLine> {
528        let mut lines = Vec::new();
529        for r in &run.results {
530            for b in &policy.budgets {
531                if r.cache_mode != b.cache || !r.key.starts_with(&b.prefix) {
532                    continue;
533                }
534                let Some(current) = r.stats.metric(&b.metric) else {
535                    continue;
536                };
537                let verdict = if r.stats.samples < b.minimum_samples {
538                    "SHORT"
539                } else if current > b.microseconds {
540                    "OVER"
541                } else {
542                    "PASS"
543                };
544                lines.push(BudgetLine {
545                    key: r.key.clone(),
546                    cache_mode: r.cache_mode,
547                    metric: b.metric.clone(),
548                    current_us: current,
549                    budget_us: b.microseconds,
550                    samples: r.stats.samples,
551                    verdict: verdict.into(),
552                });
553            }
554        }
555        lines
556    }
557
558    /// Is any measurement over its budget? Unlike a regression, this needs no comparable
559    /// baseline.
560    ///
561    /// ```
562    /// use majordomus_cli::bench::baseline::{Check, Policy};
563    /// use majordomus_cli::bench::results::{Provenance, ResultDocument};
564    ///
565    /// // a run that measured nothing a budget bounds is over none, with or without a baseline
566    /// let run = ResultDocument {
567    ///     schema: majordomus_cli::bench::RESULT_SCHEMA.into(),
568    ///     finished_at: "2026-10-12T00:00:00Z".into(),
569    ///     profile: "ci".into(),
570    ///     provenance: Provenance {
571    ///         commit: None,
572    ///         dirty: false,
573    ///         build_profile: "debug".into(),
574    ///         os: "os".into(),
575    ///         arch: "arch".into(),
576    ///         version: "0".into(),
577    ///         registry_fingerprint: "f".into(),
578    ///         host: "host/1".into(),
579    ///         load: String::new(),
580    ///     },
581    ///     results: Vec::new(),
582    /// };
583    /// let check = Check::compare(&run, None, &Policy::default());
584    /// assert!(check.budgets.is_empty());
585    /// assert!(!check.over_budget() && !check.failed());
586    /// ```
587    pub fn over_budget(&self) -> bool {
588        self.budgets.iter().any(|l| l.verdict == "OVER")
589    }
590
591    /// The budget section of the human report: one line per bounded measurement, then the
592    /// verdict. Empty when the policy bounds nothing the run measured.
593    fn render_budgets(&self) -> String {
594        let mut s = String::new();
595        if self.budgets.is_empty() {
596            return s;
597        }
598        s.push_str(&format!(
599            "{:<52} {:<6} {:>5} {:>12} {:>12} {:>5}  budget\n",
600            "target", "cache", "metric", "current_us", "budget_us", "n"
601        ));
602        for l in &self.budgets {
603            s.push_str(&format!(
604                "{:<52} {:<6} {:>5} {:>12.1} {:>12.1} {:>5}  {}\n",
605                l.key,
606                format!("{:?}", l.cache_mode).to_lowercase(),
607                l.metric,
608                l.current_us,
609                l.budget_us,
610                l.samples,
611                l.verdict
612            ));
613        }
614        s.push_str(&format!(
615            "bench --check: {} ({})\n",
616            if self.over_budget() {
617                "over budget"
618            } else {
619                "within budget"
620            },
621            self.measured_under()
622        ));
623        s
624    }
625
626    /// What every verdict of this check was measured under: the runner and its 1-minute
627    /// load average, and the baseline's when there is one. A timing verdict is a statement
628    /// about a machine as much as about a change, so the report never gives one without it.
629    ///
630    /// ```
631    /// use majordomus_cli::bench::baseline::{Check, Policy};
632    /// use majordomus_cli::bench::results::{Provenance, ResultDocument};
633    ///
634    /// let run = ResultDocument {
635    ///     schema: majordomus_cli::bench::RESULT_SCHEMA.into(),
636    ///     finished_at: "2026-10-16T00:00:00Z".into(),
637    ///     profile: "ci".into(),
638    ///     provenance: Provenance {
639    ///         commit: None,
640    ///         dirty: false,
641    ///         build_profile: "debug".into(),
642    ///         os: "os".into(),
643    ///         arch: "arch".into(),
644    ///         version: "0".into(),
645    ///         registry_fingerprint: "f".into(),
646    ///         host: "runner/4".into(),
647    ///         load: "31.70".into(),
648    ///     },
649    ///     results: Vec::new(),
650    /// };
651    /// let check = Check::compare(&run, None, &Policy::default());
652    /// assert_eq!(check.measured_under(), "runner runner/4, load 31.70");
653    /// ```
654    pub fn measured_under(&self) -> String {
655        let or = |v: &str, absent: &str| {
656            if v.is_empty() {
657                absent.to_string()
658            } else {
659                v.to_string()
660            }
661        };
662        let mut s = format!(
663            "runner {}, load {}",
664            or(&self.current_host, "unidentified"),
665            or(&self.current_load, "unknown")
666        );
667        if self.baseline_found {
668            s.push_str(&format!(
669                "; baseline recorded on {}, load {}",
670                or(&self.baseline_host, "an unidentified host"),
671                or(&self.baseline_load, "unknown")
672            ));
673        }
674        s
675    }
676
677    /// Anything over its budget, or any `FAIL` on a comparable baseline? A baseline from
678    /// another host never fails a run: its lines are reported only. A budget is absolute,
679    /// and fails on any host.
680    pub fn failed(&self) -> bool {
681        self.over_budget() || (self.comparable && self.lines.iter().any(|l| l.verdict == "FAIL"))
682    }
683
684    /// The human report.
685    pub fn render(&self) -> String {
686        let mut s = self.render_budgets();
687        if !self.baseline_found {
688            s.push_str(&format!(
689                "no baseline for {}: nothing compared; `majordomus bench baseline update` records one ({})\n",
690                self.platform,
691                self.measured_under()
692            ));
693            return s;
694        }
695        s.push_str(&format!(
696            "{:<52} {:<6} {:>5} {:>12} {:>12} {:>8} {:>8} {:>9}  verdict\n",
697            "target", "cache", "metric", "baseline_us", "current_us", "delta", "allowed", "agree/n"
698        ));
699        for l in &self.lines {
700            s.push_str(&format!(
701                "{:<52} {:<6} {:>5} {:>12.1} {:>12.1} {:>+7.0}% {:>+7.0}% {:>9}  {}\n",
702                l.key,
703                format!("{:?}", l.cache_mode).to_lowercase(),
704                l.metric,
705                l.baseline_us,
706                l.current_us,
707                l.delta * 100.0,
708                l.allowed * 100.0,
709                format!("{}/{}", l.agreeing, l.samples),
710                l.verdict
711            ));
712        }
713        let warned = self.lines.iter().filter(|l| l.verdict == "WARN").count();
714        if warned > 0 {
715            s.push_str(&format!(
716                "WARN   {warned} metric(s) over the threshold on fewer than {AGREEING_SAMPLES} agreeing samples: re-measure with more samples; not a regression until they agree\n"
717            ));
718        }
719        for k in &self.new_targets {
720            s.push_str(&format!("NEW    {k} (not in the baseline)\n"));
721        }
722        for k in &self.stale_baseline_targets {
723            s.push_str(&format!(
724                "STALE  {k} (in the baseline, not measured; renamed, removed or filtered)\n"
725            ));
726        }
727        if self.registry_changed {
728            s.push_str("NOTE   the registry fingerprint differs from the baseline's: the repository or the descriptors changed\n");
729        }
730        if !self.comparable {
731            let recorded = if self.baseline_host.is_empty() {
732                "an unidentified host".to_string()
733            } else {
734                self.baseline_host.clone()
735            };
736            s.push_str(&format!(
737                "NOTE   baseline recorded on {recorded}; this host is {}; reporting only, nothing fails\n",
738                self.current_host
739            ));
740            s.push_str(&format!(
741                "bench --check: reporting only (the baseline is from another host; {})\n",
742                self.measured_under()
743            ));
744            return s;
745        }
746        s.push_str(&format!(
747            "bench --check: {} ({})\n",
748            if self.failed() {
749                "regression(s) found"
750            } else {
751                "within policy"
752            },
753            self.measured_under()
754        ));
755        s
756    }
757}
758
759/// Read a baseline, when there is one for the platform.
760pub fn load_baseline(repo: &Repository, platform: &str) -> Result<Option<ResultDocument>> {
761    let path = baseline_path(repo, platform);
762    if !path.exists() {
763        return Ok(None);
764    }
765    let text = std::fs::read_to_string(&path).map_err(|e| Error::io(&path, e))?;
766    let doc: ResultDocument = serde_json::from_str(&text).map_err(|e| Error::InvalidManifest {
767        path,
768        reason: format!("not a benchmark result document: {e}"),
769    })?;
770    Ok(Some(doc))
771}
772
773/// Promote a run to the baseline of its platform. The tracked copy carries no
774/// machine-local path; it is the run's document as it is.
775pub fn write_baseline(repo: &Repository, run: &ResultDocument) -> Result<PathBuf> {
776    let path = baseline_path(repo, &run.provenance.platform());
777    if let Some(dir) = path.parent() {
778        std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
779    }
780    write_atomic(&path, &run.render())?;
781    Ok(path)
782}