Skip to main content

gam_runtime/warm_start/
key.rs

1//! Fingerprint keying for the warm-start store.
2//!
3//! A [`Fingerprint`] is a SHA-256 hash; two fits whose (data, spec) byte
4//! representations agree under [`Fingerprinter`] absorption produce the same
5//! key. Adversarial collisions don't matter — per-variant warm-start
6//! validators are the correctness fail-safe; the fingerprint is just a fast
7//! filter.
8
9use serde::de::{self, Visitor};
10use serde::{Deserialize, Deserializer, Serialize, Serializer};
11use sha2::{Digest, Sha256};
12use std::fmt;
13
14/// 256-bit warm-start key.
15#[derive(Clone, Copy, PartialEq, Eq, Hash)]
16pub struct Fingerprint([u8; 32]);
17
18impl Serialize for Fingerprint {
19    /// Serialize as the canonical 64-char lowercase hex string so on-disk
20    /// payloads carrying a `Fingerprint` (e.g. the cross-fit `FitArtifact`
21    /// term identities) are stable and human-readable.
22    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
23        serializer.serialize_str(&self.to_hex())
24    }
25}
26
27impl<'de> Deserialize<'de> for Fingerprint {
28    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
29        struct HexVisitor;
30        impl Visitor<'_> for HexVisitor {
31            type Value = Fingerprint;
32            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33                f.write_str("a 64-character hex-encoded SHA-256 fingerprint")
34            }
35            fn visit_str<E: de::Error>(self, v: &str) -> Result<Fingerprint, E> {
36                Fingerprint::from_hex(v).ok_or_else(|| de::Error::custom("invalid hex fingerprint"))
37            }
38        }
39        deserializer.deserialize_str(HexVisitor)
40    }
41}
42
43impl Fingerprint {
44    pub const fn as_bytes(&self) -> &[u8; 32] {
45        &self.0
46    }
47
48    pub fn to_hex(&self) -> String {
49        let mut s = String::with_capacity(64);
50        for b in &self.0 {
51            use std::fmt::Write;
52            write!(&mut s, "{:02x}", b).expect("writing to String is infallible");
53        }
54        s
55    }
56
57    pub fn from_hex(s: &str) -> Option<Self> {
58        if s.len() != 64 {
59            return None;
60        }
61        let bytes = s.as_bytes();
62        let mut out = [0u8; 32];
63        for i in 0..32 {
64            let hi = from_hex_nibble(bytes[2 * i])?;
65            let lo = from_hex_nibble(bytes[2 * i + 1])?;
66            out[i] = (hi << 4) | lo;
67        }
68        Some(Fingerprint(out))
69    }
70}
71
72const fn from_hex_nibble(c: u8) -> Option<u8> {
73    match c {
74        b'0'..=b'9' => Some(c - b'0'),
75        b'a'..=b'f' => Some(c - b'a' + 10),
76        b'A'..=b'F' => Some(c - b'A' + 10),
77        _ => None,
78    }
79}
80
81impl fmt::Debug for Fingerprint {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        write!(f, "Fingerprint({})", self.to_hex())
84    }
85}
86
87impl fmt::Display for Fingerprint {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        f.write_str(&self.to_hex())
90    }
91}
92
93/// Streaming hasher for building a [`Fingerprint`].
94///
95/// Each `absorb_*` writes a per-type discriminator byte, the caller's content
96/// tag, and a length before the data, so `absorb_f64(b"x", 0.5)` cannot
97/// collide with `absorb_bytes(b"x", <the 8 little-endian bytes of 0.5>)`, nor
98/// with `absorb_u64(b"x", 0.5f64.to_bits())` — heterogeneous fields sharing a
99/// tag can never alias.
100pub struct Fingerprinter {
101    h: Sha256,
102}
103
104/// Per-type frame discriminators for the `absorb_*` family. Written before
105/// the content tag so values of different primitive types absorbed under the
106/// same tag with coinciding payload bytes still produce distinct digests.
107mod type_code {
108    pub const TAG: u8 = 0;
109    pub const BYTES: u8 = 1;
110    pub const STR: u8 = 2;
111    pub const U64: u8 = 3;
112}
113
114impl Fingerprinter {
115    pub fn new() -> Self {
116        Self { h: Sha256::new() }
117    }
118
119    /// Write one frame header: type discriminator, then length-prefixed tag.
120    fn frame(&mut self, code: u8, tag: &[u8]) {
121        self.h.update([code]);
122        let tag_len = u32::try_from(tag.len()).expect("fingerprint tag length must fit in u32");
123        self.h.update(tag_len.to_le_bytes());
124        self.h.update(tag);
125    }
126
127    /// Absorb a tag with no payload. Useful for structural separators.
128    pub fn absorb_tag(&mut self, tag: &[u8]) {
129        self.frame(type_code::TAG, tag);
130    }
131
132    pub fn absorb_bytes(&mut self, tag: &[u8], data: &[u8]) {
133        self.frame(type_code::BYTES, tag);
134        self.h.update((data.len() as u64).to_le_bytes());
135        self.h.update(data);
136    }
137
138    pub fn absorb_str(&mut self, tag: &[u8], s: &str) {
139        self.frame(type_code::STR, tag);
140        self.h.update((s.len() as u64).to_le_bytes());
141        self.h.update(s.as_bytes());
142    }
143
144    pub fn absorb_u64(&mut self, tag: &[u8], v: u64) {
145        self.frame(type_code::U64, tag);
146        self.h.update(v.to_le_bytes());
147    }
148
149    pub fn finalize(self) -> Fingerprint {
150        let out = self.h.finalize();
151        let mut bytes = [0u8; 32];
152        bytes.copy_from_slice(&out);
153        Fingerprint(bytes)
154    }
155
156    // ------------------------------------------------------------------
157    // Untagged write_* API — drop-in replacement for the formerly-separate
158    // `StableHasher` (warm-start) and `CacheDigestBuilder` (latent_cache)
159    // hashers. Callers that use this API are responsible for their own
160    // type-disambiguation (typically by writing a leading namespace string
161    // via `write_str`); the `absorb_*` family above prepends per-call tags
162    // and is the safer choice for new code. Callers that use the untagged API
163    // must preserve their own hand-framed input protocol.
164    // ------------------------------------------------------------------
165
166    pub fn write_bytes(&mut self, data: &[u8]) {
167        self.h.update(data);
168    }
169
170    pub fn write_u8(&mut self, value: u8) {
171        self.h.update([value]);
172    }
173
174    pub fn write_bool(&mut self, value: bool) {
175        self.h.update([u8::from(value)]);
176    }
177
178    pub fn write_u64(&mut self, value: u64) {
179        self.h.update(value.to_le_bytes());
180    }
181
182    pub fn write_usize(&mut self, value: usize) {
183        self.h.update((value as u64).to_le_bytes());
184    }
185
186    pub fn write_f64(&mut self, value: f64) {
187        // Normalize -0.0 to +0.0 so signed-zero comparison ambiguity does
188        // not split warm-start key buckets — matches the prior `StableHasher`
189        // contract that warm-start keys depended on.
190        let normalized = if value == 0.0 { 0.0 } else { value };
191        self.h.update(normalized.to_bits().to_le_bytes());
192    }
193
194    pub fn write_str(&mut self, value: &str) {
195        self.write_usize(value.len());
196        self.h.update(value.as_bytes());
197    }
198
199    /// Absorb a length-prefixed `f64` slice using the per-element
200    /// [`Fingerprinter::write_f64`] contract (so `-0.0` is normalized to
201    /// `+0.0`). Canonical home for the byte-identical `len`-then-each-`f64`
202    /// hashing that previously lived as module-local `hash_f64_slice` /
203    /// `hash_vector` copies in `solver/latent_cache`. Uses a bulk byte path
204    /// only when it can emit exactly the same bytes as the element-wise
205    /// normalizing protocol.
206    pub fn write_f64_slice(&mut self, values: &[f64]) {
207        self.write_usize(values.len());
208        self.write_f64_slice_payload(values);
209    }
210
211    fn write_f64_slice_payload(&mut self, values: &[f64]) {
212        #[cfg(target_endian = "little")]
213        {
214            let needs_normalization = values
215                .iter()
216                .any(|&value| value.is_nan() || (value == 0.0 && value.is_sign_negative()));
217            if !needs_normalization {
218                // SAFETY: values.as_ptr() is valid for values.len() contiguous
219                // f64s, f64 has no padding, and reborrowing as bytes is confined
220                // to this update call. Little-endian storage matches write_f64's
221                // to_bits().to_le_bytes() byte stream for non-normalized values.
222                let bytes = unsafe {
223                    std::slice::from_raw_parts(
224                        values.as_ptr() as *const u8,
225                        std::mem::size_of_val(values),
226                    )
227                };
228                self.h.update(bytes);
229                return;
230            }
231        }
232        self.write_f64_slice_payload_slow(values);
233    }
234
235    fn write_f64_slice_payload_slow(&mut self, values: &[f64]) {
236        for &value in values {
237            self.write_f64(value);
238        }
239    }
240
241    /// Absorb a 1D `f64` array as `len` followed by every element via
242    /// [`Fingerprinter::write_f64`]. Canonical home for the byte-identical
243    /// `hash_vector` copy that previously lived in `solver/latent_cache`.
244    pub fn write_f64_array1(&mut self, values: &ndarray::Array1<f64>) {
245        self.write_usize(values.len());
246        if let Some(slice) = values.as_slice() {
247            self.write_f64_slice_payload(slice);
248        } else {
249            self.write_f64_slice_payload_slow_iter(values.iter().copied());
250        }
251    }
252
253    /// Absorb a 2D `f64` array as `(nrows, ncols)` followed by every element in
254    /// iteration order, each via [`Fingerprinter::write_f64`]. Canonical home
255    /// for the byte-identical heuristic that previously lived as module-local
256    /// `write_array2_fingerprint` (`solver/arrow_schur`) and `hash_matrix`
257    /// (`solver/latent_cache`) copies.
258    pub fn write_f64_array2(&mut self, values: &ndarray::Array2<f64>) {
259        self.write_usize(values.nrows());
260        self.write_usize(values.ncols());
261        if let Some(slice) = values.as_slice() {
262            self.write_f64_slice_payload(slice);
263        } else {
264            self.write_f64_slice_payload_slow_iter(values.iter().copied());
265        }
266    }
267
268    fn write_f64_slice_payload_slow_iter<I>(&mut self, values: I)
269    where
270        I: IntoIterator<Item = f64>,
271    {
272        for value in values {
273            self.write_f64(value);
274        }
275    }
276
277    /// Finalize and return the first 8 bytes of the SHA-256 digest as a
278    /// little-endian `u64`. Used by callers that need a compact in-process
279    /// identifier (manifold mode fingerprints, registry fingerprints, …)
280    /// rather than the full 32-byte [`Fingerprint`].
281    pub fn finish_u64(self) -> u64 {
282        let out = self.h.finalize();
283        let mut bytes = [0u8; 8];
284        bytes.copy_from_slice(&out[..8]);
285        u64::from_le_bytes(bytes)
286    }
287
288    /// Finalize and return a zero-padded 16-character hex representation
289    /// of [`Fingerprinter::finish_u64`], suitable for embedding directly
290    /// in cache-key strings.
291    pub fn finish_hex(self) -> String {
292        format!("{:016x}", self.finish_u64())
293    }
294}
295
296impl Default for Fingerprinter {
297    fn default() -> Self {
298        Self::new()
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn write_f64_slice_bulk_matches_element_protocol() {
308        fn pseudo_random_values(n: usize) -> Vec<f64> {
309            let mut state = 0x4d59_5df4_d0f3_3173_u64;
310            let mut values = Vec::with_capacity(n);
311            for idx in 0..n {
312                state = state
313                    .wrapping_mul(6364136223846793005)
314                    .wrapping_add(1442695040888963407);
315                let mantissa = state >> 12;
316                let unit = f64::from_bits(0x3ff0_0000_0000_0000 | mantissa) - 1.0;
317                values.push((unit - 0.5) * ((idx % 17) as f64 + 1.0));
318            }
319            values
320        }
321
322        fn fast_key(values: &[f64]) -> Fingerprint {
323            let mut fp = Fingerprinter::new();
324            fp.write_str("write_f64_slice_bulk_matches_element_protocol");
325            fp.write_f64_slice(values);
326            fp.finalize()
327        }
328
329        fn slow_key(values: &[f64]) -> Fingerprint {
330            let mut fp = Fingerprinter::new();
331            fp.write_str("write_f64_slice_bulk_matches_element_protocol");
332            fp.write_usize(values.len());
333            fp.write_f64_slice_payload_slow(values);
334            fp.finalize()
335        }
336
337        let clean = pseudo_random_values(257);
338        assert_eq!(fast_key(&clean), slow_key(&clean));
339
340        let mut normalized = clean.clone();
341        normalized[7] = -0.0;
342        normalized[113] = f64::from_bits(0x7ff8_0000_0000_0042);
343        assert_eq!(fast_key(&normalized), slow_key(&normalized));
344    }
345
346    #[test]
347    fn write_f64_arrays_match_element_protocol() {
348        let values = ndarray::Array2::from_shape_vec(
349            (3, 4),
350            vec![
351                1.25,
352                -2.5,
353                3.75,
354                4.0,
355                5.5,
356                -0.0,
357                7.25,
358                8.5,
359                9.75,
360                10.0,
361                f64::from_bits(0x7ff8_0000_0000_0100),
362                12.25,
363            ],
364        )
365        .expect("test array shape is valid");
366
367        let mut fast = Fingerprinter::new();
368        fast.write_str("write_f64_arrays_match_element_protocol");
369        fast.write_f64_array2(&values);
370
371        let mut slow = Fingerprinter::new();
372        slow.write_str("write_f64_arrays_match_element_protocol");
373        slow.write_usize(values.nrows());
374        slow.write_usize(values.ncols());
375        slow.write_f64_slice_payload_slow_iter(values.iter().copied());
376
377        assert_eq!(fast.finalize(), slow.finalize());
378    }
379
380    #[test]
381    fn fingerprint_serde_roundtrips_as_hex() {
382        let mut fp = Fingerprinter::new();
383        fp.absorb_str(b"k", "fingerprint-serde");
384        let key = fp.finalize();
385        let json = serde_json::to_string(&key).expect("serialize");
386        // Serialized form is the canonical quoted hex string.
387        assert_eq!(json, format!("\"{}\"", key.to_hex()));
388        let back: Fingerprint = serde_json::from_str(&json).expect("deserialize");
389        assert_eq!(key, back);
390        // A malformed hex payload is rejected, not silently aliased.
391        assert!(serde_json::from_str::<Fingerprint>("\"not-hex\"").is_err());
392    }
393
394    #[test]
395    fn invalid_hex_rejected() {
396        assert!(Fingerprint::from_hex("not hex").is_none());
397        assert!(Fingerprint::from_hex(&"a".repeat(63)).is_none());
398        assert!(Fingerprint::from_hex(&"z".repeat(64)).is_none());
399    }
400}