wickra_verify_core/
verify.rs1use 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
15fn verify_with(
18 claim: &Claim,
19 data: &BTreeMap<String, Vec<Candle>>,
20 atol: f64,
21 rtol: f64,
22) -> Result<Verdict> {
23 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
61pub fn verify(claim: &Claim, data: &BTreeMap<String, Vec<Candle>>) -> Result<Verdict> {
65 verify_with(claim, data, DEFAULT_ATOL, DEFAULT_RTOL)
66}
67
68#[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#[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 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 #[must_use]
137 pub fn version() -> &'static str {
138 env!("CARGO_PKG_VERSION")
139 }
140
141 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 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 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}