Skip to main content

somatize_core/
canon.rs

1//! Canonical, deterministic serialization for cache-key hashing.
2//!
3//! Cache keys must be identical across processes, machines, and library
4//! versions. Ordinary serializer output is not: `serde_json` encodes
5//! `HashMap`s in random iteration order, and pickle-style formats depend
6//! on traversal order and library version. This module produces a
7//! canonical CBOR encoding (RFC 8949 §4.2 core deterministic encoding)
8//! with dCBOR-style float canonicalization:
9//!
10//! - map keys sorted bytewise by their encoded form (duplicates are an error)
11//! - a single canonical NaN bit pattern
12//! - `-0.0` normalized to `+0.0`
13//!
14//! Use [`canonical_bytes`] / [`hash_canonical`] for anything that feeds a
15//! [`CacheKey`]; never hash raw serializer output.
16
17use crate::cache::CacheKey;
18use crate::error::{Result, SomaError};
19use ciborium::value::Value as Cbor;
20use serde::Serialize;
21
22/// Encode `value` to canonical CBOR bytes.
23///
24/// Errors when the value cannot be serialized — for cache keys that must
25/// mean "uncacheable", never a silent fallback encoding.
26pub fn canonical_bytes<T: Serialize + ?Sized>(value: &T) -> Result<Vec<u8>> {
27    let mut raw = Vec::new();
28    ciborium::ser::into_writer(value, &mut raw)
29        .map_err(|e| SomaError::Cache(format!("not canonically serializable: {e}")))?;
30    let decoded: Cbor = ciborium::de::from_reader(raw.as_slice())
31        .map_err(|e| SomaError::Cache(format!("canonical re-decode failed: {e}")))?;
32    let canon = canonicalize(decoded)?;
33    encode(&canon)
34}
35
36/// Hash `value`'s canonical CBOR encoding.
37pub fn hash_canonical<T: Serialize + ?Sized>(value: &T) -> Result<CacheKey> {
38    Ok(CacheKey::hash_data(&canonical_bytes(value)?))
39}
40
41fn encode(v: &Cbor) -> Result<Vec<u8>> {
42    let mut buf = Vec::new();
43    ciborium::ser::into_writer(v, &mut buf)
44        .map_err(|e| SomaError::Cache(format!("canonical encode failed: {e}")))?;
45    Ok(buf)
46}
47
48fn canonicalize(v: Cbor) -> Result<Cbor> {
49    Ok(match v {
50        Cbor::Float(f) => Cbor::Float(canonical_float(f)),
51        Cbor::Array(items) => {
52            Cbor::Array(items.into_iter().map(canonicalize).collect::<Result<_>>()?)
53        }
54        Cbor::Map(entries) => {
55            let mut encoded: Vec<(Vec<u8>, Cbor, Cbor)> = Vec::with_capacity(entries.len());
56            for (key, value) in entries {
57                let key = canonicalize(key)?;
58                let value = canonicalize(value)?;
59                let key_bytes = encode(&key)?;
60                encoded.push((key_bytes, key, value));
61            }
62            encoded.sort_by(|a, b| a.0.cmp(&b.0));
63            for pair in encoded.windows(2) {
64                if pair[0].0 == pair[1].0 {
65                    return Err(SomaError::Cache(
66                        "duplicate map key in canonical encoding".into(),
67                    ));
68                }
69            }
70            Cbor::Map(encoded.into_iter().map(|(_, k, v)| (k, v)).collect())
71        }
72        Cbor::Tag(tag, inner) => Cbor::Tag(tag, Box::new(canonicalize(*inner)?)),
73        other => other,
74    })
75}
76
77/// dCBOR float rules: one NaN bit pattern, no negative zero.
78fn canonical_float(f: f64) -> f64 {
79    if f.is_nan() {
80        f64::NAN
81    } else if f == 0.0 {
82        0.0
83    } else {
84        f
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use std::collections::HashMap;
92
93    #[test]
94    fn hashmap_encoding_is_order_independent() {
95        // The historical bug: serde_json::to_vec of a HashMap is
96        // iteration-order dependent. Canonical CBOR must not be.
97        let mut reference: Option<Vec<u8>> = None;
98        for i in 0..100 {
99            let mut map = HashMap::new();
100            // Insert in varying orders to shuffle bucket layouts.
101            let keys = ["alpha", "beta", "gamma", "delta", "epsilon"];
102            for (j, _k) in keys.iter().enumerate() {
103                let idx = (i + j) % keys.len();
104                map.insert(keys[idx].to_string(), idx as i64);
105            }
106            let bytes = canonical_bytes(&map).unwrap();
107            match &reference {
108                None => reference = Some(bytes),
109                Some(r) => assert_eq!(r, &bytes, "iteration {i} diverged"),
110            }
111        }
112    }
113
114    #[test]
115    fn nested_structures_canonicalize() {
116        #[derive(serde::Serialize)]
117        struct Config {
118            name: String,
119            params: HashMap<String, f64>,
120            layers: Vec<u32>,
121        }
122        let mut params = HashMap::new();
123        params.insert("lr".into(), 0.001);
124        params.insert("momentum".into(), 0.9);
125        let a = Config {
126            name: "m".into(),
127            params: params.clone(),
128            layers: vec![64, 32],
129        };
130        let b = Config {
131            name: "m".into(),
132            params,
133            layers: vec![64, 32],
134        };
135        assert_eq!(hash_canonical(&a).unwrap(), hash_canonical(&b).unwrap());
136    }
137
138    #[test]
139    fn negative_zero_normalizes() {
140        assert_eq!(
141            canonical_bytes(&(-0.0f64)).unwrap(),
142            canonical_bytes(&0.0f64).unwrap()
143        );
144    }
145
146    #[test]
147    fn nan_payloads_collapse_to_one_encoding() {
148        let quiet = f64::NAN;
149        let payload = f64::from_bits(0x7ff8_0000_0000_0001);
150        assert!(payload.is_nan());
151        assert_eq!(
152            canonical_bytes(&quiet).unwrap(),
153            canonical_bytes(&payload).unwrap()
154        );
155    }
156
157    #[test]
158    fn distinct_values_distinct_hashes() {
159        assert_ne!(
160            hash_canonical(&vec![1.0f64, 2.0]).unwrap(),
161            hash_canonical(&vec![2.0f64, 1.0]).unwrap()
162        );
163        assert_ne!(hash_canonical(&"a").unwrap(), hash_canonical(&"b").unwrap());
164    }
165
166    #[test]
167    fn float_and_int_do_not_collide() {
168        // 1u64 and 1.0f64 encode differently in CBOR (major type 0 vs 7).
169        assert_ne!(
170            canonical_bytes(&1u64).unwrap(),
171            canonical_bytes(&1.0f64).unwrap()
172        );
173    }
174}