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}