wickra_proof_core/canonical.rs
1//! Deterministic JSON canonicalization — the single source of the hash's
2//! stability across all ten language bindings.
3//!
4//! The rules (each normative):
5//! 1. Object keys are sorted ascending by Unicode code point (`BTreeMap`).
6//! 2. No whitespace: `,` and `:` separators, nothing else.
7//! 3. Every float is quantized to 1e-8 by decimal rounding (`{:.8}`), trailing
8//! zeros trimmed. A whole-valued float collapses to its integer token
9//! (`1.0` -> `"1"`), because JSON in most host languages (JavaScript above
10//! all) cannot preserve the `.0`: `JSON.stringify` emits `1`, and the hash
11//! must be byte-identical regardless of which language loaded the spec.
12//! Integers stay integers by the same token. Magnitudes at or above
13//! `2^52 * 1e-8` (where the f64 ULP reaches the grid) instead use the
14//! shortest round-trippable form, keeping canonicalization a fixed point.
15//! 4. `NaN` / `±inf` cannot occur: `serde_json` rejects them at parse time, so
16//! every `Value` number is a finite integer or float by construction.
17//! 5. Strings use `serde_json`'s standard escaping.
18//! 6. Array order is preserved (it is meaning-bearing).
19
20use crate::error::Result;
21use serde_json::Value;
22use std::collections::BTreeMap;
23use std::fmt::Write as _;
24use wickra_backtest_core::{BacktestReport, Candle};
25
26/// Format a float in a fixed, cross-language-stable, idempotent decimal form:
27/// eight fractional digits, trailing zeros trimmed, and a whole value collapsed
28/// to its bare integer token (`1.0` -> `"1"`). The integer collapse is essential:
29/// a host language cannot distinguish `1.0` from `1` in JSON — `JSON.stringify`
30/// emits `1` — so the only representation every language can reproduce for a
31/// whole number is the integer one. Negative zero normalizes to `0`.
32///
33/// Quantization to 1e-8 is done purely by the `{:.8}` decimal rounding — no
34/// separate binary-grid step. That matters for the moat's load-bearing property:
35/// canonicalization must be a fixed point (canonicalize -> parse -> canonicalize
36/// yields the same bytes). Rounding to eight decimals is a fixed point only while
37/// the 1e-8 grid is coarser than the f64 ULP, i.e. `|x| < 2^52 * 1e-8`; a binary
38/// `(x*1e8).round()/1e8` grid disagrees with the decimal one right at that
39/// boundary and used to drift (found by the canonicalize fuzz target on inputs
40/// like `44447444.444...` and `5e55`).
41///
42/// At or above that magnitude the 1e-8 grid is finer than f64 can represent, so
43/// `{:.8}` is meaningless and unstable; emit the shortest round-trippable form
44/// (`Display`, always positional for f64, never scientific) instead, which
45/// re-parses to the same value under `serde_json`'s `float_roundtrip` parser.
46fn format_f64(x: f64) -> String {
47 // 2^52 * 1e-8: the magnitude at which the f64 ULP first reaches the 1e-8
48 // grid. Below it, decimal rounding to eight places is a fixed point.
49 const GRID_RESOLUTION_LIMIT: f64 = 45_035_996.273_704_96;
50 let x = if x == 0.0 { 0.0 } else { x };
51 if x.abs() >= GRID_RESOLUTION_LIMIT {
52 return format!("{x}");
53 }
54 let mut s = format!("{x:.8}");
55 if s.contains('.') {
56 while s.ends_with('0') {
57 s.pop();
58 }
59 if s.ends_with('.') {
60 s.pop();
61 }
62 }
63 // A small negative that rounds to zero at eight decimals formats as
64 // `-0.00000000`, which trims to `-0`. Re-parsing `-0` yields `-0.0`, which
65 // the `x == 0.0` guard above collapses to `0` — so the signed form would
66 // break idempotence. Drop the sign whenever the rounded value is zero.
67 if s == "-0" {
68 s.clear();
69 s.push('0');
70 }
71 s
72}
73
74fn write_number(out: &mut String, n: &serde_json::Number) {
75 if let Some(i) = n.as_i64() {
76 write!(out, "{i}").expect("writing to a String is infallible");
77 } else if let Some(u) = n.as_u64() {
78 write!(out, "{u}").expect("writing to a String is infallible");
79 } else {
80 // A JSON number that is neither i64 nor u64 is a finite f64: serde_json
81 // rejects NaN/inf at parse time and never yields an unrepresentable
82 // number here.
83 let f = n.as_f64().unwrap_or(0.0);
84 out.push_str(&format_f64(f));
85 }
86}
87
88fn write_string(out: &mut String, s: &str) {
89 // serde_json's string serializer is the reference escaping; reuse it.
90 let encoded = Value::String(s.to_string()).to_string();
91 out.push_str(&encoded);
92}
93
94fn write_value(out: &mut String, value: &Value) {
95 match value {
96 Value::Null => out.push_str("null"),
97 Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
98 Value::Number(n) => write_number(out, n),
99 Value::String(s) => write_string(out, s),
100 Value::Array(items) => {
101 out.push('[');
102 for (i, item) in items.iter().enumerate() {
103 if i > 0 {
104 out.push(',');
105 }
106 write_value(out, item);
107 }
108 out.push(']');
109 }
110 Value::Object(map) => {
111 let sorted: BTreeMap<&String, &Value> = map.iter().collect();
112 out.push('{');
113 for (i, (key, val)) in sorted.iter().enumerate() {
114 if i > 0 {
115 out.push(',');
116 }
117 write_string(out, key);
118 out.push(':');
119 write_value(out, val);
120 }
121 out.push('}');
122 }
123 }
124}
125
126/// Produce the canonical, whitespace-free, key-sorted string form of `value`.
127/// Byte-identical across languages; the input to the blake3 hash.
128///
129/// The `Result` return keeps a uniform signature with the rest of the API and
130/// the language bindings; canonicalization of a `serde_json::Value` is total.
131pub fn canonicalize(value: &Value) -> Result<String> {
132 let mut out = String::new();
133 write_value(&mut out, value);
134 Ok(out)
135}
136
137/// The lowercase 64-hex blake3 of a canonical string (no prefix).
138pub(crate) fn blake3_hex(canonical: &str) -> String {
139 blake3::hash(canonical.as_bytes()).to_hex().to_string()
140}
141
142/// The canonical hash of any JSON value: the blake3 of its canonical form.
143///
144/// This is the crate's only definition of "the hash". [`prove`](crate::prove)
145/// reports both of its hashes through it, and [`hash_candles`] and
146/// [`hash_report`] are it applied to the two shapes a caller outside the prover
147/// needs. Anything that recomputes a hash independently -- a zkVM guest, a
148/// language binding -- has to agree with this function, or the proof it
149/// produces says nothing.
150///
151/// # Errors
152/// Propagates a canonicalization failure. Canonicalizing a `Value` is total, so
153/// the variant exists for signature uniformity with the rest of the API.
154pub fn hash_value(value: &Value) -> Result<String> {
155 Ok(blake3_hex(&canonicalize(value)?))
156}
157
158/// The canonical hash of a candle series -- the dataset commitment.
159///
160/// Binds a proof to the data it was computed over: the same candles in the same
161/// order produce the same 64-hex string in every language, so a verifier can
162/// tell whether two proofs used the same series without being shown it.
163///
164/// Note what this is *not*: [`prove`](crate::prove) hashes
165/// `{strategy, dataset_ref, candles, engine_version}` as one block into
166/// `inputs_hash`. This hashes the candles alone.
167///
168/// # Errors
169/// Returns [`Error`](crate::Error) if the candles cannot be represented as JSON.
170pub fn hash_candles(candles: &[Candle]) -> Result<String> {
171 hash_value(&serde_json::to_value(candles)?)
172}
173
174/// The canonical hash of a backtest report.
175///
176/// Byte-identical to the `report_hash` that [`prove`](crate::prove) reports for
177/// the same report -- `prove` calls this rather than repeating it, so the two
178/// cannot drift apart.
179///
180/// # Errors
181/// Returns [`Error`](crate::Error) if the report cannot be represented as JSON.
182pub fn hash_report(report: &BacktestReport) -> Result<String> {
183 hash_value(&serde_json::to_value(report)?)
184}