Skip to main content

cgn_core/
hash.rs

1//! Stable BLAKE3 prefix hashing used by the router and KV daemon.
2//!
3//! Two flavours are exposed:
4//!
5//! * [`hash_chunks`] — independent per-chunk digests. Cheap. Correct only
6//!   when KV reuse cares about *content overlap* and not *position*.
7//! * [`hash_seq_chunks`] — sequence-chained digests. Each block is salted
8//!   with the prior block's digest so that the hash for chunk N encodes
9//!   the entire prefix `tokens[0..N*TOKENS_PER_CHUNK]`. This matches what
10//!   actual KV caches require: the K/V tensors at position P depend on
11//!   every token at positions `<P`, so two requests with the same suffix
12//!   but different prefixes must produce different digests at every
13//!   position past the divergence point. The router uses this variant to
14//!   compute *longest-prefix* overlap; the resulting score is what
15//!   determines whether a node can skip a prefill.
16//!
17//! Both variants are model-salted.
18
19/// Number of token IDs hashed per chunk. Keeps the prefix-trie depth bounded
20/// while still letting cache hits accumulate at sub-prompt granularity.
21pub const TOKENS_PER_CHUNK: usize = 32;
22
23/// Hash a window of token ids into a deterministic 32-byte digest.
24///
25/// The hash is salted with the model name so that two models with overlapping
26/// vocabularies do not collide on cache-hit accounting. Token IDs are
27/// serialised in little-endian form for cross-architecture stability.
28pub fn hash_chunk(model: &str, tokens: &[u32]) -> [u8; 32] {
29    let mut h = blake3::Hasher::new();
30    h.update(b"cognitora.v1\0");
31    h.update(model.as_bytes());
32    h.update(b"\0");
33    for &t in tokens {
34        h.update(&t.to_le_bytes());
35    }
36    *h.finalize().as_bytes()
37}
38
39/// Sequence-chained digest for one chunk. `parent` is the digest of the
40/// prior chunk, or all-zero for the first chunk.
41pub fn hash_seq_chunk(model: &str, parent: &[u8; 32], tokens: &[u32]) -> [u8; 32] {
42    let mut h = blake3::Hasher::new();
43    h.update(b"cognitora.seq.v1\0");
44    h.update(model.as_bytes());
45    h.update(b"\0");
46    h.update(parent);
47    for &t in tokens {
48        h.update(&t.to_le_bytes());
49    }
50    *h.finalize().as_bytes()
51}
52
53/// Walk a full token sequence and yield one digest per `TOKENS_PER_CHUNK`
54/// rolling chunk. The last (partial) chunk is also emitted because it is
55/// often the most cache-relevant.
56///
57/// **Position-independent**: equal token-windows at different positions
58/// hash to the same digest. Use [`hash_seq_chunks`] when KV-cache reuse
59/// requires positional fidelity.
60pub fn hash_chunks(model: &str, tokens: &[u32]) -> Vec<[u8; 32]> {
61    if tokens.is_empty() {
62        return Vec::new();
63    }
64    let mut out = Vec::with_capacity(tokens.len().div_ceil(TOKENS_PER_CHUNK));
65    for chunk in tokens.chunks(TOKENS_PER_CHUNK) {
66        out.push(hash_chunk(model, chunk));
67    }
68    out
69}
70
71/// Walk a full token sequence and yield one *sequence-chained* digest per
72/// chunk. Chunk N's digest depends on chunks `0..=N`, so the digest at
73/// position N uniquely identifies the prefix `tokens[0..(N+1)*TOKENS_PER_CHUNK]`.
74///
75/// This is the correct hash for KV-cache prefix matching: a node that
76/// reports holding the chain `[d_0, d_1, ..., d_K]` can skip prefilling
77/// up to chunk `K` of a request that shares those exact digests in
78/// order.
79pub fn hash_seq_chunks(model: &str, tokens: &[u32]) -> Vec<[u8; 32]> {
80    if tokens.is_empty() {
81        return Vec::new();
82    }
83    let mut out = Vec::with_capacity(tokens.len().div_ceil(TOKENS_PER_CHUNK));
84    let mut parent = [0u8; 32];
85    for chunk in tokens.chunks(TOKENS_PER_CHUNK) {
86        let d = hash_seq_chunk(model, &parent, chunk);
87        out.push(d);
88        parent = d;
89    }
90    out
91}
92
93/// Format a digest as lowercase hex (16 chars of prefix → 8 bytes is plenty).
94pub fn short(digest: &[u8; 32]) -> String {
95    const HEX: &[u8; 16] = b"0123456789abcdef";
96    let mut s = String::with_capacity(16);
97    for b in &digest[..8] {
98        s.push(HEX[(b >> 4) as usize] as char);
99        s.push(HEX[(b & 0x0f) as usize] as char);
100    }
101    s
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn deterministic() {
110        let a = hash_chunk("llama3", &[1, 2, 3]);
111        let b = hash_chunk("llama3", &[1, 2, 3]);
112        assert_eq!(a, b);
113    }
114
115    #[test]
116    fn model_salts() {
117        let a = hash_chunk("llama3", &[1, 2, 3]);
118        let b = hash_chunk("qwen3", &[1, 2, 3]);
119        assert_ne!(a, b);
120    }
121
122    #[test]
123    fn chunks_partial_tail() {
124        let toks: Vec<u32> = (0..70).collect();
125        let h = hash_chunks("m", &toks);
126        assert_eq!(h.len(), 3); // 32 + 32 + 6
127    }
128
129    #[test]
130    fn short_is_16_chars() {
131        let d = hash_chunk("m", &[1, 2, 3]);
132        assert_eq!(short(&d).len(), 16);
133    }
134
135    #[test]
136    fn seq_chunks_are_position_dependent() {
137        // [a, b] and [c, b] both have b at position 1, but the seq hash
138        // at position 1 must differ because the prefix differs.
139        let toks_ab: Vec<u32> = (0..64u32).collect();
140        let toks_cb: Vec<u32> = std::iter::repeat_n(99u32, 32).chain(32..64u32).collect();
141
142        let h1 = hash_seq_chunks("m", &toks_ab);
143        let h2 = hash_seq_chunks("m", &toks_cb);
144        assert_eq!(h1.len(), 2);
145        assert_eq!(h2.len(), 2);
146        assert_ne!(h1[0], h2[0]);
147        // Same chunk-1 tokens but different prefix → seq hash must differ.
148        assert_ne!(h1[1], h2[1]);
149    }
150
151    #[test]
152    fn seq_chunks_extend_consistently() {
153        let toks: Vec<u32> = (0..96u32).collect();
154        let prefix: Vec<u32> = toks[..64].to_vec();
155        let h_full = hash_seq_chunks("m", &toks);
156        let h_prefix = hash_seq_chunks("m", &prefix);
157        // Extending the sequence preserves all earlier digests.
158        assert_eq!(h_prefix, h_full[..2]);
159    }
160
161    #[test]
162    fn seq_chunks_diverge_when_independent_chunks_match() {
163        // Independent chunk hashing would consider these "equal" at chunk
164        // 1, but seq hashing must not.
165        let mut a: Vec<u32> = (0..64).collect();
166        let mut b: Vec<u32> = (0..64).collect();
167        a[0] = 9999; // diverge in chunk 0 only
168        b[0] = 1234;
169        let ha = hash_seq_chunks("m", &a);
170        let hb = hash_seq_chunks("m", &b);
171        assert_ne!(ha[0], hb[0]);
172        // Chunk-1 token windows are identical but prefixes diverged.
173        let ind = hash_chunks("m", &a);
174        let ind_b = hash_chunks("m", &b);
175        assert_eq!(ind[1], ind_b[1], "independent hashing collides at pos 1");
176        assert_ne!(ha[1], hb[1], "seq hashing must diverge at pos 1");
177    }
178}