Skip to main content

quantik_core/bench/
canonical.rs

1//! Python-compatible canonical JSON encoding.
2//!
3//! Dataset checksums are sha256 over the exact bytes Python produces with
4//! `json.dumps(payload, sort_keys=True, separators=(",", ":"))`. Two spots
5//! diverge from serde_json's encoder and are reimplemented here:
6//!
7//! - floats: Python uses `repr(float)` (shortest round-trip, scientific
8//!   notation when the decimal exponent is < -4 or >= 16, two-digit
9//!   exponent with sign), where ryu prefers positional notation;
10//! - strings: Python defaults to `ensure_ascii=True`, escaping any
11//!   non-ASCII character as `\uXXXX` (surrogate pairs above the BMP).
12//!
13//! Map keys are already sorted because `serde_json::Map` is a `BTreeMap`
14//! (byte order == code-point order for the ASCII keys used by the schema).
15
16use serde_json::Value;
17
18/// Format an f64 exactly like CPython's `repr(float)`.
19pub fn python_float_repr(x: f64) -> String {
20    if x.is_nan() {
21        return "NaN".into();
22    }
23    if x.is_infinite() {
24        return if x > 0.0 { "Infinity" } else { "-Infinity" }.into();
25    }
26    if x == 0.0 {
27        return if x.is_sign_negative() { "-0.0" } else { "0.0" }.into();
28    }
29
30    // `{:e}` gives the shortest round-trip digits in scientific form,
31    // e.g. "4.7e-5", "3e1", "-1.75e-4".
32    let sci = format!("{x:e}");
33    let (mantissa, exp_str) = sci.split_once('e').expect("LowerExp always emits an e");
34    let exp: i32 = exp_str.parse().expect("valid exponent");
35    let neg = mantissa.starts_with('-');
36    let digits: String = mantissa.chars().filter(char::is_ascii_digit).collect();
37
38    let body = if (-4..16).contains(&exp) {
39        // Positional notation.
40        if exp >= 0 {
41            let int_len = (exp + 1) as usize;
42            if digits.len() > int_len {
43                format!("{}.{}", &digits[..int_len], &digits[int_len..])
44            } else {
45                // Pad with zeros and force a trailing ".0" like Python.
46                format!("{}{}.0", digits, "0".repeat(int_len - digits.len()))
47            }
48        } else {
49            format!("0.{}{}", "0".repeat((-exp - 1) as usize), digits)
50        }
51    } else {
52        // Scientific notation: single leading digit, no trailing ".0",
53        // sign always present, exponent at least two digits.
54        let m = if digits.len() == 1 {
55            digits.clone()
56        } else {
57            format!("{}.{}", &digits[..1], &digits[1..])
58        };
59        let sign = if exp < 0 { '-' } else { '+' };
60        format!("{}e{}{:02}", m, sign, exp.abs())
61    };
62
63    if neg {
64        format!("-{body}")
65    } else {
66        body
67    }
68}
69
70/// Escape a string like Python's `json.dumps` with `ensure_ascii=True`.
71fn escape_string(s: &str, out: &mut String) {
72    out.push('"');
73    for c in s.chars() {
74        match c {
75            '"' => out.push_str("\\\""),
76            '\\' => out.push_str("\\\\"),
77            '\n' => out.push_str("\\n"),
78            '\r' => out.push_str("\\r"),
79            '\t' => out.push_str("\\t"),
80            '\u{8}' => out.push_str("\\b"),
81            '\u{c}' => out.push_str("\\f"),
82            c if (c as u32) < 0x20 => {
83                out.push_str(&format!("\\u{:04x}", c as u32));
84            }
85            c if c.is_ascii() => out.push(c),
86            c => {
87                let code = c as u32;
88                if code <= 0xFFFF {
89                    out.push_str(&format!("\\u{code:04x}"));
90                } else {
91                    // Surrogate pair.
92                    let v = code - 0x10000;
93                    out.push_str(&format!(
94                        "\\u{:04x}\\u{:04x}",
95                        0xD800 + (v >> 10),
96                        0xDC00 + (v & 0x3FF)
97                    ));
98                }
99            }
100        }
101    }
102    out.push('"');
103}
104
105fn write_value(value: &Value, out: &mut String) {
106    match value {
107        Value::Null => out.push_str("null"),
108        Value::Bool(true) => out.push_str("true"),
109        Value::Bool(false) => out.push_str("false"),
110        Value::Number(n) => {
111            if let Some(i) = n.as_i64() {
112                out.push_str(&i.to_string());
113            } else if let Some(u) = n.as_u64() {
114                out.push_str(&u.to_string());
115            } else {
116                out.push_str(&python_float_repr(n.as_f64().expect("finite float")));
117            }
118        }
119        Value::String(s) => escape_string(s, out),
120        Value::Array(items) => {
121            out.push('[');
122            for (i, item) in items.iter().enumerate() {
123                if i > 0 {
124                    out.push(',');
125                }
126                write_value(item, out);
127            }
128            out.push(']');
129        }
130        Value::Object(map) => {
131            out.push('{');
132            for (i, (key, item)) in map.iter().enumerate() {
133                if i > 0 {
134                    out.push(',');
135                }
136                escape_string(key, out);
137                out.push(':');
138                write_value(item, out);
139            }
140            out.push('}');
141        }
142    }
143}
144
145/// Serialize `value` exactly as Python's
146/// `json.dumps(value, sort_keys=True, separators=(",", ":"))`.
147pub fn canonical_json(value: &Value) -> String {
148    let mut out = String::new();
149    write_value(value, &mut out);
150    out
151}
152
153/// Write one indentation level (`indent` spaces per `level`) into `out`.
154fn write_indent(indent: usize, level: usize, out: &mut String) {
155    out.extend(std::iter::repeat_n(' ', indent * level));
156}
157
158fn write_value_pretty(value: &Value, indent: usize, level: usize, out: &mut String) {
159    match value {
160        Value::Object(map) => {
161            if map.is_empty() {
162                out.push_str("{}");
163                return;
164            }
165            out.push_str("{\n");
166            let last = map.len() - 1;
167            for (i, (key, item)) in map.iter().enumerate() {
168                write_indent(indent, level + 1, out);
169                escape_string(key, out);
170                out.push_str(": ");
171                write_value_pretty(item, indent, level + 1, out);
172                if i != last {
173                    out.push(',');
174                }
175                out.push('\n');
176            }
177            write_indent(indent, level, out);
178            out.push('}');
179        }
180        Value::Array(items) => {
181            if items.is_empty() {
182                out.push_str("[]");
183                return;
184            }
185            out.push_str("[\n");
186            let last = items.len() - 1;
187            for (i, item) in items.iter().enumerate() {
188                write_indent(indent, level + 1, out);
189                write_value_pretty(item, indent, level + 1, out);
190                if i != last {
191                    out.push(',');
192                }
193                out.push('\n');
194            }
195            write_indent(indent, level, out);
196            out.push(']');
197        }
198        scalar => write_value(scalar, out),
199    }
200}
201
202/// Serialize `value` exactly as Python's
203/// `json.dumps(value, indent=2, sort_keys=True) + "\n"` — used for the
204/// checkpoint manifest, which humans read directly.
205pub fn canonical_json_pretty(value: &Value) -> String {
206    let mut out = String::new();
207    write_value_pretty(value, 2, 0, &mut out);
208    out.push('\n');
209    out
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use serde_json::json;
216
217    #[test]
218    fn float_repr_matches_python() {
219        // (input, CPython repr) pairs.
220        let cases: [(f64, &str); 14] = [
221            (0.000047, "4.7e-05"),
222            (0.0001, "0.0001"),
223            (0.000175, "0.000175"),
224            (7.959543, "7.959543"),
225            (30.0, "30.0"),
226            (1.0, "1.0"),
227            (-0.5, "-0.5"),
228            (0.1, "0.1"),
229            (1e-5, "1e-05"),
230            (1e22, "1e+22"),
231            (1e16, "1e+16"),
232            (9999999999999998.0, "9999999999999998.0"),
233            (0.0, "0.0"),
234            (1.414, "1.414"),
235        ];
236        for (x, expected) in cases {
237            assert_eq!(python_float_repr(x), expected, "for {x}");
238        }
239    }
240
241    #[test]
242    fn canonical_matches_python_layout() {
243        let value = json!({
244            "b": [1, 2.5, null, true],
245            "a": {"nested": "ok", "t": 4.7e-5},
246        });
247        assert_eq!(
248            canonical_json(&value),
249            r#"{"a":{"nested":"ok","t":4.7e-05},"b":[1,2.5,null,true]}"#
250        );
251    }
252
253    #[test]
254    fn pretty_matches_python_indent2_layout() {
255        // CPython: json.dumps({"b": [1, {"z": 1, "a": 2}], "a": {}}, indent=2,
256        //                     sort_keys=True) + "\n"
257        let value = json!({"b": [1, {"z": 1, "a": 2}], "a": {}});
258        assert_eq!(
259            canonical_json_pretty(&value),
260            "{\n  \"a\": {},\n  \"b\": [\n    1,\n    {\n      \"a\": 2,\n      \"z\": 1\n    }\n  ]\n}\n"
261        );
262    }
263
264    #[test]
265    fn pretty_empty_object_and_array() {
266        assert_eq!(canonical_json_pretty(&json!({})), "{}\n");
267        assert_eq!(canonical_json_pretty(&json!([])), "[]\n");
268    }
269
270    #[test]
271    fn non_ascii_escaped_like_python() {
272        // CPython: json.dumps({"s": "héllo — 🎉"}, sort_keys=True,
273        //                     separators=(",", ":"))
274        let value = json!({"s": "héllo — 🎉"});
275        assert_eq!(
276            canonical_json(&value),
277            "{\"s\":\"h\\u00e9llo \\u2014 \\ud83c\\udf89\"}"
278        );
279    }
280}