1use sha2::{Digest, Sha256};
38
39const HASH_DOMAIN: &[u8] = b"ferrox-kv-block-v1";
44
45const TAG_ROOT: &[u8] = b"root";
46const TAG_BLOCK: &[u8] = b"block";
47
48#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
51pub struct BlockHash([u8; 32]);
52
53impl BlockHash {
54 pub fn from_bytes(bytes: [u8; 32]) -> Self {
55 BlockHash(bytes)
56 }
57
58 pub fn as_bytes(&self) -> &[u8; 32] {
59 &self.0
60 }
61
62 pub fn to_hex(&self) -> String {
65 let mut out = String::with_capacity(64);
66 for byte in self.0 {
67 out.push(char::from_digit((byte >> 4) as u32, 16).unwrap());
68 out.push(char::from_digit((byte & 0xf) as u32, 16).unwrap());
69 }
70 out
71 }
72
73 pub fn shard_prefix(&self, n: usize) -> String {
77 self.to_hex().chars().take(n.min(64)).collect()
78 }
79}
80
81impl std::fmt::Debug for BlockHash {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 write!(f, "BlockHash({}…)", self.shard_prefix(12))
87 }
88}
89
90impl std::fmt::Display for BlockHash {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 f.write_str(&self.to_hex())
93 }
94}
95
96fn absorb(hasher: &mut Sha256, field: &[u8]) {
99 hasher.update((field.len() as u64).to_le_bytes());
100 hasher.update(field);
101}
102
103#[derive(Clone, Debug)]
106pub struct BlockHasher {
107 model: String,
108 extra_keys: Vec<String>,
109 root: BlockHash,
110}
111
112impl BlockHasher {
113 pub fn new<S: AsRef<str>>(model: impl Into<String>, extra_keys: &[S]) -> Self {
120 let model = model.into();
121 let extra_keys: Vec<String> = extra_keys.iter().map(|k| k.as_ref().to_string()).collect();
122 let mut hasher = Sha256::new();
123 absorb(&mut hasher, HASH_DOMAIN);
124 absorb(&mut hasher, TAG_ROOT);
125 absorb(&mut hasher, model.as_bytes());
126 absorb_extra_keys(&mut hasher, &extra_keys);
127 let root = BlockHash(hasher.finalize().into());
128 BlockHasher {
129 model,
130 extra_keys,
131 root,
132 }
133 }
134
135 pub fn root(&self) -> BlockHash {
140 self.root
141 }
142
143 pub fn model(&self) -> &str {
144 &self.model
145 }
146
147 pub fn extra_keys(&self) -> &[String] {
148 &self.extra_keys
149 }
150
151 pub fn block(&self, parent: &BlockHash, token_ids: &[usize]) -> BlockHash {
158 let mut hasher = Sha256::new();
159 absorb(&mut hasher, HASH_DOMAIN);
160 absorb(&mut hasher, TAG_BLOCK);
161 absorb(&mut hasher, self.model.as_bytes());
162 absorb_extra_keys(&mut hasher, &self.extra_keys);
163 absorb(&mut hasher, parent.as_bytes());
164 hasher.update((token_ids.len() as u64).to_le_bytes());
165 for &token in token_ids {
166 hasher.update((token as u64).to_le_bytes());
167 }
168 BlockHash(hasher.finalize().into())
169 }
170
171 pub fn chain(&self, tokens: &[usize], block_size: usize) -> Vec<BlockHash> {
183 assert!(block_size > 0, "block_size must be positive");
184 let mut parent = self.root;
185 let mut out = Vec::with_capacity(full_blocks(tokens.len(), block_size));
186 for block in tokens.chunks_exact(block_size) {
187 parent = self.block(&parent, block);
188 out.push(parent);
189 }
190 out
191 }
192}
193
194fn absorb_extra_keys(hasher: &mut Sha256, extra_keys: &[String]) {
195 hasher.update((extra_keys.len() as u64).to_le_bytes());
196 for key in extra_keys {
197 absorb(hasher, key.as_bytes());
198 }
199}
200
201pub fn full_blocks(token_count: usize, block_size: usize) -> usize {
203 assert!(block_size > 0, "block_size must be positive");
204 token_count / block_size
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 fn hasher() -> BlockHasher {
212 BlockHasher::new("model-a", &[] as &[&str])
213 }
214
215 #[test]
216 fn hashing_is_deterministic() {
217 let h = hasher();
218 let a = h.chain(&[1, 2, 3, 4], 2);
219 let b = BlockHasher::new("model-a", &[] as &[&str]).chain(&[1, 2, 3, 4], 2);
220 assert_eq!(a, b);
221 assert_eq!(a.len(), 2);
222 }
223
224 #[test]
234 fn hash_encoding_is_stable() {
235 let h = BlockHasher::new("model-a", &["lora:alpha"]);
236 assert_eq!(
237 h.root().to_hex(),
238 "95ce1d4f327b6b56c46ba4a5aeda5f087caaa9383ce4e60f2d8aa178ff13c1b0"
239 );
240 let chain = h.chain(&[1, 2, 3, 4], 2);
241 assert_eq!(
242 chain[0].to_hex(),
243 "c62d1daa451103ce8a2d6f1a3e2251768395f454ec69cf3bf5df3e0bca127956"
244 );
245 assert_eq!(
246 chain[1].to_hex(),
247 "8937bab998de031307801867bd4d95236c4fbc3f295ee319307ea4b3016983a3"
248 );
249 }
250
251 #[test]
252 fn different_models_never_share_a_chain() {
253 let a = BlockHasher::new("model-a", &[] as &[&str]);
254 let b = BlockHasher::new("model-b", &[] as &[&str]);
255 assert_ne!(a.root(), b.root());
256 assert_ne!(a.chain(&[1, 2], 2), b.chain(&[1, 2], 2));
257 }
258
259 #[test]
263 fn extra_keys_change_identity() {
264 let plain = BlockHasher::new("model-a", &[] as &[&str]);
265 let lora = BlockHasher::new("model-a", &["lora:alpha"]);
266 let other = BlockHasher::new("model-a", &["lora:beta"]);
267 assert_ne!(plain.chain(&[1, 2], 2), lora.chain(&[1, 2], 2));
268 assert_ne!(lora.chain(&[1, 2], 2), other.chain(&[1, 2], 2));
269 }
270
271 #[test]
275 fn extra_keys_are_not_ambiguous_under_concatenation() {
276 let a = BlockHasher::new("m", &["ab", "c"]);
277 let b = BlockHasher::new("m", &["a", "bc"]);
278 assert_ne!(a.root(), b.root());
279 let c = BlockHasher::new("mab", &["c"]);
280 assert_ne!(a.root(), c.root());
281 }
282
283 #[test]
286 fn same_tokens_under_different_parents_differ() {
287 let h = hasher();
288 let left = h.chain(&[9, 9, 5, 6], 2);
289 let right = h.chain(&[7, 7, 5, 6], 2);
290 assert_ne!(left[0], right[0]);
291 assert_ne!(
292 left[1], right[1],
293 "block [5,6] must differ under different parents"
294 );
295 }
296
297 #[test]
300 fn chain_of_a_prefix_is_a_prefix_of_the_chain() {
301 let h = hasher();
302 let short = h.chain(&[1, 2, 3, 4], 2);
303 let long = h.chain(&[1, 2, 3, 4, 5, 6], 2);
304 assert_eq!(long.len(), 3);
305 assert_eq!(&long[..2], &short[..]);
306 }
307
308 #[test]
309 fn block_boundaries_are_part_of_identity() {
310 let h = hasher();
311 let by_two = h.chain(&[1, 2, 3, 4], 2);
312 let by_four = h.chain(&[1, 2, 3, 4], 4);
313 assert_eq!(by_two.len(), 2);
314 assert_eq!(by_four.len(), 1);
315 assert_ne!(by_two[1], by_four[0]);
316 }
317
318 #[test]
320 fn trailing_partial_block_is_not_hashed() {
321 let h = hasher();
322 assert_eq!(h.chain(&[1, 2, 3], 2).len(), 1);
323 assert_eq!(h.chain(&[1], 2).len(), 0);
324 assert_eq!(h.chain(&[], 2).len(), 0);
325 assert_eq!(full_blocks(3, 2), 1);
326 assert_eq!(full_blocks(0, 2), 0);
327 assert_eq!(
328 h.chain(&[1, 2, 3], 2)[0],
329 h.chain(&[1, 2], 2)[0],
330 "a partial tail must not change the blocks before it"
331 );
332 }
333
334 #[test]
335 fn token_values_and_order_matter() {
336 let h = hasher();
337 assert_ne!(h.chain(&[1, 2], 2), h.chain(&[2, 1], 2));
338 assert_ne!(h.chain(&[1, 2], 2), h.chain(&[1, 3], 2));
339 }
340
341 #[test]
342 fn hex_and_shard_prefix_are_well_formed() {
343 let h = hasher();
344 let hash = h.chain(&[1, 2], 2)[0];
345 let hex = hash.to_hex();
346 assert_eq!(hex.len(), 64);
347 assert!(hex
348 .chars()
349 .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()));
350 assert_eq!(hash.shard_prefix(2), hex[..2]);
351 assert_eq!(hash.shard_prefix(999).len(), 64);
352 assert_eq!(BlockHash::from_bytes(*hash.as_bytes()), hash);
353 }
354}