open-standards/eoc/eoc-bench/crates/eoc-bench-runner/src/lib.rs
Transaction Science a437feb8ab Parallel ingest: 9 issues' worth of work across openpay / smart-byte / eoc
openpay/
  compliance/                     PCI-DSS readiness package (scope-map,
                                  pci-zero-architecture, hsm-kms-guidance,
                                  qsa-checklist), citing PCI-DSS v4.0.1
                                  requirements. (#1)
  examples/pci-zero/              Runnable example demonstrating a deployment
                                  where merchant code never touches a PAN. (#1)
  crates/op-core/                 Tokenized<Card> typestate + NetworkToken
                                  + CardNetwork + Cryptogram. (#2)
  crates/op-rails-card/network_token/
                                  NetworkTokenProvider trait + VTS adapter
                                  + MDES adapter, with deterministic stubs
                                  behind a 'live' feature flag. (#2)
  crates/op-driver-sdk/conformance/network_token.rs
                                  Conformance harness for third-party
                                  NetworkTokenProvider implementations. (#2)
  crates/op-cli/src/ledger/       'openpay ledger as-of' subcommand for
                                  bi-temporal queries. (#3)
  crates/op-orchestrator/src/telemetry.rs
                                  OpenTelemetry span emission per
                                  Payment<S> transition with semantic-
                                  conventions tags. (#3)
  docs/observability/             Grafana dashboard JSON + wiring guide
                                  for the bi-temporal ledger lens. (#3)

smart-byte/
  smart-byte-rs/                  Rust reference implementation: envelope
                                  encode/decode (canonical CBOR + BLAKE3
                                  content addressing), Ed25519 sign +
                                  verify, lockstep BFT commit MVP, Iroh
                                  transport scaffold, CLI. (#4)
  spec/identity_and_key_rotation.md
                                  Adopts KERI SAIDs + key-event log
                                  format; explicitly does not adopt ACDC
                                  chaining wholesale. (#5)
  conformance/v1/                 21 test vectors covering canonicalisation,
                                  SAID computation, signature verification,
                                  lockstep commit, gossip propagation.
                                  CI gate enforces spec/vector coupling. (#6)

eoc/
  eoc-rs/                         Rust + WASM reference implementation:
                                  4-stage cascade (cache -> kv -> graph ->
                                  neural), joule counters via RAPL / NVML /
                                  powermetrics, CLI, wasm-bindgen surface.
                                  18 tests pass. (#7)
  eoc-bench/                      Benchmark harness, MT-Bench and Router
                                  case sets, 'joules per MT-Bench point'
                                  metric, 'eoc-bench run | compare' CLI. (#8)
  standards/sci-for-ai-submission.md
                                  Submission proposal mapping EOC's
                                  receipt schema to GSF SCI-for-AI. (#9)
  cookbook/                       HuggingFace-style cookbook (markdown +
                                  runnable Python) demonstrating EOC's
                                  cascade with HF AI Energy Score. (#9)
  docs/bench-v0.md                Reproducibility doc for eoc-bench.

Workspace state:
- openpay clippy -- -D warnings: clean across all touched crates
- smart-byte-rs cargo test --workspace + clippy: clean
- eoc-rs cargo test --workspace + clippy: clean (18 tests)
- eoc-bench cargo check --workspace + clippy + 15 tests: clean

Rust 1.95.0 edition 2024 pinned at the open-standards root via
rust-toolchain.toml; every workspace inherits.

Closes #1 #2 #3 #4 #5 #6 #7 #8 #9.
2026-05-23 13:24:05 -04:00

343 lines
12 KiB
Rust

//! Benchmark runner — drives [`BenchCase`]s through the EOC cascade and
//! produces an aggregated [`BenchReport`].
//!
//! The runner is intentionally cascade-agnostic. When this crate is built
//! with the `eoc-rs` feature, the [`run`] function accepts a real
//! `eoc_cascade::Cascade`. Without that feature, only the data types and
//! aggregator are available — useful for ingesting JSON reports produced
//! by other deployments and for unit testing the aggregation logic.
//!
//! # The metric the harness exists to compute
//!
//! `joules-per-correct = total_joules / max(correct_answers, 1)`
//!
//! Lower is better. The cascade's job is to drive this number down by
//! resolving as many queries as possible at cache/kv/graph before falling
//! through to the (expensive) neural stage. The aggregator also reports
//! the share of resolutions per stage so a deployment can see *where* its
//! joules are going.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// Stage abstraction
// ---------------------------------------------------------------------------
//
// The aggregator and serialised report don't depend on the eoc-rs
// implementation crates — they round-trip a stage by its stable string id
// (`"cache" | "kv" | "graph" | "neural"`). When the `eoc-rs` feature is
// on we import `eoc_core::Stage` directly and `From`/`Into` it to the
// string form at the wire boundary; when it's off we use this local mirror
// so the report type stays usable.
/// Stable string identifiers for the four cascade stages. Mirror of
/// `eoc_core::Stage::as_str()` — kept here so this crate compiles
/// stand-alone without `eoc-rs`.
pub mod stage_id {
/// LRU / content-addressed cache.
pub const CACHE: &str = "cache";
/// Key-value / embedding-similarity lookup.
pub const KV: &str = "kv";
/// Graph / triple-store retrieval.
pub const GRAPH: &str = "graph";
/// Neural inference (last resort).
pub const NEURAL: &str = "neural";
}
#[cfg(feature = "eoc-rs")]
pub use eoc_core::Stage;
/// A single benchmark case.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BenchCase {
/// Stable identifier for the case (e.g. `"mt-arith-01"`).
pub id: String,
/// The prompt fed to the cascade.
pub prompt: String,
/// Optional expected answer — used only for accuracy scoring. When
/// `None`, the case is treated as inspection-only (counts towards
/// joules but never towards `accuracy_pct`).
pub expected: Option<String>,
/// Dataset/suite label (e.g. `"mt"`, `"router"`). Lets a single
/// report combine multiple suites without losing provenance.
pub dataset: String,
}
impl BenchCase {
/// Convenience constructor for tests / fixtures.
pub fn new(
id: impl Into<String>,
prompt: impl Into<String>,
expected: Option<String>,
dataset: impl Into<String>,
) -> Self {
Self {
id: id.into(),
prompt: prompt.into(),
expected,
dataset: dataset.into(),
}
}
}
/// Result of running one [`BenchCase`] through the cascade.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BenchResult {
/// Which case this result is for.
pub case_id: String,
/// The response payload returned by the cascade.
pub response: String,
/// Which stage resolved the query (stable string id).
pub stage: String,
/// Energy cost in micro-joules.
pub joule_cost_microjoules: u64,
/// Wall-clock latency for the resolution.
pub latency_ms: u64,
/// Whether the response matched the expected answer. `None` when
/// the case had no `expected` field.
pub accuracy: Option<bool>,
}
/// Aggregated metrics for a [`run`].
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BenchReport {
/// Number of cases in the report.
pub case_count: usize,
/// Sum of all `joule_cost_microjoules`, expressed in joules.
pub total_joules: f64,
/// `total_joules / correct_answers`. `f64::INFINITY` when there are
/// no correct answers — that result is dimensioned but uninformative.
pub joules_per_correct: f64,
/// Percentage of cases (with an `expected`) that matched.
pub accuracy_pct: f64,
/// Latency p50 in milliseconds.
pub latency_p50_ms: u64,
/// Latency p95 in milliseconds.
pub latency_p95_ms: u64,
/// Latency p99 in milliseconds.
pub latency_p99_ms: u64,
/// Count of cases resolved at each stage, keyed by stable stage id.
pub stage_distribution: BTreeMap<String, usize>,
}
// ---------------------------------------------------------------------------
// run() — only available with the eoc-rs feature
// ---------------------------------------------------------------------------
/// Run a slice of [`BenchCase`]s through an `eoc_cascade::Cascade` and
/// collect a [`BenchResult`] per case.
///
/// Each case is resolved sequentially; the harness deliberately avoids
/// per-case parallelism so that the joule attribution reported by the
/// cascade is not polluted by overlapping work on the same accelerator.
#[cfg(feature = "eoc-rs")]
pub async fn run(cases: &[BenchCase], cascade: &eoc_cascade::Cascade) -> Vec<BenchResult> {
let mut out = Vec::with_capacity(cases.len());
for case in cases {
let query = eoc_core::Query::new(case.prompt.clone());
let started = std::time::Instant::now();
let response = cascade.resolve(query).await;
let latency_ms = started.elapsed().as_millis().min(u64::MAX as u128) as u64;
let accuracy = case
.expected
.as_ref()
.map(|e| matches_expected(&response.payload, e));
out.push(BenchResult {
case_id: case.id.clone(),
response: response.payload,
stage: response.stage.as_str().to_string(),
joule_cost_microjoules: response.joule_cost.microjoules,
latency_ms,
accuracy,
});
}
out
}
/// Default accuracy predicate: case-insensitive substring match between
/// the response payload and the expected answer. Bench suites with
/// stricter scoring rules can post-process [`BenchResult`]s before
/// calling [`aggregate`].
pub fn matches_expected(response: &str, expected: &str) -> bool {
let r = response.to_lowercase();
let e = expected.to_lowercase();
r.contains(&e)
}
/// Aggregate raw results into a [`BenchReport`].
pub fn aggregate(results: &[BenchResult]) -> BenchReport {
let case_count = results.len();
let total_microjoules: u128 = results
.iter()
.map(|r| u128::from(r.joule_cost_microjoules))
.sum();
let total_joules = (total_microjoules as f64) / 1_000_000.0;
let scored: Vec<bool> = results.iter().filter_map(|r| r.accuracy).collect();
let correct = scored.iter().filter(|x| **x).count();
let accuracy_pct = if scored.is_empty() {
0.0
} else {
(correct as f64) * 100.0 / (scored.len() as f64)
};
let joules_per_correct = if correct == 0 {
f64::INFINITY
} else {
total_joules / (correct as f64)
};
let mut latencies: Vec<u64> = results.iter().map(|r| r.latency_ms).collect();
latencies.sort_unstable();
let latency_p50_ms = percentile(&latencies, 0.50);
let latency_p95_ms = percentile(&latencies, 0.95);
let latency_p99_ms = percentile(&latencies, 0.99);
let mut stage_distribution: BTreeMap<String, usize> = BTreeMap::new();
for r in results {
*stage_distribution.entry(r.stage.clone()).or_insert(0) += 1;
}
BenchReport {
case_count,
total_joules,
joules_per_correct,
accuracy_pct,
latency_p50_ms,
latency_p95_ms,
latency_p99_ms,
stage_distribution,
}
}
/// Nearest-rank percentile (no interpolation). Returns 0 on empty input.
fn percentile(sorted: &[u64], q: f64) -> u64 {
if sorted.is_empty() {
return 0;
}
// Nearest-rank: rank = ceil(q * n), 1-indexed.
let n = sorted.len();
let rank = (q * n as f64).ceil() as usize;
let idx = rank.saturating_sub(1).min(n - 1);
sorted[idx]
}
#[cfg(test)]
mod tests {
use super::*;
fn syn(id: &str, stage: &str, microjoules: u64, latency_ms: u64, correct: Option<bool>) -> BenchResult {
BenchResult {
case_id: id.to_string(),
response: format!("response for {id}"),
stage: stage.to_string(),
joule_cost_microjoules: microjoules,
latency_ms,
accuracy: correct,
}
}
#[test]
fn aggregate_counts_stages() {
let results = vec![
syn("a", stage_id::CACHE, 0, 1, Some(true)),
syn("b", stage_id::KV, 100, 2, Some(true)),
syn("c", stage_id::GRAPH, 500, 3, Some(false)),
syn("d", stage_id::NEURAL, 50_000_000, 80, Some(true)),
syn("e", stage_id::CACHE, 0, 1, Some(true)),
];
let report = aggregate(&results);
assert_eq!(report.case_count, 5);
assert_eq!(report.stage_distribution[stage_id::CACHE], 2);
assert_eq!(report.stage_distribution[stage_id::KV], 1);
assert_eq!(report.stage_distribution[stage_id::GRAPH], 1);
assert_eq!(report.stage_distribution[stage_id::NEURAL], 1);
}
#[test]
fn aggregate_joules_arithmetic() {
let results = vec![
syn("a", stage_id::CACHE, 0, 1, Some(true)),
syn("b", stage_id::KV, 250_000, 2, Some(true)),
syn("c", stage_id::NEURAL, 1_750_000, 10, Some(true)),
];
let report = aggregate(&results);
// (0 + 250_000 + 1_750_000) microjoules = 2 joules.
assert!((report.total_joules - 2.0).abs() < 1e-9);
// 2 joules / 3 correct ≈ 0.6667 J/correct.
assert!((report.joules_per_correct - (2.0 / 3.0)).abs() < 1e-9);
assert!((report.accuracy_pct - 100.0).abs() < 1e-9);
}
#[test]
fn aggregate_accuracy_excludes_unscored_cases() {
let results = vec![
syn("scored-pass", stage_id::CACHE, 0, 1, Some(true)),
syn("scored-fail", stage_id::NEURAL, 100, 2, Some(false)),
syn("unscored", stage_id::KV, 50, 3, None),
];
let report = aggregate(&results);
// 1 of 2 scored cases passed.
assert!((report.accuracy_pct - 50.0).abs() < 1e-9);
// Stage distribution still counts the unscored case.
assert_eq!(report.case_count, 3);
assert_eq!(report.stage_distribution[stage_id::KV], 1);
}
#[test]
fn joules_per_correct_is_infinity_with_zero_correct() {
let results = vec![
syn("fail-1", stage_id::NEURAL, 1_000_000, 50, Some(false)),
syn("fail-2", stage_id::NEURAL, 1_000_000, 60, Some(false)),
];
let report = aggregate(&results);
assert!(report.joules_per_correct.is_infinite());
assert!((report.accuracy_pct - 0.0).abs() < 1e-9);
}
#[test]
fn latency_percentiles_are_nearest_rank() {
// 100 cases with latency = 1..=100 ms; p50≈50, p95≈95, p99≈99.
let results: Vec<BenchResult> = (1..=100)
.map(|ms| syn(&format!("c-{ms}"), stage_id::CACHE, 0, ms, Some(true)))
.collect();
let report = aggregate(&results);
assert_eq!(report.latency_p50_ms, 50);
assert_eq!(report.latency_p95_ms, 95);
assert_eq!(report.latency_p99_ms, 99);
}
#[test]
fn empty_input_is_well_defined() {
let report = aggregate(&[]);
assert_eq!(report.case_count, 0);
assert_eq!(report.total_joules, 0.0);
assert!(report.joules_per_correct.is_infinite());
assert_eq!(report.accuracy_pct, 0.0);
assert_eq!(report.latency_p50_ms, 0);
assert!(report.stage_distribution.is_empty());
}
#[test]
fn matches_expected_is_case_insensitive_substring() {
assert!(matches_expected("The answer is 4.", "4"));
assert!(matches_expected("PARIS", "paris"));
assert!(!matches_expected("the answer is five", "4"));
}
#[test]
fn report_roundtrips_through_serde_json() {
let results = vec![
syn("a", stage_id::CACHE, 0, 1, Some(true)),
syn("b", stage_id::NEURAL, 1_000, 2, Some(false)),
];
let report = aggregate(&results);
let json = serde_json::to_string(&report).unwrap();
let back: BenchReport = serde_json::from_str(&json).unwrap();
assert_eq!(report, back);
}
}