frink_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"frink-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 ///
234 /// They changed once, on 2026-09-19, when the project was renamed
235 /// from Ferrox to Frink and `HASH_DOMAIN` went with it. That is
236 /// the bump this comment asks for rather than a break: every
237 /// previously written block becomes unreachable instead of being
238 /// read under a name that no longer describes it. The three values
239 /// below were re-derived in Python, not copied from a failing
240 /// assertion.
241 #[test]
242 fn hash_encoding_is_stable() {
243 let h = BlockHasher::new("model-a", &["lora:alpha"]);
244 assert_eq!(
245 h.root().to_hex(),
246 "08d2e26f303283373f6e74dd73fd2d8c00e9d22edeb6d35df6f3557fd78172f1"
247 );
248 let chain = h.chain(&[1, 2, 3, 4], 2);
249 assert_eq!(
250 chain[0].to_hex(),
251 "fd891b2d4f3154d4877be7163bbba82332178dbf7c52d23c04cdde8b366ef31f"
252 );
253 assert_eq!(
254 chain[1].to_hex(),
255 "24a65c69d897153b0a798429b379a9fff184a89a0193e4dc4a6520f537509795"
256 );
257 }
258
259 #[test]
260 fn different_models_never_share_a_chain() {
261 let a = BlockHasher::new("model-a", &[] as &[&str]);
262 let b = BlockHasher::new("model-b", &[] as &[&str]);
263 assert_ne!(a.root(), b.root());
264 assert_ne!(a.chain(&[1, 2], 2), b.chain(&[1, 2], 2));
265 }
266
267 /// The salt slot: same model, same tokens, different LoRA identity
268 /// must be a different block. A cache that ignored this would serve
269 /// base-model KV state to an adapter request.
270 #[test]
271 fn extra_keys_change_identity() {
272 let plain = BlockHasher::new("model-a", &[] as &[&str]);
273 let lora = BlockHasher::new("model-a", &["lora:alpha"]);
274 let other = BlockHasher::new("model-a", &["lora:beta"]);
275 assert_ne!(plain.chain(&[1, 2], 2), lora.chain(&[1, 2], 2));
276 assert_ne!(lora.chain(&[1, 2], 2), other.chain(&[1, 2], 2));
277 }
278
279 /// Length prefixing, stated as the property it protects: two
280 /// different key lists whose concatenations are byte-identical must
281 /// still hash differently.
282 #[test]
283 fn extra_keys_are_not_ambiguous_under_concatenation() {
284 let a = BlockHasher::new("m", &["ab", "c"]);
285 let b = BlockHasher::new("m", &["a", "bc"]);
286 assert_ne!(a.root(), b.root());
287 let c = BlockHasher::new("mab", &["c"]);
288 assert_ne!(a.root(), c.root());
289 }
290
291 /// The point of chaining: the same token run after a different
292 /// history is a different block, because its KV state is.
293 #[test]
294 fn same_tokens_under_different_parents_differ() {
295 let h = hasher();
296 let left = h.chain(&[9, 9, 5, 6], 2);
297 let right = h.chain(&[7, 7, 5, 6], 2);
298 assert_ne!(left[0], right[0]);
299 assert_ne!(
300 left[1], right[1],
301 "block [5,6] must differ under different parents"
302 );
303 }
304
305 /// The prefix-cache lookup property: extending a prompt extends its
306 /// chain, it does not rewrite it.
307 #[test]
308 fn chain_of_a_prefix_is_a_prefix_of_the_chain() {
309 let h = hasher();
310 let short = h.chain(&[1, 2, 3, 4], 2);
311 let long = h.chain(&[1, 2, 3, 4, 5, 6], 2);
312 assert_eq!(long.len(), 3);
313 assert_eq!(&long[..2], &short[..]);
314 }
315
316 #[test]
317 fn block_boundaries_are_part_of_identity() {
318 let h = hasher();
319 let by_two = h.chain(&[1, 2, 3, 4], 2);
320 let by_four = h.chain(&[1, 2, 3, 4], 4);
321 assert_eq!(by_two.len(), 2);
322 assert_eq!(by_four.len(), 1);
323 assert_ne!(by_two[1], by_four[0]);
324 }
325
326 /// A still-growing tail has no stable identity, so it gets no hash.
327 #[test]
328 fn trailing_partial_block_is_not_hashed() {
329 let h = hasher();
330 assert_eq!(h.chain(&[1, 2, 3], 2).len(), 1);
331 assert_eq!(h.chain(&[1], 2).len(), 0);
332 assert_eq!(h.chain(&[], 2).len(), 0);
333 assert_eq!(full_blocks(3, 2), 1);
334 assert_eq!(full_blocks(0, 2), 0);
335 assert_eq!(
336 h.chain(&[1, 2, 3], 2)[0],
337 h.chain(&[1, 2], 2)[0],
338 "a partial tail must not change the blocks before it"
339 );
340 }
341
342 #[test]
343 fn token_values_and_order_matter() {
344 let h = hasher();
345 assert_ne!(h.chain(&[1, 2], 2), h.chain(&[2, 1], 2));
346 assert_ne!(h.chain(&[1, 2], 2), h.chain(&[1, 3], 2));
347 }
348
349 #[test]
350 fn hex_and_shard_prefix_are_well_formed() {
351 let h = hasher();
352 let hash = h.chain(&[1, 2], 2)[0];
353 let hex = hash.to_hex();
354 assert_eq!(hex.len(), 64);
355 assert!(hex
356 .chars()
357 .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()));
358 assert_eq!(hash.shard_prefix(2), hex[..2]);
359 assert_eq!(hash.shard_prefix(999).len(), 64);
360 assert_eq!(BlockHash::from_bytes(*hash.as_bytes()), hash);
361 }
362}