Skip to main content

wickra_benchmark_core/
case.rs

1//! [`BenchmarkCase`] — one curated `(strategy, dataset, expected report, hash)`
2//! tuple: the unit the suite is built from.
3
4use crate::error::{Error, Result};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8pub use wickra_backtest_core::{BacktestReport, Candle, StrategySpec};
9
10/// A single golden-verified case. Its `strategy` is an embedded wickra-backtest
11/// `StrategySpec` (kept as raw JSON so wickra-benchmark-core stays decoupled from the
12/// engine's struct internals across the FFI boundary); its `expected` report and
13/// `expected_hash` are frozen when the case is blessed and are byte-exact
14/// thereafter. Running the case recomputes the report from `strategy` + the
15/// dataset and checks it against both.
16#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
17#[serde(deny_unknown_fields)]
18pub struct BenchmarkCase {
19    /// Stable, unique case key; the sort and tie key (for example
20    /// `"sma-crossover-01"`).
21    pub id: String,
22    /// Human-readable description of what the case exercises.
23    pub description: String,
24    /// The embedded wickra-backtest `StrategySpec`, as raw JSON.
25    pub strategy: Value,
26    /// The dataset file this case runs on, relative to the data root (for
27    /// example `"sma-uptrend.csv"`).
28    pub dataset_ref: String,
29    /// The frozen reference report (byte-exact) the recompute is checked
30    /// against, kept as raw JSON. The engine's `BacktestReport` is serialize-only
31    /// across the FFI boundary, so the expectation travels as a JSON object.
32    pub expected: Value,
33    /// The lowercase 64-hex blake3 of the canonical `expected` report.
34    pub expected_hash: String,
35}
36
37/// A 64-character lowercase-hex string is the shape of a blake3 digest.
38fn is_hex64_lowercase(s: &str) -> bool {
39    s.len() == 64
40        && s.bytes()
41            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
42}
43
44impl BenchmarkCase {
45    /// Parse a `BenchmarkCase` from JSON.
46    pub fn from_json(s: &str) -> Result<Self> {
47        let case: Self = serde_json::from_str(s).map_err(|e| Error::BadCase(e.to_string()))?;
48        case.validate()?;
49        Ok(case)
50    }
51
52    /// Parse a `BenchmarkCase` from TOML.
53    pub fn from_toml(s: &str) -> Result<Self> {
54        let case: Self = toml::from_str(s).map_err(|e| Error::Parse(e.to_string()))?;
55        case.validate()?;
56        Ok(case)
57    }
58
59    /// Validate structural invariants: a non-empty `id` and `dataset_ref`, a
60    /// well-formed `expected_hash`, and a `strategy` that is a JSON object.
61    pub(crate) fn validate(&self) -> Result<()> {
62        if self.id.is_empty() {
63            return Err(Error::BadCase("case id must not be empty".to_string()));
64        }
65        if self.dataset_ref.is_empty() {
66            return Err(Error::BadCase("dataset_ref must not be empty".to_string()));
67        }
68        if !is_hex64_lowercase(&self.expected_hash) {
69            return Err(Error::BadCase(format!(
70                "expected_hash must be 64 lowercase hex characters, got {:?}",
71                self.expected_hash
72            )));
73        }
74        if !self.expected.is_object() {
75            return Err(Error::BadCase(
76                "expected must be a JSON object (a BacktestReport)".to_string(),
77            ));
78        }
79        if !self.strategy.is_object() {
80            return Err(Error::BadSpec(
81                "strategy must be a JSON object (a StrategySpec)".to_string(),
82            ));
83        }
84        Ok(())
85    }
86}