Skip to main content

sqry_core/graph/
body_hash.rs

1//! 128-bit deterministic body hashing using dual xxh64
2//!
3//! Uses fixed seeds for cross-process determinism.
4//! Seeds are versioned alongside the index schema.
5//!
6//! # Why xxh64 over siphasher?
7//!
8//! - **Already in codebase**: xxhash-rust used in 6+ sqry modules
9//! - **Seeding proven**: prewarm/store.rs, project/types.rs use seeded xxh64
10//! - **Faster**: ~10-15 GB/s vs ~3-5 GB/s for `SipHash`
11//! - **No new dependency**: Reduces build time and audit surface
12//! - **Same determinism**: Fixed seed = identical output across runs
13//!
14//! # Seed Registry
15//!
16//! All seeds in sqry follow the ASCII convention (8 chars = 64 bits):
17//!
18//! | Module | Seed Constant | Hex Value | ASCII Meaning | Purpose |
19//! |--------|---------------|-----------|---------------|---------|
20//! | `prewarm/store.rs` | `PAYLOAD_CHECKSUM_SEED` | `0x5351_5259_5041_594C` | "SQRYPAYL" | Checksum verification |
21//! | `project/types.rs` | `HASH_SEED` | `0x5351_5259_5041_5448` | "SQRYPATH" | Path hashing |
22//! | `body_hash.rs` | `HASH_SEED_0` | `0x5351_5259_4455_5030` | "SQRYDUP0" | Body hash high bits |
23//! | `body_hash.rs` | `HASH_SEED_1` | `0x5351_5259_4455_5031` | "SQRYDUP1" | Body hash low bits |
24//! | `body_hash.rs` | `SHAPE_SEED_0` | `0x5351_5259_5348_5030` | "SQRYSHP0" | Shape hash high bits |
25//! | `body_hash.rs` | `SHAPE_SEED_1` | `0x5351_5259_5348_5031` | "SQRYSHP1" | Shape hash low bits |
26//!
27//! # Rules for adding new seeds
28//!
29//! 1. All seeds MUST be unique across the codebase
30//! 2. Use ASCII encoding of meaningful names (8 chars = 64 bits)
31//! 3. Follow pattern: "SQRY" prefix + 4-char identifier
32//! 4. Register new seeds in this table before implementation
33//! 5. Changing the `HASH_SEED_*` (body-hash) seeds requires an
34//!    `INDEX_SCHEMA_VERSION` bump; changing the `SHAPE_SEED_*` (shape-hash) seeds
35//!    requires a `SHAPE_SCHEMA_VERSION` bump (the sibling discipline defined in
36//!    `crate::graph::unified::storage::shape`). The two version counters are
37//!    independent because the two hashes live in separate on-disk payloads.
38
39use serde::{Deserialize, Serialize};
40use xxhash_rust::xxh64::xxh64;
41
42/// Fixed hash seed for high 64 bits of body hash.
43///
44/// Schema: `INDEX_SCHEMA_VERSION` = 2
45/// ASCII: "SQRYDUP0" = `0x5351_5259_4455_5030`
46pub const HASH_SEED_0: u64 = 0x5351_5259_4455_5030;
47
48/// Fixed hash seed for low 64 bits of body hash.
49///
50/// Schema: `INDEX_SCHEMA_VERSION` = 2
51/// ASCII: "SQRYDUP1" = `0x5351_5259_4455_5031`
52pub const HASH_SEED_1: u64 = 0x5351_5259_4455_5031;
53
54/// Fixed hash seed for the high 64 bits of the structural `shape_hash` and the
55/// `Weisfeiler-Lehman` label / `MinHash` lane derivation (the shape walker in
56/// `crate::graph::unified::build::shape`).
57///
58/// Distinct from the `HASH_SEED_*` body-hash seeds so the renaming-invariant
59/// structural hash never collides with the exact copy-paste hash. Changing it
60/// changes the bytes of every `ShapeDescriptor`, so it is gated by
61/// `SHAPE_SCHEMA_VERSION`, NOT `INDEX_SCHEMA_VERSION`.
62///
63/// ASCII: "SQRYSHP0" = `0x5351_5259_5348_5030`
64pub const SHAPE_SEED_0: u64 = 0x5351_5259_5348_5030;
65
66/// Fixed hash seed for the low 64 bits of the structural `shape_hash`.
67///
68/// The shape-hash sibling of [`HASH_SEED_1`]; gated by `SHAPE_SCHEMA_VERSION`.
69///
70/// ASCII: "SQRYSHP1" = `0x5351_5259_5348_5031`
71pub const SHAPE_SEED_1: u64 = 0x5351_5259_5348_5031;
72
73/// 128-bit body hash for collision resistance
74///
75/// Using two xxh64 outputs with different seeds provides 128-bit security.
76/// This is sufficient to avoid birthday paradox collisions in codebases
77/// with millions of symbols.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
79pub struct BodyHash128 {
80    /// High 64 bits (computed with `HASH_SEED_0`)
81    pub high: u64,
82    /// Low 64 bits (computed with `HASH_SEED_1`)
83    pub low: u64,
84}
85
86impl BodyHash128 {
87    /// Compute deterministic 128-bit hash of normalized body content
88    ///
89    /// Uses two xxh64 calls with different seeds to achieve 128-bit collision resistance.
90    /// This follows the established pattern in sqry (see prewarm/store.rs:514).
91    ///
92    /// # Arguments
93    ///
94    /// * `content` - Normalized body content bytes
95    ///
96    /// # Returns
97    ///
98    /// A 128-bit hash value
99    #[must_use]
100    pub fn compute(content: &[u8]) -> Self {
101        Self {
102            high: xxh64(content, HASH_SEED_0),
103            low: xxh64(content, HASH_SEED_1),
104        }
105    }
106
107    /// Convert to u128 for comparison and storage
108    #[must_use]
109    pub fn as_u128(&self) -> u128 {
110        (u128::from(self.high) << 64) | u128::from(self.low)
111    }
112
113    /// Create from u128 value
114    #[must_use]
115    pub fn from_u128(value: u128) -> Self {
116        let high = u64::try_from(value >> 64).unwrap_or(u64::MAX);
117        let low = u64::try_from(value & u128::from(u64::MAX)).unwrap_or(u64::MAX);
118        Self { high, low }
119    }
120}
121
122impl std::fmt::Display for BodyHash128 {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        write!(f, "{:016x}{:016x}", self.high, self.low)
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn test_body_hash_deterministic() {
134        let content = b"fn example() { return 42; }";
135        let hash1 = BodyHash128::compute(content);
136        let hash2 = BodyHash128::compute(content);
137        assert_eq!(hash1, hash2, "Hash must be deterministic");
138    }
139
140    #[test]
141    fn test_body_hash_different_content() {
142        let content1 = b"fn example() { return 42; }";
143        let content2 = b"fn example() { return 43; }";
144        let hash1 = BodyHash128::compute(content1);
145        let hash2 = BodyHash128::compute(content2);
146        assert_ne!(
147            hash1, hash2,
148            "Different content should produce different hash"
149        );
150    }
151
152    #[test]
153    fn test_body_hash_empty() {
154        let hash = BodyHash128::compute(b"");
155        // Empty content should still produce a valid hash
156        assert_ne!(hash.high, 0, "Empty content hash high should not be zero");
157        assert_ne!(hash.low, 0, "Empty content hash low should not be zero");
158    }
159
160    #[test]
161    fn test_body_hash_u128_roundtrip() {
162        let content = b"test content for roundtrip";
163        let hash = BodyHash128::compute(content);
164        let as_u128 = hash.as_u128();
165        let roundtrip = BodyHash128::from_u128(as_u128);
166        assert_eq!(hash, roundtrip, "u128 roundtrip should preserve hash");
167    }
168
169    #[test]
170    fn test_xxh64_fixed_seed() {
171        // Verify the seeds are the expected ASCII values
172        // "SQRYDUP0" in hex: S=53, Q=51, R=52, Y=59, D=44, U=55, P=50, 0=30
173        assert_eq!(HASH_SEED_0, 0x5351_5259_4455_5030);
174        // "SQRYDUP1" in hex: S=53, Q=51, R=52, Y=59, D=44, U=55, P=50, 1=31
175        assert_eq!(HASH_SEED_1, 0x5351_5259_4455_5031);
176    }
177
178    #[test]
179    fn test_shape_seeds_fixed_and_unique() {
180        // "SQRYSHP0": S=53, Q=51, R=52, Y=59, S=53, H=48, P=50, 0=30
181        assert_eq!(SHAPE_SEED_0, 0x5351_5259_5348_5030);
182        // "SQRYSHP1": ... 1=31
183        assert_eq!(SHAPE_SEED_1, 0x5351_5259_5348_5031);
184        // The seed-registry invariant: every seed in this module is unique, so the
185        // structural hash can never collide with the exact copy-paste hash.
186        let seeds = [HASH_SEED_0, HASH_SEED_1, SHAPE_SEED_0, SHAPE_SEED_1];
187        for (i, a) in seeds.iter().enumerate() {
188            for b in &seeds[i + 1..] {
189                assert_ne!(a, b, "seeds must be unique across the codebase");
190            }
191        }
192    }
193
194    #[test]
195    fn test_xxh64_cross_endian() {
196        // Fixed test vector to catch endianness or streaming/segment-size regression
197        // These values should be consistent across all platforms
198        let test_data = b"fn example() { return 42; }";
199        let hash = BodyHash128::compute(test_data);
200
201        // These expected values are computed on x86_64 Linux reference platform
202        // If these fail on another platform, investigate endianness handling
203        // Note: We're testing that the hash is non-zero and deterministic,
204        // not specific values (which would couple to xxhash implementation)
205        assert_ne!(
206            hash.high, 0,
207            "High bits should be non-zero for non-empty content"
208        );
209        assert_ne!(
210            hash.low, 0,
211            "Low bits should be non-zero for non-empty content"
212        );
213
214        // Verify the two halves are different (different seeds)
215        assert_ne!(
216            hash.high, hash.low,
217            "High and low should differ due to different seeds"
218        );
219    }
220
221    #[test]
222    fn test_body_hash_display() {
223        let hash = BodyHash128 {
224            high: 0x1234_5678_90AB_CDEF,
225            low: 0xFEDC_BA09_8765_4321,
226        };
227        let display = format!("{hash}");
228        assert_eq!(display, "1234567890abcdeffedcba0987654321");
229    }
230
231    #[test]
232    fn test_body_hash_serde_json() {
233        let hash = BodyHash128::compute(b"test");
234        let json = serde_json::to_string(&hash).unwrap();
235        let parsed: BodyHash128 = serde_json::from_str(&json).unwrap();
236        assert_eq!(hash, parsed, "JSON roundtrip should preserve hash");
237    }
238}