Skip to main content

ferrox_core/
kv_block.rs

1//! Content-addressed identity for KV-cache blocks: what makes two
2//! stored prefixes *the same* prefix, across processes and across
3//! restarts.
4//!
5//! A block is a fixed-size run of token positions in one sequence. Its
6//! identity is a **parent-chained SHA-256**:
7//!
8//! ```text
9//! root       = H(domain, "root",  model, extra_keys)
10//! block[i]   = H(domain, "block", model, extra_keys, parent, token_ids)
11//! parent     = root for block[0], block[i-1] otherwise
12//! ```
13//!
14//! Chaining is what makes a hash mean *this token run, at this offset,
15//! after exactly this history* rather than merely "these tokens".
16//! Without it, two different prompts that happen to share an interior
17//! token run would collide on a block whose KV state depends on
18//! everything before it, and the cache would hand back state computed
19//! under a different history -- silent wrong answers, not a miss.
20//!
21//! `extra_keys` is the salt slot for anything that changes what the KV
22//! state *means* without changing the token ids: a LoRA adapter's
23//! identity, an image/audio embedding's identity for a multimodal
24//! prompt. Sampling parameters are deliberately **not** part of the
25//! key: KV state is sampling-independent, and folding temperature or a
26//! seed into the key would only shatter the cache.
27//!
28//! Every field is length-prefixed before hashing, so no two different
29//! `(model, extra_keys, parent, tokens)` tuples can serialize to the
30//! same byte string. Concatenating raw fields would let
31//! `extra_keys = ["ab", "c"]` and `["a", "bc"]` hash identically.
32//!
33//! This module is identity only -- no storage, no eviction, no I/O.
34//! The disk tier that will consume it is `kv-ssd-tier` in
35//! `docs/plans/serving-and-tiered-kv.md`.
36
37use sha2::{Digest, Sha256};
38
39/// Domain separator, versioned. Bumping it invalidates every hash ever
40/// computed, which is the intended effect if the encoding below ever
41/// has to change: old blocks become unreachable rather than
42/// misinterpreted.
43const HASH_DOMAIN: &[u8] = b"ferrox-kv-block-v1";
44
45const TAG_ROOT: &[u8] = b"root";
46const TAG_BLOCK: &[u8] = b"block";
47
48/// A block's content address: the 32-byte SHA-256 of its chained
49/// identity.
50#[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    /// Lowercase hex, 64 characters. This is the on-disk file name the
63    /// SSD tier will use.
64    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    /// The first `n` hex characters, for sharding blocks into
74    /// subdirectories so one directory never holds every block on the
75    /// machine. `n` is clamped to the digest length.
76    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        // Short form: a full 64-char digest in a log line is noise, and
84        // 12 hex characters are plenty to follow one block through a
85        // trace.
86        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
96/// Writes one length-prefixed field, so the concatenation of fields is
97/// injective (see the module note on `["ab","c"]` vs `["a","bc"]`).
98fn absorb(hasher: &mut Sha256, field: &[u8]) {
99    hasher.update((field.len() as u64).to_le_bytes());
100    hasher.update(field);
101}
102
103/// Hashes blocks for one (model, extra_keys) identity. Cheap to build;
104/// the root is computed once and reused for every chain.
105#[derive(Clone, Debug)]
106pub struct BlockHasher {
107    model: String,
108    extra_keys: Vec<String>,
109    root: BlockHash,
110}
111
112impl BlockHasher {
113    /// `model` should identify the *weights*, not the file path -- two
114    /// servers on different machines must agree on it for a shared or
115    /// restored cache to be reusable at all.
116    ///
117    /// `extra_keys` are ordered: they are hashed in the order given, so
118    /// callers must be consistent (sort them if the source is a set).
119    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    /// The seed every chain starts from: the identity of "no tokens
136    /// yet, under this model and these extra keys". A chain rooted here
137    /// can never be confused with one rooted under a different model or
138    /// a different LoRA.
139    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    /// Hashes one block: this token run, following `parent`.
152    ///
153    /// `model` and `extra_keys` are folded in again even though the
154    /// parent chain already carries them, so a single block hash is
155    /// verifiable from `(parent, tokens)` alone without walking back to
156    /// the root.
157    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    /// Hashes `tokens` as a chain of `block_size`-token blocks, rooted
172    /// at [`root`](Self::root).
173    ///
174    /// **Only whole blocks are hashed.** A trailing partial block gets
175    /// no hash: its content is still growing, so any identity assigned
176    /// to it now would name a different token run a moment later.
177    /// [`full_blocks`] reports how many hashes a length yields.
178    ///
179    /// Because each block's hash covers its parent, the chain of a
180    /// prompt is a strict prefix of the chain of anything that extends
181    /// it -- which is exactly the lookup a prefix cache needs.
182    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
201/// How many whole blocks `token_count` tokens make at `block_size`.
202pub 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    /// The encoding is a persistence format: a block written by one
225    /// build must still be findable by the next one. If this fails, the
226    /// hash inputs changed and `HASH_DOMAIN` must be bumped so old
227    /// blocks become unreachable rather than misread.
228    ///
229    /// These digests were cross-validated against an independent Python
230    /// `hashlib` implementation of the same length-prefixed encoding --
231    /// they pin the *encoding*, not merely whatever this code happens
232    /// to produce.
233    #[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    /// The salt slot: same model, same tokens, different LoRA identity
260    /// must be a different block. A cache that ignored this would serve
261    /// base-model KV state to an adapter request.
262    #[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    /// Length prefixing, stated as the property it protects: two
272    /// different key lists whose concatenations are byte-identical must
273    /// still hash differently.
274    #[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    /// The point of chaining: the same token run after a different
284    /// history is a different block, because its KV state is.
285    #[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    /// The prefix-cache lookup property: extending a prompt extends its
298    /// chain, it does not rewrite it.
299    #[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    /// A still-growing tail has no stable identity, so it gets no hash.
319    #[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}