Skip to main content

wickra_verify_core/
verify.rs

1//! `verify` / `explain` and the [`Verifier`] command-JSON handle.
2
3use crate::canon::{canonicalize, hash};
4use crate::claim::Claim;
5use crate::compare::compare;
6use crate::config::{Config, DEFAULT_ATOL, DEFAULT_RTOL};
7use crate::error::{Error, Result};
8use crate::verdict::Verdict;
9use serde::Deserialize;
10use serde_json::{json, Value};
11use std::collections::BTreeMap;
12use std::fmt::Write as _;
13use wickra_backtest_core::{run, version as engine_version, Candle, StrategySpec};
14
15/// Recompute the report from `claim.strategy` + `data` and compare it, field by
16/// field, against `claim.claimed_report` under the given tolerances.
17fn verify_with(
18    claim: &Claim,
19    data: &BTreeMap<String, Vec<Candle>>,
20    atol: f64,
21    rtol: f64,
22) -> Result<Verdict> {
23    // Structural validation (strategy + claimed_report are objects). Callers that
24    // build a Claim via from_json already ran this, but the command-JSON path
25    // deserializes a Claim directly, so re-check here.
26    claim.validate()?;
27    let engine = engine_version().to_string();
28
29    let strategy: StrategySpec = serde_json::from_value(claim.strategy.clone())
30        .map_err(|e| Error::BadClaim(e.to_string()))?;
31    let candles = data
32        .get(&strategy.symbol)
33        .ok_or_else(|| Error::Data(format!("no candles for symbol {}", strategy.symbol)))?;
34
35    let actual = run(&strategy, candles).map_err(|e| Error::Backtest(e.to_string()))?;
36    let actual_value =
37        serde_json::to_value(&actual).map_err(|e| Error::BadReport(e.to_string()))?;
38
39    let mismatches = compare(&claim.claimed_report, &actual_value, atol, rtol);
40
41    let claimed_report_hash = hash(&canonicalize(&claim.claimed_report)?);
42    let actual_report_hash = hash(&canonicalize(&actual_value)?);
43    let inputs = json!({
44        "strategy": claim.strategy,
45        "dataset_ref": claim.dataset_ref,
46        "candles": serde_json::to_value(data).map_err(|e| Error::Data(e.to_string()))?,
47        "engine_version": engine,
48    });
49    let inputs_hash = hash(&canonicalize(&inputs)?);
50
51    Ok(Verdict {
52        matches: mismatches.is_empty(),
53        mismatches,
54        engine_version: engine,
55        claimed_report_hash,
56        actual_report_hash,
57        inputs_hash,
58    })
59}
60
61/// Verify a claim against candle data with the default tolerances
62/// (`atol = 1e-9`, `rtol = 1e-6`). Recomputes the report and compares — a
63/// doctored `claimed_report` is refuted, an honest one is confirmed.
64pub fn verify(claim: &Claim, data: &BTreeMap<String, Vec<Candle>>) -> Result<Verdict> {
65    verify_with(claim, data, DEFAULT_ATOL, DEFAULT_RTOL)
66}
67
68/// A human-readable, deterministic one-shot summary of a verdict.
69#[must_use]
70pub fn explain(verdict: &Verdict) -> String {
71    if verdict.matches {
72        return format!(
73            "VERIFIED: claimed report matches the recomputed report (engine {}).",
74            verdict.engine_version
75        );
76    }
77    let mut lines = format!(
78        "REFUTED: {} field(s) disagree with the recomputed report (engine {}):",
79        verdict.mismatches.len(),
80        verdict.engine_version
81    );
82    for m in &verdict.mismatches {
83        write!(
84            lines,
85            "\n  {}: claimed {}, actual {} (delta {})",
86            m.field, m.claimed, m.actual, m.delta
87        )
88        .expect("writing to a String is infallible");
89    }
90    lines
91}
92
93/// Command-JSON handle carrying the comparison tolerances. Holds no other state,
94/// but is handle-shaped so the ten language bindings share the same surface as
95/// screener/proof/terminal.
96#[derive(Debug, Clone, Copy)]
97pub struct Verifier {
98    atol: f64,
99    rtol: f64,
100}
101
102impl Default for Verifier {
103    fn default() -> Self {
104        Self {
105            atol: DEFAULT_ATOL,
106            rtol: DEFAULT_RTOL,
107        }
108    }
109}
110
111#[derive(Deserialize)]
112struct VerifyReq {
113    claim: Claim,
114    #[serde(default)]
115    data: Option<BTreeMap<String, Vec<Candle>>>,
116}
117
118#[derive(Deserialize)]
119struct ExplainReq {
120    verdict: Verdict,
121}
122
123impl Verifier {
124    /// Build a verifier from a `Config` JSON string (`{"atol":..,"rtol":..}`).
125    /// Missing fields fall back to the defaults.
126    pub fn new(config_json: &str) -> Result<Self> {
127        let config: Config =
128            serde_json::from_str(config_json).map_err(|e| Error::Parse(e.to_string()))?;
129        Ok(Self {
130            atol: config.atol,
131            rtol: config.rtol,
132        })
133    }
134
135    /// The wickra-verify-core crate version.
136    #[must_use]
137    pub fn version() -> &'static str {
138        env!("CARGO_PKG_VERSION")
139    }
140
141    /// Verify a claim against candle data with this verifier's tolerances.
142    pub fn verify(&self, claim: &Claim, data: &BTreeMap<String, Vec<Candle>>) -> Result<Verdict> {
143        verify_with(claim, data, self.atol, self.rtol)
144    }
145
146    /// Dispatch a command envelope `{"cmd": ...}` and return a canonical JSON
147    /// string. Unknown commands and errors return an error envelope, never a
148    /// panic.
149    pub fn command_json(&self, cmd_json: &str) -> Result<String> {
150        let value = dispatch(self, cmd_json);
151        canonicalize(&value)
152    }
153}
154
155fn dispatch(verifier: &Verifier, cmd_json: &str) -> Value {
156    match dispatch_inner(verifier, cmd_json) {
157        Ok(v) => v,
158        Err(e) => json!({ "ok": false, "error": e.to_string() }),
159    }
160}
161
162fn dispatch_inner(verifier: &Verifier, cmd_json: &str) -> Result<Value> {
163    let env: Value = serde_json::from_str(cmd_json).map_err(|e| Error::Parse(e.to_string()))?;
164    let cmd = env.get("cmd").and_then(Value::as_str).unwrap_or("");
165    match cmd {
166        "verify" => {
167            let req: VerifyReq =
168                serde_json::from_value(env).map_err(|e| Error::Parse(e.to_string()))?;
169            // Fall back to the claim's inline data when none is supplied.
170            let verdict = if let Some(data) = req.data {
171                verifier.verify(&req.claim, &data)?
172            } else {
173                let data = req.claim.inline_data().ok_or_else(|| {
174                    Error::Data("no data supplied and claim is not inline".to_string())
175                })?;
176                verifier.verify(&req.claim, data)?
177            };
178            serde_json::to_value(verdict).map_err(|e| Error::BadReport(e.to_string()))
179        }
180        "explain" => {
181            let req: ExplainReq =
182                serde_json::from_value(env).map_err(|e| Error::Parse(e.to_string()))?;
183            Ok(json!({ "ok": true, "text": explain(&req.verdict) }))
184        }
185        "canonicalize" => {
186            let value = env.get("value").cloned().unwrap_or(Value::Null);
187            Ok(json!({ "ok": true, "canonical": canonicalize(&value)? }))
188        }
189        "version" => Ok(json!({
190            "version": Verifier::version(),
191            "engine_version": engine_version(),
192        })),
193        other => Err(Error::Parse(format!("unknown cmd: {other}"))),
194    }
195}