1use std::fmt;
8
9use serde::{Deserialize, Serialize};
10
11use crate::{opcode_abi_fingerprint, semantic_abi_fingerprint_hex, DataValue, ARTIFACT_VERSION};
12
13mod schema;
14pub use schema::portable_benchmark_json_schema;
15
16pub const PORTABLE_BENCHMARK_SCHEMA_VERSION: &str = "harn.portable_kernel.benchmark.v1";
17pub const PORTABLE_MAX_DISPATCH_ITERATIONS: usize = 1_000_000;
18pub const PORTABLE_MAX_COMPILE_ITERATIONS: usize = 100_000;
19pub const PORTABLE_MAX_WORKERS: usize = 256;
20
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase", deny_unknown_fields)]
26pub struct PortableBenchmarkReceipt {
27 pub schema_version: String,
28 pub target: BenchmarkTarget,
29 pub source: String,
30 pub entry: String,
31 pub entry_kind: BenchmarkEntryKind,
32 pub artifact_bytes: usize,
33 pub artifact_digest: String,
34 pub iterations: usize,
35 pub workers: usize,
36 pub provenance: BenchmarkProvenance,
37 pub initialization_ms: Option<f64>,
38 pub compile: CompileMeasurements,
39 pub decode: Option<BenchmarkStatistics>,
40 pub dispatch: DispatchMeasurements,
41 pub terminal_digest: String,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum BenchmarkTarget {
47 Native,
48 Browser,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum BenchmarkEntryKind {
54 Function,
55 Pipeline,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum BenchmarkBuildProfile {
61 Debug,
62 Release,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase", deny_unknown_fields)]
67pub struct BenchmarkProvenance {
68 pub harn_version: String,
69 pub kernel_version: String,
70 pub artifact_format_version: u16,
71 pub semantic_abi_fingerprint: String,
72 pub opcode_abi_fingerprint: String,
73 pub build_profile: BenchmarkBuildProfile,
74 pub os: String,
75 pub arch: String,
76}
77
78impl BenchmarkProvenance {
79 pub fn current(
80 harn_version: impl Into<String>,
81 build_profile: BenchmarkBuildProfile,
82 os: impl Into<String>,
83 arch: impl Into<String>,
84 ) -> Self {
85 Self {
86 harn_version: harn_version.into(),
87 kernel_version: crate::KERNEL_VERSION.to_string(),
88 artifact_format_version: ARTIFACT_VERSION,
89 semantic_abi_fingerprint: semantic_abi_fingerprint_hex(),
90 opcode_abi_fingerprint: opcode_abi_fingerprint()
91 .iter()
92 .map(|byte| format!("{byte:02x}"))
93 .collect(),
94 build_profile,
95 os: os.into(),
96 arch: arch.into(),
97 }
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase", deny_unknown_fields)]
103pub struct CompileMeasurements {
104 pub first_ms: f64,
105 pub repeated: BenchmarkStatistics,
106}
107
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase", deny_unknown_fields)]
110pub struct DispatchMeasurements {
111 pub first_ms: f64,
112 pub repeated: BenchmarkStatistics,
113 pub batch_wall_ms: f64,
114 pub throughput_per_second: f64,
115}
116
117impl PortableBenchmarkReceipt {
118 pub fn validate(&self) -> Result<(), String> {
120 if self.schema_version != PORTABLE_BENCHMARK_SCHEMA_VERSION {
121 return Err("portable benchmark schema version is not supported".to_string());
122 }
123 if self.source.is_empty() || self.entry.is_empty() || self.artifact_bytes == 0 {
124 return Err("portable benchmark identity fields must not be empty".to_string());
125 }
126 if self.iterations == 0 || self.iterations > PORTABLE_MAX_DISPATCH_ITERATIONS {
127 return Err(
128 "portable benchmark dispatch iteration count is outside limits".to_string(),
129 );
130 }
131 if self.workers == 0 || self.workers > PORTABLE_MAX_WORKERS {
132 return Err("portable benchmark worker count is outside limits".to_string());
133 }
134 if self.workers > self.iterations {
135 return Err(
136 "portable benchmark workers must not exceed dispatch iterations".to_string(),
137 );
138 }
139 if self.compile.repeated.iterations == 0
140 || self.compile.repeated.iterations > PORTABLE_MAX_COMPILE_ITERATIONS
141 {
142 return Err("portable benchmark compile iteration count is outside limits".to_string());
143 }
144 if self.dispatch.repeated.iterations != self.iterations {
145 return Err(
146 "portable benchmark dispatch statistics do not match iterations".to_string(),
147 );
148 }
149 if let Some(decode) = self.decode {
150 if decode.iterations != self.compile.repeated.iterations {
151 return Err(
152 "portable benchmark decode statistics do not match compilation samples"
153 .to_string(),
154 );
155 }
156 }
157 match self.target {
158 BenchmarkTarget::Native
159 if self.initialization_ms.is_some() || self.decode.is_none() =>
160 {
161 return Err("native portable benchmarks require decode samples and no adapter initialization".to_string());
162 }
163 BenchmarkTarget::Browser
164 if self.initialization_ms.is_none() || self.decode.is_some() =>
165 {
166 return Err("browser portable benchmarks require adapter initialization and include decode in dispatch".to_string());
167 }
168 BenchmarkTarget::Native | BenchmarkTarget::Browser => {}
169 }
170 if !is_digest(&self.artifact_digest)
171 || !is_digest(&self.terminal_digest)
172 || !is_digest(&self.provenance.semantic_abi_fingerprint)
173 || !is_digest(&self.provenance.opcode_abi_fingerprint)
174 {
175 return Err("portable benchmark digests must be lowercase 32-byte hex".to_string());
176 }
177 if self.provenance.harn_version.is_empty()
178 || self.provenance.kernel_version.is_empty()
179 || self.provenance.os.is_empty()
180 || self.provenance.arch.is_empty()
181 || self.provenance.artifact_format_version == 0
182 {
183 return Err("portable benchmark provenance is incomplete".to_string());
184 }
185 for value in [
186 self.initialization_ms.unwrap_or(0.0),
187 self.compile.first_ms,
188 self.dispatch.first_ms,
189 self.dispatch.batch_wall_ms,
190 self.dispatch.throughput_per_second,
191 ] {
192 if !value.is_finite() || value < 0.0 {
193 return Err(
194 "portable benchmark measurements must be finite and non-negative".to_string(),
195 );
196 }
197 }
198 for statistics in [
199 &self.compile.repeated,
200 &self.dispatch.repeated,
201 self.decode.as_ref().unwrap_or(&self.compile.repeated),
202 ] {
203 if !valid_statistics(statistics) {
204 return Err(
205 "portable benchmark statistics must be finite and non-negative".to_string(),
206 );
207 }
208 }
209 if self.dispatch.batch_wall_ms == 0.0 || self.dispatch.throughput_per_second == 0.0 {
210 return Err("portable benchmark batch measurements must be positive".to_string());
211 }
212 Ok(())
213 }
214}
215
216fn is_digest(value: &str) -> bool {
217 value.len() == 64
218 && value
219 .bytes()
220 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
221}
222
223fn valid_statistics(statistics: &BenchmarkStatistics) -> bool {
224 statistics.iterations > 0
225 && [
226 statistics.min_ms,
227 statistics.mean_ms,
228 statistics.p50_ms,
229 statistics.p95_ms,
230 statistics.max_ms,
231 statistics.stddev_ms,
232 statistics.total_ms,
233 ]
234 .into_iter()
235 .all(|value| value.is_finite() && value >= 0.0)
236}
237
238pub fn benchmark_terminal_digest(value: &DataValue) -> String {
243 blake3::hash(value.to_json().to_string().as_bytes())
244 .to_hex()
245 .to_string()
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
253#[non_exhaustive]
254pub struct BenchmarkStatistics {
255 pub iterations: usize,
256 pub min_ms: f64,
257 pub mean_ms: f64,
258 pub p50_ms: f64,
259 pub p95_ms: f64,
260 pub max_ms: f64,
261 pub stddev_ms: f64,
262 pub total_ms: f64,
263}
264
265impl BenchmarkStatistics {
266 pub fn from_samples(
272 samples: impl IntoIterator<Item = f64>,
273 ) -> Result<Self, BenchmarkStatisticsError> {
274 let mut sorted = samples.into_iter().collect::<Vec<_>>();
275 if sorted.is_empty() {
276 return Err(BenchmarkStatisticsError::Empty);
277 }
278 for (index, sample) in sorted.iter_mut().enumerate() {
279 if !sample.is_finite() {
280 return Err(BenchmarkStatisticsError::NonFinite { index });
281 }
282 if *sample < 0.0 {
283 return Err(BenchmarkStatisticsError::Negative { index });
284 }
285 if *sample == 0.0 {
289 *sample = 0.0;
290 }
291 }
292
293 sorted.sort_by(f64::total_cmp);
294 let iterations = sorted.len();
295 let total_ms = compensated_sum(sorted.iter().copied());
296 if !total_ms.is_finite() {
297 return Err(BenchmarkStatisticsError::AggregateOverflow);
298 }
299 let mean_ms = total_ms / iterations as f64;
300 let variance = compensated_sum(sorted.iter().map(|sample| {
301 let delta = sample - mean_ms;
302 delta * delta
303 })) / iterations as f64;
304 if !variance.is_finite() {
305 return Err(BenchmarkStatisticsError::AggregateOverflow);
306 }
307
308 Ok(Self {
309 iterations,
310 min_ms: sorted[0],
311 mean_ms,
312 p50_ms: percentile_r7(&sorted, 0.50),
313 p95_ms: percentile_r7(&sorted, 0.95),
314 max_ms: sorted[iterations - 1],
315 stddev_ms: variance.sqrt(),
316 total_ms,
317 })
318 }
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323pub enum BenchmarkStatisticsError {
324 Empty,
325 NonFinite { index: usize },
326 Negative { index: usize },
327 AggregateOverflow,
328}
329
330impl BenchmarkStatisticsError {
331 pub const fn code(self) -> &'static str {
332 match self {
333 Self::Empty => "benchmark_samples_empty",
334 Self::NonFinite { .. } => "benchmark_sample_non_finite",
335 Self::Negative { .. } => "benchmark_sample_negative",
336 Self::AggregateOverflow => "benchmark_aggregate_overflow",
337 }
338 }
339}
340
341impl fmt::Display for BenchmarkStatisticsError {
342 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
343 match self {
344 Self::Empty => formatter.write_str("benchmark samples must not be empty"),
345 Self::NonFinite { index } => {
346 write!(formatter, "benchmark sample {index} must be finite")
347 }
348 Self::Negative { index } => {
349 write!(formatter, "benchmark sample {index} must not be negative")
350 }
351 Self::AggregateOverflow => {
352 formatter.write_str("benchmark sample aggregate exceeds finite range")
353 }
354 }
355 }
356}
357
358impl std::error::Error for BenchmarkStatisticsError {}
359
360fn percentile_r7(sorted: &[f64], probability: f64) -> f64 {
361 if sorted.len() == 1 {
362 return sorted[0];
363 }
364 let rank = probability * (sorted.len() - 1) as f64;
365 let lower = rank.floor() as usize;
366 let upper = rank.ceil() as usize;
367 if lower == upper {
368 sorted[lower]
369 } else {
370 let weight = rank - lower as f64;
371 sorted[lower] * (1.0 - weight) + sorted[upper] * weight
372 }
373}
374
375fn compensated_sum(values: impl IntoIterator<Item = f64>) -> f64 {
376 let mut sum = 0.0;
377 let mut compensation = 0.0;
378 for value in values {
379 let corrected = value - compensation;
380 let next = sum + corrected;
381 compensation = (next - sum) - corrected;
382 sum = next;
383 }
384 sum
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 #[test]
392 fn aggregates_unsorted_samples_with_r7_percentiles() {
393 let stats = BenchmarkStatistics::from_samples([30.0, 10.0, 40.0, 20.0]).unwrap();
394
395 assert_eq!(stats.iterations, 4);
396 assert_eq!(stats.min_ms, 10.0);
397 assert_eq!(stats.mean_ms, 25.0);
398 assert_eq!(stats.p50_ms, 25.0);
399 assert_eq!(stats.p95_ms, 38.5);
400 assert_eq!(stats.max_ms, 40.0);
401 assert_eq!(stats.stddev_ms, 125.0_f64.sqrt());
402 assert_eq!(stats.total_ms, 100.0);
403 }
404
405 #[test]
406 fn single_sample_has_zero_variance_and_exact_percentiles() {
407 let stats = BenchmarkStatistics::from_samples([1.25]).unwrap();
408
409 assert_eq!(stats.p50_ms, 1.25);
410 assert_eq!(stats.p95_ms, 1.25);
411 assert_eq!(stats.stddev_ms, 0.0);
412 }
413
414 #[test]
415 fn normalizes_signed_zero_for_cross_host_receipts() {
416 let stats = BenchmarkStatistics::from_samples([-0.0, 0.0]).unwrap();
417
418 assert_eq!(stats.min_ms.to_bits(), 0.0_f64.to_bits());
419 assert_eq!(stats.max_ms.to_bits(), 0.0_f64.to_bits());
420 }
421
422 #[test]
423 fn rejects_empty_or_invalid_elapsed_times() {
424 assert_eq!(
425 BenchmarkStatistics::from_samples([]).unwrap_err(),
426 BenchmarkStatisticsError::Empty
427 );
428 assert_eq!(
429 BenchmarkStatistics::from_samples([1.0, f64::NAN]).unwrap_err(),
430 BenchmarkStatisticsError::NonFinite { index: 1 }
431 );
432 assert_eq!(
433 BenchmarkStatistics::from_samples([1.0, f64::INFINITY]).unwrap_err(),
434 BenchmarkStatisticsError::NonFinite { index: 1 }
435 );
436 assert_eq!(
437 BenchmarkStatistics::from_samples([1.0, -0.1]).unwrap_err(),
438 BenchmarkStatisticsError::Negative { index: 1 }
439 );
440 assert_eq!(
441 BenchmarkStatistics::from_samples([f64::MAX, f64::MAX]).unwrap_err(),
442 BenchmarkStatisticsError::AggregateOverflow
443 );
444 }
445
446 #[test]
447 fn serialized_field_names_match_the_receipt_contract() {
448 let stats = BenchmarkStatistics::from_samples([10.0, 20.0]).unwrap();
449 let value = serde_json::to_value(stats).unwrap();
450
451 assert_eq!(value["iterations"], 2);
452 assert_eq!(value["p50_ms"], 15.0);
453 assert_eq!(value["p95_ms"], 19.5);
454 }
455
456 #[test]
457 fn terminal_digest_uses_canonical_tagged_json() {
458 let left = DataValue::Record(std::collections::BTreeMap::from([
459 ("z".to_string(), DataValue::Float(f64::NAN)),
460 ("a".to_string(), DataValue::Int(i64::MAX)),
461 ]));
462 let right = DataValue::from_json(left.to_json()).unwrap();
463
464 assert_eq!(
465 benchmark_terminal_digest(&left),
466 benchmark_terminal_digest(&right)
467 );
468 }
469}