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    /// EXPERIMENTAL (0.15.3+ probe). In-process wasm-gc backend via
64    /// wasmtime with the GC + tail-call proposals enabled. Compiles
65    /// once with `aver compile --target wasm-gc`, instantiates +
66    /// invokes per iteration. Same pipeline as `wasm-local`; the
67    /// difference is the codegen backend that produced the bytes.
68    WasmGc,
69}
70
71impl BenchTarget {
72    pub fn parse(s: &str) -> Result<Self, String> {
73        match s {
74            "vm" => Ok(Self::Vm),
75            "wasm-local" => Ok(Self::WasmLocal),
76            "wasm-gc" => Ok(Self::WasmGc),
77            "rust" => Ok(Self::Rust),
78            other => Err(format!(
79                "unknown bench target '{}'; expected one of: vm, wasm-local, wasm-gc, rust",
80                other
81            )),
82        }
83    }
84
85    pub const fn name(self) -> &'static str {
86        match self {
87            Self::Vm => "vm",
88            Self::WasmLocal => "wasm-local",
89            Self::WasmGc => "wasm-gc",
90            Self::Rust => "rust",
91        }
92    }
93}
94
95/// Per-metric regression tolerances used by `--compare baseline.json`.
96/// Defaults are deliberately loose for 0.15.1 — the bench harness is new
97/// and machines vary. Tighten per-scenario in TOML once a baseline is
98/// stable on the target machine.
99#[derive(Debug, Clone, Copy)]
100pub struct Tolerance {
101    /// Maximum allowed `p50_ms` increase as a percentage of baseline.
102    /// Default 20.0 (i.e. +20% over baseline counts as a regression).
103    pub wall_time_p50_pct: f64,
104    /// Maximum allowed `p95_ms` increase, percentage. Default 30.0 —
105    /// p95 is noisier than p50 by nature, so the bar is looser.
106    pub wall_time_p95_pct: f64,
107}
108
109impl Default for Tolerance {
110    fn default() -> Self {
111        Self {
112            wall_time_p50_pct: 20.0,
113            wall_time_p95_pct: 30.0,
114        }
115    }
116}
117
118#[derive(Debug, Clone, Default)]
119pub struct ExpectedShape {
120    pub response_bytes: Option<usize>,
121    pub response_bytes_min: Option<usize>,
122    pub response_bytes_max: Option<usize>,
123}
124
125impl Manifest {
126    pub fn load(path: &Path) -> Result<Self, ManifestError> {
127        let raw = std::fs::read_to_string(path)
128            .map_err(|e| ManifestError::Read(format!("cannot read '{}': {}", path.display(), e)))?;
129        let table: toml::Value = raw.parse().map_err(|e: toml::de::Error| {
130            ManifestError::Parse(format!("{}: {}", path.display(), e))
131        })?;
132        let manifest_dir = path.parent().unwrap_or_else(|| Path::new("."));
133        Self::from_toml(&table, path, manifest_dir)
134    }
135
136    fn from_toml(
137        table: &toml::Value,
138        manifest_path: &Path,
139        manifest_dir: &Path,
140    ) -> Result<Self, ManifestError> {
141        let entry_raw = table.get("entry").and_then(|v| v.as_str()).ok_or_else(|| {
142            ManifestError::Validate(format!(
143                "{}: missing required `entry = \"...\"`",
144                manifest_path.display()
145            ))
146        })?;
147        let entry_path = Path::new(entry_raw);
148        let entry = if entry_path.is_absolute() {
149            entry_path.to_path_buf()
150        } else {
151            manifest_dir.join(entry_path)
152        };
153        if !entry.exists() {
154            return Err(ManifestError::Validate(format!(
155                "{}: entry '{}' does not exist (resolved to '{}')",
156                manifest_path.display(),
157                entry_raw,
158                entry.display()
159            )));
160        }
161
162        let name = table
163            .get("name")
164            .and_then(|v| v.as_str())
165            .map(|s| s.to_string())
166            .unwrap_or_else(|| {
167                manifest_path
168                    .file_stem()
169                    .and_then(|s| s.to_str())
170                    .unwrap_or("scenario")
171                    .to_string()
172            });
173
174        let iterations = table
175            .get("iterations")
176            .and_then(|v| v.as_integer())
177            .unwrap_or(10) as usize;
178        let warmup = table
179            .get("warmup")
180            .and_then(|v| v.as_integer())
181            .unwrap_or(1) as usize;
182
183        if iterations == 0 {
184            return Err(ManifestError::Validate(format!(
185                "{}: `iterations` must be > 0",
186                manifest_path.display()
187            )));
188        }
189
190        let args = table
191            .get("args")
192            .and_then(|v| v.as_array())
193            .map(|arr| {
194                arr.iter()
195                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
196                    .collect()
197            })
198            .unwrap_or_default();
199
200        let expected = match table.get("expected") {
201            Some(toml::Value::Table(t)) => ExpectedShape {
202                response_bytes: t
203                    .get("response_bytes")
204                    .and_then(|v| v.as_integer())
205                    .map(|n| n as usize),
206                response_bytes_min: t
207                    .get("response_bytes_min")
208                    .and_then(|v| v.as_integer())
209                    .map(|n| n as usize),
210                response_bytes_max: t
211                    .get("response_bytes_max")
212                    .and_then(|v| v.as_integer())
213                    .map(|n| n as usize),
214            },
215            _ => ExpectedShape::default(),
216        };
217
218        let tolerance = match table.get("tolerance") {
219            Some(toml::Value::Table(t)) => {
220                let mut tol = Tolerance::default();
221                if let Some(v) = t.get("wall_time_p50_pct").and_then(|v| v.as_float()) {
222                    tol.wall_time_p50_pct = v;
223                }
224                if let Some(v) = t.get("wall_time_p95_pct").and_then(|v| v.as_float()) {
225                    tol.wall_time_p95_pct = v;
226                }
227                tol
228            }
229            _ => Tolerance::default(),
230        };
231
232        Ok(Manifest {
233            name,
234            entry,
235            iterations,
236            warmup,
237            args,
238            expected,
239            tolerance,
240        })
241    }
242}