nornir 0.4.10

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! Benchmark harness — project-agnostic.
//!
//! Provides a uniform run/result envelope plus an append-only JSONL
//! history. Repo-specific metrics live in [`BenchResult::metrics`] as
//! a free-form JSON object, so one envelope serves any project.

pub mod api;
pub mod assets;
pub mod history;
pub mod legacy;
pub mod progress;
pub mod telemetry;

use serde::{Deserialize, Serialize};

/// A single benchmark result. Repo-specific metrics live in `metrics`
/// so both holger (`holger_ops_sec` / `nexus_ops_sec`) and znippy
/// (`compress_mbs` / `decompress_mbs` / `files`) can share the same
/// run envelope.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BenchResult {
    pub name: String,
    #[serde(flatten)]
    pub metrics: serde_json::Map<String, serde_json::Value>,
}

/// Pass/fail outcome of a single test that ran alongside the bench.
///
/// `tests` lives next to `results` on a [`BenchRun`] so the no-regression
/// gate can fail a release both for scalar drops *and* for any test
/// flipping red. Optional fields (`duration_ms`, `message`) carry the
/// extra detail when the runner provides it (criterion / `cargo test`
/// JSON output etc.).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestOutcome {
    pub name: String,
    pub passed: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub duration_ms: Option<f64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
}

/// One full bench run — one line in `bench_history.jsonl`.
///
/// Backward-compat fields:
/// - `version`, `machine`, `cores` are `#[serde(default)]` so legacy
///   entries (holger 2026-05-27 lines lacking machine/version/cores;
///   znippy lines lacking machine) deserialize cleanly. Going forward
///   all newly written runs must populate them — `history::append`
///   rejects empty `machine`.
/// - `date` (YYYY-MM-DD string) is retained for legacy compatibility.
///   New writers should also populate `timestamp` (RFC 3339, UTC) which
///   becomes the canonical time column in the Iceberg `bench_runs`
///   table once the warehouse phase lands.
/// - `tests` defaults to empty so existing JSONL parses; new runs are
///   expected to populate it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BenchRun {
    pub date: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub timestamp: Option<String>,
    #[serde(default)]
    pub version: String,
    #[serde(default)]
    pub machine: String,
    #[serde(default)]
    pub cores: u32,
    pub results: Vec<BenchResult>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tests: Vec<TestOutcome>,
}

impl BenchRun {
    pub fn find(&self, name: &str) -> Option<&BenchResult> {
        self.results.iter().find(|r| r.name == name)
    }

    /// True iff every test in `tests` passed. An empty `tests` vec is
    /// treated as "no tests run" → returns true.
    pub fn all_tests_passed(&self) -> bool {
        self.tests.iter().all(|t| t.passed)
    }

    /// Convenience: list the names of any failed tests.
    pub fn failed_tests(&self) -> Vec<&str> {
        self.tests
            .iter()
            .filter(|t| !t.passed)
            .map(|t| t.name.as_str())
            .collect()
    }
}

/// Which way is "better" for a metric — used by the `benches` doc renderer
/// to **bold the winning cell** in a comparison row (e.g. ours vs a legacy
/// tool). `High` = bigger is better (throughput), `Low` = smaller is better
/// (latency/time), `Neutral` = not a competition (counts, ratios) → never
/// bolded.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MetricDirection {
    High,
    Low,
    Neutral,
}

/// Resolve a metric's direction: an explicit `overrides` entry wins (the
/// `benches` renderer fills this from a `best=metric:low` marker arg); otherwise
/// fall back to the unit implied by the metric name (the `_mbs` / `_ms` / …
/// convention — see [`unit_of`]).
pub fn direction_of(
    overrides: &std::collections::HashMap<String, MetricDirection>,
    metric: &str,
) -> MetricDirection {
    if let Some(d) = overrides.get(metric) {
        return *d;
    }
    unit_of(metric).map(|u| u.direction).unwrap_or(MetricDirection::Neutral)
}

/// A recognised metric unit and its natural direction. Two metric cells are
/// "comparable" (compete in a row, eligible for bolding) iff they share the
/// same `unit.name`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Unit {
    pub name: &'static str,
    pub direction: MetricDirection,
}

/// Recognise the trailing unit of a metric key. Covers both the
/// `<corpus>_<suffix>` convention (`_mbs`/`_ms`/`_pct`) and the longhand
/// holger-style keys (`mb_per_sec`/`ops_per_sec`/`seconds`). Returns `None`
/// for plain counts (`bytes`, `ops`, `files`) — those never bold.
pub fn unit_of(metric: &str) -> Option<Unit> {
    use MetricDirection::*;
    let m = metric.to_ascii_lowercase();
    // (suffix, unit-name, direction) — order matters: longest/most-specific first.
    const TABLE: &[(&str, &str, MetricDirection)] = &[
        ("mb_per_sec", "mbs", High),
        ("mbps", "mbs", High),
        ("_mbs", "mbs", High),
        ("gb_per_sec", "gbs", High),
        ("_gbs", "gbs", High),
        ("ops_per_sec", "ops_sec", High),
        ("ops_sec", "ops_sec", High),
        ("_ops_sec", "ops_sec", High),
        // generic throughput (rows_per_sec, commits_per_sec, …) — more is better.
        ("_per_sec", "per_sec", High),
        ("seconds", "secs", Low),
        ("_secs", "secs", Low),
        // bare seconds columns (build_s, query_s, total_s) — less is better.
        ("_s", "secs", Low),
        ("_ms", "ms", Low),
        ("_us", "us", Low),
        ("_ns", "ns", Low),
        ("_pct", "pct", Neutral),
        ("_x", "x", Neutral),
        ("speedup", "x", Neutral),
    ];
    for (suffix, name, dir) in TABLE {
        if m == *suffix || m.ends_with(suffix) {
            return Some(Unit { name, direction: *dir });
        }
    }
    None
}

#[cfg(test)]
mod direction_tests {
    use super::*;
    use std::collections::HashMap;

    #[test]
    fn unit_and_direction_from_name() {
        assert_eq!(unit_of("ljar_mbs").unwrap().name, "mbs");
        assert_eq!(unit_of("unzip_mbs").unwrap().direction, MetricDirection::High);
        assert_eq!(unit_of("mb_per_sec").unwrap().name, "mbs");
        assert_eq!(unit_of("holger_ops_sec").unwrap().name, "ops_sec");
        assert_eq!(unit_of("decode_ms").unwrap().direction, MetricDirection::Low);
        assert_eq!(unit_of("seconds").unwrap().direction, MetricDirection::Low);
        assert_eq!(unit_of("speedup_x").unwrap().direction, MetricDirection::Neutral);
        // plain counts have no unit → never bolded
        assert!(unit_of("bytes").is_none());
        assert!(unit_of("files").is_none());
    }

    #[test]
    fn explicit_override_wins() {
        let mut o = HashMap::new();
        o.insert("weird_metric".to_string(), MetricDirection::Low);
        assert_eq!(direction_of(&o, "weird_metric"), MetricDirection::Low);
        // unlisted → suffix fallback
        assert_eq!(direction_of(&o, "ljar_mbs"), MetricDirection::High);
        assert_eq!(direction_of(&o, "count"), MetricDirection::Neutral);
    }
}