Skip to main content

aver/bench/
manifest.rs

1//! Scenario manifest: TOML on disk, struct in memory.
2//!
3//! ```toml
4//! # bench/scenarios/factorial.toml
5//! name  = "factorial"             # optional, defaults to file stem
6//! entry = "/tmp/factorial.av"     # absolute or relative to manifest dir
7//! iterations = 50
8//! warmup     = 5
9//! args       = []                 # CLI args passed to `main`, default empty
10//!
11//! [expected]
12//! # response_bytes = 23           # exact match (optional)
13//! # response_bytes_min = 100      # range match (optional)
14//! # response_bytes_max = 200
15//! ```
16//!
17//! Keep the format dumb: TOML, flat, no env vars, no string templates.
18//! A scenario is meant to be the simplest reproducible thing that pins
19//! a measurement, not a generic test runner. If a scenario needs more
20//! than this, that's a sign to write a small Aver harness fn in the
21//! `entry` file rather than to grow this struct.
22
23use std::path::{Path, PathBuf};
24
25#[derive(Debug)]
26pub enum ManifestError {
27    Read(String),
28    Parse(String),
29    Validate(String),
30}
31
32impl std::fmt::Display for ManifestError {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        match self {
35            Self::Read(m) | Self::Parse(m) | Self::Validate(m) => f.write_str(m),
36        }
37    }
38}
39
40#[derive(Debug, Clone)]
41pub struct Manifest {
42    pub name: String,
43    /// Absolute path to the entry `.av` file.
44    pub entry: PathBuf,
45    pub iterations: usize,
46    pub warmup: usize,
47    pub args: Vec<String>,
48    pub expected: ExpectedShape,
49    pub tolerance: Tolerance,
50}
51
52/// Bench targets — picks which backend runs the scenario.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum BenchTarget {
55    /// In-process VM via `vm::compile_program_with_modules`.
56    Vm,
57    /// In-process WASM via wasmtime (compiled once, instantiated +
58    /// invoked per iteration). Requires the `wasm` feature.
59    WasmLocal,
60    /// Native Rust binary produced by `aver compile --target rust` +
61    /// `cargo build --release`. Subprocess spawn per iteration.
62    Rust,
63}
64
65impl BenchTarget {
66    pub fn parse(s: &str) -> Result<Self, String> {
67        match s {
68            "vm" => Ok(Self::Vm),
69            "wasm-local" => Ok(Self::WasmLocal),
70            "rust" => Ok(Self::Rust),
71            other => Err(format!(
72                "unknown bench target '{}'; expected one of: vm, wasm-local, rust",
73                other
74            )),
75        }
76    }
77
78    pub const fn name(self) -> &'static str {
79        match self {
80            Self::Vm => "vm",
81            Self::WasmLocal => "wasm-local",
82            Self::Rust => "rust",
83        }
84    }
85}
86
87/// Per-metric regression tolerances used by `--compare baseline.json`.
88/// Defaults are deliberately loose for 0.15.1 — the bench harness is new
89/// and machines vary. Tighten per-scenario in TOML once a baseline is
90/// stable on the target machine.
91#[derive(Debug, Clone, Copy)]
92pub struct Tolerance {
93    /// Maximum allowed `p50_ms` increase as a percentage of baseline.
94    /// Default 20.0 (i.e. +20% over baseline counts as a regression).
95    pub wall_time_p50_pct: f64,
96    /// Maximum allowed `p95_ms` increase, percentage. Default 30.0 —
97    /// p95 is noisier than p50 by nature, so the bar is looser.
98    pub wall_time_p95_pct: f64,
99}
100
101impl Default for Tolerance {
102    fn default() -> Self {
103        Self {
104            wall_time_p50_pct: 20.0,
105            wall_time_p95_pct: 30.0,
106        }
107    }
108}
109
110#[derive(Debug, Clone, Default)]
111pub struct ExpectedShape {
112    pub response_bytes: Option<usize>,
113    pub response_bytes_min: Option<usize>,
114    pub response_bytes_max: Option<usize>,
115}
116
117impl Manifest {
118    pub fn load(path: &Path) -> Result<Self, ManifestError> {
119        let raw = std::fs::read_to_string(path)
120            .map_err(|e| ManifestError::Read(format!("cannot read '{}': {}", path.display(), e)))?;
121        let table: toml::Value = raw.parse().map_err(|e: toml::de::Error| {
122            ManifestError::Parse(format!("{}: {}", path.display(), e))
123        })?;
124        let manifest_dir = path.parent().unwrap_or_else(|| Path::new("."));
125        Self::from_toml(&table, path, manifest_dir)
126    }
127
128    fn from_toml(
129        table: &toml::Value,
130        manifest_path: &Path,
131        manifest_dir: &Path,
132    ) -> Result<Self, ManifestError> {
133        let entry_raw = table.get("entry").and_then(|v| v.as_str()).ok_or_else(|| {
134            ManifestError::Validate(format!(
135                "{}: missing required `entry = \"...\"`",
136                manifest_path.display()
137            ))
138        })?;
139        let entry_path = Path::new(entry_raw);
140        let entry = if entry_path.is_absolute() {
141            entry_path.to_path_buf()
142        } else {
143            manifest_dir.join(entry_path)
144        };
145        if !entry.exists() {
146            return Err(ManifestError::Validate(format!(
147                "{}: entry '{}' does not exist (resolved to '{}')",
148                manifest_path.display(),
149                entry_raw,
150                entry.display()
151            )));
152        }
153
154        let name = table
155            .get("name")
156            .and_then(|v| v.as_str())
157            .map(|s| s.to_string())
158            .unwrap_or_else(|| {
159                manifest_path
160                    .file_stem()
161                    .and_then(|s| s.to_str())
162                    .unwrap_or("scenario")
163                    .to_string()
164            });
165
166        let iterations = table
167            .get("iterations")
168            .and_then(|v| v.as_integer())
169            .unwrap_or(10) as usize;
170        let warmup = table
171            .get("warmup")
172            .and_then(|v| v.as_integer())
173            .unwrap_or(1) as usize;
174
175        if iterations == 0 {
176            return Err(ManifestError::Validate(format!(
177                "{}: `iterations` must be > 0",
178                manifest_path.display()
179            )));
180        }
181
182        let args = table
183            .get("args")
184            .and_then(|v| v.as_array())
185            .map(|arr| {
186                arr.iter()
187                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
188                    .collect()
189            })
190            .unwrap_or_default();
191
192        let expected = match table.get("expected") {
193            Some(toml::Value::Table(t)) => ExpectedShape {
194                response_bytes: t
195                    .get("response_bytes")
196                    .and_then(|v| v.as_integer())
197                    .map(|n| n as usize),
198                response_bytes_min: t
199                    .get("response_bytes_min")
200                    .and_then(|v| v.as_integer())
201                    .map(|n| n as usize),
202                response_bytes_max: t
203                    .get("response_bytes_max")
204                    .and_then(|v| v.as_integer())
205                    .map(|n| n as usize),
206            },
207            _ => ExpectedShape::default(),
208        };
209
210        let tolerance = match table.get("tolerance") {
211            Some(toml::Value::Table(t)) => {
212                let mut tol = Tolerance::default();
213                if let Some(v) = t.get("wall_time_p50_pct").and_then(|v| v.as_float()) {
214                    tol.wall_time_p50_pct = v;
215                }
216                if let Some(v) = t.get("wall_time_p95_pct").and_then(|v| v.as_float()) {
217                    tol.wall_time_p95_pct = v;
218                }
219                tol
220            }
221            _ => Tolerance::default(),
222        };
223
224        Ok(Manifest {
225            name,
226            entry,
227            iterations,
228            warmup,
229            args,
230            expected,
231            tolerance,
232        })
233    }
234}