Skip to main content

ferrum_models/common/
paged_pool.rs

1//! Multi-sequence paged-KV pool — Phase 4 of Metal paged attention.
2//!
3//! Replaces the per-cache_id `KvCache` allocation model with a
4//! shared-pool architecture matching vLLM / mistral.rs:
5//!
6//! - **One pool per layer** holds K and V for *all* concurrent sequences.
7//!   Sized to `MAX_TOTAL_BLOCKS × num_kv_heads × block_size × head_dim`.
8//! - **Per-cache_id state** ([`PagedSeqState`]) carries that sequence's
9//!   logical → physical block mapping (`block_table`) plus its current
10//!   length (`len`). Multiple cache_ids can index into the same pool
11//!   without colliding because their block_tables point at disjoint
12//!   physical blocks (or shared ones, if prefix-caching is enabled
13//!   later).
14//! - **[`BlockAllocator`]** is a free-list owning physical block indices.
15//!   `allocate` pops, `free` pushes back. Out-of-memory surfaces as
16//!   `Result::Err` so the scheduler can refuse the request rather than
17//!   panicking deep in the model forward.
18//!
19//! ## Ref counting (Phase 1 of block-level prefix cache)
20//!
21//! Each physical block has a `u16` ref count. `allocate` / `allocate_n`
22//! create a block with `ref_count = 1`. `free` decrements; when the count
23//! reaches zero the block returns to the free list. `acquire(block)`
24//! increments — used by the prefix cache when another sequence wants to
25//! share an already-resident block.
26//!
27//! Backwards compatible: pre-prefix-cache callers don't touch `acquire`,
28//! so every block stays at ref=1 and `free` behaves identically to the
29//! old single-ref free.
30//!
31//! ## Hash table (Phase 2 of block-level prefix cache)
32//!
33//! Each block can be tagged with a content-addressed `BlockHash` via
34//! [`BlockAllocator::register_block_hash`]. The hash is computed by
35//! chaining `parent_hash` with the block's tokens (see
36//! [`block_hash_chain`]) so identical prefixes across requests produce
37//! identical hash sequences.
38//!
39//! [`BlockAllocator::try_acquire_by_hash`] looks up a hash and bumps the
40//! ref count on hit. **Soft-free blocks** (ref=0 still in `free_list`
41//! with content intact) can be resurrected — this is what makes the cache
42//! actually useful across requests. `allocate` evicts a stale hash when
43//! a soft-free block is recycled with new content.
44//!
45//! ## What's *not* here yet (deferred to Phase 3-4 of prefix cache):
46//! - **Engine integration**: hashing incoming prompt blocks, looking up
47//!   matching prefix, splicing the existing blocks into the new seq's
48//!   block_table, running prefill only on the unmatched suffix.
49//! - **LRU eviction**: `free_list` is LIFO. Better policy would be
50//!   LRU-soft-free so the oldest (least likely to hit) blocks are
51//!   reused first. Today the most-recently-freed gets reused first
52//!   (pessimistic for cross-request reuse).
53//! - **Preemption**: when blocks run out we still just `Err`; real
54//!   scheduler would refuse-and-queue or evict.
55//! - Cross-process or cross-model pooling.
56
57use ferrum_kernels::backend::Backend;
58use ferrum_types::{FerrumError, Result, TokenId};
59use std::collections::HashMap;
60use std::hash::{Hash, Hasher};
61use std::sync::atomic::{AtomicUsize, Ordering};
62
63/// Hash identifying a content-addressable KV block. Computed from a chain
64/// of (parent_hash, block_tokens) — see [`block_hash_chain`].
65pub type BlockHash = u64;
66
67/// Compute the hash for one block, chained from its parent.
68///
69/// `parent` is the hash of the preceding block (0 for the first block).
70/// `tokens` are the `block_size` token ids in this block.
71///
72/// Uses Rust's default `SipHasher` for collision resistance with no extra
73/// deps. SipHash at ~1 GB/s processes a 16-token block (64 bytes) in tens
74/// of ns; per-request hashing of <200 blocks is well under a microsecond
75/// — invisible next to a prefill kernel.
76pub fn block_hash(parent: BlockHash, tokens: &[TokenId]) -> BlockHash {
77    let mut h = std::collections::hash_map::DefaultHasher::new();
78    parent.hash(&mut h);
79    for t in tokens {
80        t.get().hash(&mut h);
81    }
82    h.finish()
83}
84
85/// Compute the chained hash sequence for a token list, broken into
86/// `block_size`-token blocks. Trailing partial block (length <
87/// `block_size`) is dropped — partial blocks can't be looked up
88/// independently since their content depends on what gets appended next.
89pub fn block_hash_chain(tokens: &[TokenId], block_size: usize) -> Vec<BlockHash> {
90    let n = tokens.len() / block_size;
91    let mut out = Vec::with_capacity(n);
92    let mut parent: BlockHash = 0;
93    for chunk in tokens.chunks_exact(block_size) {
94        parent = block_hash(parent, chunk);
95        out.push(parent);
96    }
97    out
98}
99
100/// LIFO free-list block allocator. `O(1)` allocate / free, no fragmentation
101/// (all blocks are uniform size).
102///
103/// `capacity` is the total physical block count baked into the pool at
104/// load time. The allocator is independent per-model (block index space
105/// is not portable across models).
106pub struct BlockAllocator {
107    free_list: Vec<u32>,
108    capacity: u32,
109    /// Watermark: how many blocks have been live at peak, useful for
110    /// pool-sizing diagnostics in the bench harness.
111    peak_in_use: AtomicUsize,
112    /// Per-block reference count. `ref_counts[i] == 0` ⟺ block i is on
113    /// the free list (either freshly-uninitialized or soft-free with a
114    /// hash still registered). `allocate` sets to 1; `acquire` increments;
115    /// `free` decrements and (only when reaching 0) returns the block
116    /// to the free list. Width 16 bits — supports up to 65535 concurrent
117    /// sharers of the same physical KV block, far past any realistic
118    /// continuous batching cap.
119    ref_counts: Vec<u16>,
120    /// Block-hash lookup. Contains entries for every block whose content
121    /// has been hash-registered via [`Self::register_block_hash`] — both
122    /// live (ref ≥ 1) and soft-free (ref = 0 but content not yet
123    /// overwritten). [`Self::try_acquire_by_hash`] hits this map; on hit
124    /// the entry is resurrected with ref = 1 if it was soft-free.
125    /// Survives across requests — this is what makes the prefix cache
126    /// actually useful.
127    hash_table: HashMap<BlockHash, u32>,
128    /// Reverse map block_id → hash, for cleanup. When a block's content
129    /// gets overwritten by `allocate` returning it on a fresh request,
130    /// we use this to erase its stale hash from `hash_table`.
131    block_to_hash: Vec<Option<BlockHash>>,
132}
133
134impl BlockAllocator {
135    /// Create a fresh allocator. All `num_blocks` blocks start free.
136    /// Free-list is built so `allocate()` returns block 0 first, then 1,
137    /// etc. — predictable for tests and ensures the lower physical
138    /// blocks see the most reuse (better cache locality on M1's SLC).
139    pub fn new(num_blocks: u32) -> Self {
140        let mut free_list: Vec<u32> = (0..num_blocks).collect();
141        free_list.reverse(); // pop() yields 0 first
142        Self {
143            free_list,
144            capacity: num_blocks,
145            peak_in_use: AtomicUsize::new(0),
146            ref_counts: vec![0u16; num_blocks as usize],
147            hash_table: HashMap::new(),
148            block_to_hash: vec![None; num_blocks as usize],
149        }
150    }
151
152    /// Allocate a single physical block. Returns `Err` when the pool is
153    /// exhausted — caller is expected to refuse the request and queue
154    /// it (or evict another seq, when that's wired up).
155    ///
156    /// New block starts with `ref_count = 1`. Drop one ref via `free()`
157    /// to return it to the pool, or call `acquire()` for additional
158    /// sharers.
159    pub fn allocate(&mut self) -> Result<u32> {
160        match self.free_list.pop() {
161            Some(b) => {
162                debug_assert!(
163                    self.ref_counts[b as usize] == 0,
164                    "allocate yielded block {b} with non-zero ref_count {}",
165                    self.ref_counts[b as usize]
166                );
167                // Soft-free block with a stale hash → content will be
168                // overwritten, so erase the hash mapping.
169                self.evict_hash_if_any(b);
170                self.ref_counts[b as usize] = 1;
171                let in_use = self.capacity as usize - self.free_list.len();
172                self.peak_in_use.fetch_max(in_use, Ordering::Relaxed);
173                Ok(b)
174            }
175            None => Err(FerrumError::resource_exhausted(format!(
176                "paged KV pool exhausted (capacity={} blocks, all in use)",
177                self.capacity
178            ))),
179        }
180    }
181
182    /// Internal: erase any hash mapping for a block whose content is
183    /// about to be overwritten. No-op if the block had no hash registered.
184    fn evict_hash_if_any(&mut self, block: u32) {
185        if let Some(h) = self.block_to_hash[block as usize].take() {
186            // Only erase from hash_table if it still points at THIS block.
187            // (Defensive — a hash collision later registering the same h
188            // to a different block could mean the entry now points
189            // elsewhere; we shouldn't yank the live one.)
190            if let Some(&mapped) = self.hash_table.get(&h) {
191                if mapped == block {
192                    self.hash_table.remove(&h);
193                }
194            }
195        }
196    }
197
198    /// Bulk allocate. Atomic: either all `n` succeed or none are taken.
199    /// Each returned block starts at `ref_count = 1`.
200    pub fn allocate_n(&mut self, n: usize) -> Result<Vec<u32>> {
201        if self.free_list.len() < n {
202            return Err(FerrumError::resource_exhausted(format!(
203                "paged KV pool exhausted: need {n} blocks but only {} free",
204                self.free_list.len()
205            )));
206        }
207        let mut out = Vec::with_capacity(n);
208        for _ in 0..n {
209            let b = self.free_list.pop().unwrap();
210            debug_assert!(
211                self.ref_counts[b as usize] == 0,
212                "allocate_n yielded block {b} with non-zero ref_count"
213            );
214            self.evict_hash_if_any(b);
215            self.ref_counts[b as usize] = 1;
216            out.push(b);
217        }
218        let in_use = self.capacity as usize - self.free_list.len();
219        self.peak_in_use.fetch_max(in_use, Ordering::Relaxed);
220        Ok(out)
221    }
222
223    /// Look up a hash in the prefix-cache table. If the hash maps to a
224    /// physical block (live or soft-free), bump its ref count and return
225    /// the block id. A soft-free block (ref=0 in free_list) is
226    /// resurrected — pulled out of the free list and ref set to 1.
227    /// Returns `None` on cache miss.
228    pub fn try_acquire_by_hash(&mut self, hash: BlockHash) -> Option<u32> {
229        let &block = self.hash_table.get(&hash)?;
230        let bi = block as usize;
231        if self.ref_counts[bi] == 0 {
232            // Soft-free: pull out of free_list (O(n) on free_list length;
233            // typically small — few hundred at peak utilisation).
234            if let Some(pos) = self.free_list.iter().rposition(|&b| b == block) {
235                self.free_list.swap_remove(pos);
236            } else {
237                // Inconsistency: ref=0 but not in free_list. Shouldn't
238                // happen unless someone bypassed the API. Treat as miss
239                // to avoid corruption.
240                debug_assert!(false, "block {block} has ref=0 but not in free_list");
241                return None;
242            }
243            self.ref_counts[bi] = 1;
244            let in_use = self.capacity as usize - self.free_list.len();
245            self.peak_in_use.fetch_max(in_use, Ordering::Relaxed);
246        } else {
247            self.acquire(block);
248        }
249        Some(block)
250    }
251
252    /// Associate a hash with a block (after its content is written).
253    /// If the block already had a hash, the old mapping is replaced.
254    /// Block must currently be live (ref ≥ 1).
255    pub fn register_block_hash(&mut self, block: u32, hash: BlockHash) {
256        let bi = block as usize;
257        debug_assert!(
258            self.ref_counts[bi] > 0,
259            "register_block_hash on block {block} with ref_count=0 (not allocated)"
260        );
261        // Replace prior hash if any.
262        if let Some(old_h) = self.block_to_hash[bi].replace(hash) {
263            if old_h != hash {
264                if let Some(&mapped) = self.hash_table.get(&old_h) {
265                    if mapped == block {
266                        self.hash_table.remove(&old_h);
267                    }
268                }
269            } else {
270                // Same hash being re-registered; nothing to clean up.
271                return;
272            }
273        }
274        // Last-writer-wins on collision (same hash different content —
275        // SipHash collisions on 64-token inputs are astronomically rare).
276        self.hash_table.insert(hash, block);
277    }
278
279    /// Read accessor for tests / debugging.
280    #[inline]
281    pub fn hash_table_size(&self) -> usize {
282        self.hash_table.len()
283    }
284
285    /// Increment the ref count for an already-allocated block. Used by
286    /// prefix-caching paths when a new sequence wants to share a block
287    /// that's already resident from a prior request.
288    ///
289    /// Panics in debug builds if the block is not currently allocated
290    /// (ref_count==0) — a release-build path that calls `acquire` on a
291    /// free block is a memory-safety bug we'd rather catch loudly.
292    pub fn acquire(&mut self, block: u32) {
293        let bi = block as usize;
294        debug_assert!(
295            self.ref_counts[bi] > 0,
296            "acquire on block {block} with ref_count=0 (not currently allocated)"
297        );
298        self.ref_counts[bi] = self.ref_counts[bi]
299            .checked_add(1)
300            .expect("BlockAllocator ref_count u16 overflow (>65535 sharers)");
301    }
302
303    /// Bulk acquire — convenience for prefix-cache hit paths that take a
304    /// list of physical block ids to share.
305    pub fn acquire_many(&mut self, blocks: &[u32]) {
306        for &b in blocks {
307            self.acquire(b);
308        }
309    }
310
311    /// Drop one ref from each block. Blocks whose ref_count hits 0 are
312    /// returned to the free list and become available for the next
313    /// `allocate*` call.
314    ///
315    /// Pre-prefix-cache callers see no behavioural change: every block
316    /// they hold starts at ref=1 (from `allocate`), and `free` drops it
317    /// to 0 — physically frees on the same call, same as before.
318    pub fn free(&mut self, blocks: &[u32]) {
319        for &b in blocks {
320            let bi = b as usize;
321            debug_assert!(
322                self.ref_counts[bi] > 0,
323                "free on block {b} with ref_count=0 (double-free)"
324            );
325            self.ref_counts[bi] -= 1;
326            if self.ref_counts[bi] == 0 {
327                self.free_list.push(b);
328            }
329        }
330    }
331
332    /// Read current ref count for a block. 0 means free, ≥1 means in
333    /// use by that many sequences.
334    #[inline]
335    pub fn ref_count(&self, block: u32) -> u16 {
336        self.ref_counts[block as usize]
337    }
338
339    pub fn free_count(&self) -> usize {
340        self.free_list.len()
341    }
342
343    pub fn capacity(&self) -> u32 {
344        self.capacity
345    }
346
347    pub fn peak_in_use(&self) -> usize {
348        self.peak_in_use.load(Ordering::Relaxed)
349    }
350}
351
352/// Per-sequence paged-KV state.
353///
354/// Holds the logical→physical block mapping for ONE sequence (one
355/// `cache_id`) plus its current token count. The mapping is stored as
356/// both:
357/// - `blocks: Vec<u32>` — the host-side source of truth, used by the
358///   block allocator + grow logic.
359/// - `block_table_buf: B::Buffer` — a device-side u32 buffer that mirrors
360///   `blocks` and is read directly by the paged Metal kernels (PR #68 /
361///   #69). Kept in sync via [`Self::ensure_capacity`].
362///
363/// `context_lens_buf` is a 1-element u32 device buffer holding `len`.
364/// The kernel reads it each forward; we update it via `B::write_u32`.
365pub struct PagedSeqState<B: Backend> {
366    pub blocks: Vec<u32>,
367    pub block_table_buf: B::Buffer,
368    pub context_lens_buf: B::Buffer,
369    pub len: usize,
370    pub block_size: usize,
371    pub max_blocks_per_seq: usize,
372}
373
374impl<B: Backend> PagedSeqState<B> {
375    /// Allocate buffers for a sequence that hasn't yet allocated any
376    /// blocks. The allocator isn't touched here — the first call to
377    /// [`Self::ensure_capacity`] does the real work.
378    pub fn new(block_size: usize, max_blocks_per_seq: usize) -> Self {
379        let block_table_buf =
380            B::alloc_typed(ferrum_kernels::backend::Dtype::U32, max_blocks_per_seq);
381        let context_lens_buf = B::alloc_typed(ferrum_kernels::backend::Dtype::U32, 1);
382        // Initialise context_lens to 0 so a forward dispatched before
383        // any token has been written sees an empty context.
384        let mut ctx = B::new_context();
385        let mut cl = context_lens_buf;
386        B::write_typed::<u32>(&mut ctx, &mut cl, &[0u32]);
387        B::sync(&mut ctx);
388        Self {
389            blocks: Vec::with_capacity(max_blocks_per_seq),
390            block_table_buf,
391            context_lens_buf: cl,
392            len: 0,
393            block_size,
394            max_blocks_per_seq,
395        }
396    }
397
398    /// Ensure the seq has enough blocks to hold `target_len` tokens.
399    /// Allocates additional blocks from the pool if needed and re-syncs
400    /// `block_table_buf` to the device. Idempotent if already big enough.
401    pub fn ensure_capacity(
402        &mut self,
403        ctx: &mut B::Context,
404        alloc: &mut BlockAllocator,
405        target_len: usize,
406    ) -> Result<()> {
407        let needed = target_len.div_ceil(self.block_size);
408        if needed > self.max_blocks_per_seq {
409            return Err(FerrumError::model(format!(
410                "paged KV: target_len={target_len} would need {needed} blocks, exceeds max_blocks_per_seq={}",
411                self.max_blocks_per_seq
412            )));
413        }
414        while self.blocks.len() < needed {
415            let block = alloc.allocate()?;
416            self.blocks.push(block);
417        }
418        // Mirror the host-side blocks list into the device buffer. We
419        // write the FULL `max_blocks_per_seq` entries — unused slots
420        // beyond `needed` are never read by the kernel (it only walks
421        // `[0, ceil(context_len / block_size))`), but writing them
422        // keeps the buffer's content predictable.
423        let mut padded = self.blocks.clone();
424        padded.resize(self.max_blocks_per_seq, 0);
425        B::write_typed::<u32>(ctx, &mut self.block_table_buf, &padded);
426        Ok(())
427    }
428
429    /// Update the on-device `context_lens_buf` to the current `self.len`.
430    /// Call this after [`Self::ensure_capacity`] but before dispatching
431    /// the paged attention kernel for this seq.
432    pub fn sync_context_len(&mut self, ctx: &mut B::Context) {
433        B::write_typed::<u32>(ctx, &mut self.context_lens_buf, &[self.len as u32]);
434    }
435
436    /// Release all blocks back to the allocator. Buffers are kept (cheap
437    /// to reuse for a future cache_id), but blocks become available for
438    /// other sequences. Sets `len` back to 0.
439    pub fn release(&mut self, alloc: &mut BlockAllocator) {
440        alloc.free(&self.blocks);
441        self.blocks.clear();
442        self.len = 0;
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    #[test]
451    fn allocator_basic() {
452        let mut a = BlockAllocator::new(4);
453        assert_eq!(a.free_count(), 4);
454        assert_eq!(a.allocate().unwrap(), 0);
455        assert_eq!(a.allocate().unwrap(), 1);
456        assert_eq!(a.allocate().unwrap(), 2);
457        assert_eq!(a.allocate().unwrap(), 3);
458        assert!(a.allocate().is_err());
459        assert_eq!(a.free_count(), 0);
460
461        a.free(&[1, 3]);
462        assert_eq!(a.free_count(), 2);
463        // LIFO: most recently freed comes back first.
464        assert_eq!(a.allocate().unwrap(), 3);
465        assert_eq!(a.allocate().unwrap(), 1);
466    }
467
468    #[test]
469    fn allocator_atomic_n_failure() {
470        let mut a = BlockAllocator::new(3);
471        let _ = a.allocate().unwrap(); // 1 left in free_list... wait, 2 left
472        let _ = a.allocate().unwrap();
473        // 1 free, asking for 2 should fail without consuming the 1.
474        assert!(a.allocate_n(2).is_err());
475        assert_eq!(a.free_count(), 1);
476    }
477
478    #[test]
479    fn allocator_peak_tracking() {
480        let mut a = BlockAllocator::new(8);
481        let blocks = a.allocate_n(5).unwrap();
482        assert_eq!(a.peak_in_use(), 5);
483        a.free(&blocks);
484        assert_eq!(a.peak_in_use(), 5); // peak doesn't decrease
485        let _ = a.allocate_n(3).unwrap();
486        assert_eq!(a.peak_in_use(), 5);
487    }
488
489    #[test]
490    fn refcount_allocate_starts_at_one() {
491        let mut a = BlockAllocator::new(4);
492        let b = a.allocate().unwrap();
493        assert_eq!(a.ref_count(b), 1);
494    }
495
496    #[test]
497    fn refcount_acquire_increments() {
498        let mut a = BlockAllocator::new(4);
499        let b = a.allocate().unwrap();
500        a.acquire(b);
501        a.acquire(b);
502        assert_eq!(a.ref_count(b), 3);
503    }
504
505    #[test]
506    fn refcount_free_decrements_no_physical_release() {
507        let mut a = BlockAllocator::new(4);
508        let b = a.allocate().unwrap();
509        a.acquire(b); // ref=2
510        a.free(&[b]); // ref=1 — NOT yet returned to free_list
511        assert_eq!(a.ref_count(b), 1);
512        assert_eq!(a.free_count(), 3); // only 3 of original 4 blocks free
513    }
514
515    #[test]
516    fn refcount_free_physical_release_at_zero() {
517        let mut a = BlockAllocator::new(4);
518        let b = a.allocate().unwrap();
519        a.acquire(b); // ref=2
520        a.free(&[b]); // ref=1
521        a.free(&[b]); // ref=0 → physical release
522        assert_eq!(a.ref_count(b), 0);
523        assert_eq!(a.free_count(), 4);
524    }
525
526    #[test]
527    fn refcount_legacy_single_ref_behaviour_unchanged() {
528        // Pre-prefix-cache code never calls `acquire`. Verify that
529        // allocate+free behaves identically to the old version: block
530        // immediately returns to the pool.
531        let mut a = BlockAllocator::new(2);
532        let b = a.allocate().unwrap();
533        assert_eq!(a.free_count(), 1);
534        a.free(&[b]);
535        assert_eq!(a.free_count(), 2);
536        assert_eq!(a.ref_count(b), 0);
537        // Re-allocation reuses the slot (LIFO).
538        let b2 = a.allocate().unwrap();
539        assert_eq!(b2, b);
540    }
541
542    #[test]
543    fn refcount_bulk_acquire_and_release() {
544        let mut a = BlockAllocator::new(8);
545        let blocks = a.allocate_n(3).unwrap();
546        a.acquire_many(&blocks); // each ref=2
547        for &b in &blocks {
548            assert_eq!(a.ref_count(b), 2);
549        }
550        a.free(&blocks); // each ref=1, none physically released
551        assert_eq!(a.free_count(), 5);
552        a.free(&blocks); // each ref=0, all released
553        assert_eq!(a.free_count(), 8);
554    }
555
556    // ── Phase 2: hash table tests ────────────────────────────────────────
557
558    fn toks(ids: &[u32]) -> Vec<TokenId> {
559        ids.iter().map(|&i| TokenId::new(i)).collect()
560    }
561
562    #[test]
563    fn block_hash_chain_basic() {
564        let tokens = toks(&[1, 2, 3, 4, 5, 6, 7, 8]);
565        let chain = block_hash_chain(&tokens, 4);
566        assert_eq!(chain.len(), 2);
567        // Identical second block must produce a DIFFERENT chained hash
568        // because its parent (first block) is the same here but the
569        // chain order forces hash[1] = h(hash[0], tokens[4..8]).
570        assert_ne!(chain[0], chain[1]);
571    }
572
573    #[test]
574    fn block_hash_chain_identical_prefix_same_hashes() {
575        let a = toks(&[10, 20, 30, 40, 50, 60, 70, 80]);
576        let b = toks(&[10, 20, 30, 40, 99, 99, 99, 99]);
577        let ca = block_hash_chain(&a, 4);
578        let cb = block_hash_chain(&b, 4);
579        assert_eq!(ca[0], cb[0], "first block matches → same hash[0]");
580        assert_ne!(ca[1], cb[1], "second block differs → different hash[1]");
581    }
582
583    #[test]
584    fn block_hash_chain_drops_partial_trailing() {
585        let tokens = toks(&[1, 2, 3, 4, 5, 6, 7]); // 7 tokens, block_size=4 → 1 full
586        let chain = block_hash_chain(&tokens, 4);
587        assert_eq!(chain.len(), 1);
588    }
589
590    #[test]
591    fn hash_table_register_and_acquire_live() {
592        let mut a = BlockAllocator::new(4);
593        let b = a.allocate().unwrap();
594        let h = 0xDEAD_BEEFu64;
595        a.register_block_hash(b, h);
596        assert_eq!(a.hash_table_size(), 1);
597        // Re-acquire by hash: must bump ref (block is live)
598        let got = a.try_acquire_by_hash(h);
599        assert_eq!(got, Some(b));
600        assert_eq!(a.ref_count(b), 2);
601    }
602
603    #[test]
604    fn hash_table_soft_free_resurrection() {
605        let mut a = BlockAllocator::new(4);
606        let b = a.allocate().unwrap();
607        let h = 0xABCD_1234u64;
608        a.register_block_hash(b, h);
609        a.free(&[b]); // ref=0, block in free_list, hash STILL registered
610        assert_eq!(a.free_count(), 4);
611        assert_eq!(a.ref_count(b), 0);
612        // Cache hit on the soft-free block: must resurrect with ref=1
613        let got = a.try_acquire_by_hash(h);
614        assert_eq!(got, Some(b));
615        assert_eq!(a.ref_count(b), 1);
616        // The block is OFF the free_list now.
617        assert_eq!(a.free_count(), 3);
618    }
619
620    #[test]
621    fn hash_table_miss_returns_none() {
622        let mut a = BlockAllocator::new(4);
623        let b = a.allocate().unwrap();
624        a.register_block_hash(b, 1);
625        assert_eq!(a.try_acquire_by_hash(99), None);
626        // No side effects on miss
627        assert_eq!(a.ref_count(b), 1);
628    }
629
630    #[test]
631    fn hash_table_evicts_on_realloc() {
632        // Allocate, register hash, free → soft-free with hash. Then
633        // allocate a new block — the recycled one's hash must be erased
634        // because its content is about to be overwritten.
635        let mut a = BlockAllocator::new(2);
636        let b1 = a.allocate().unwrap();
637        let h = 0x1234u64;
638        a.register_block_hash(b1, h);
639        a.free(&[b1]); // soft-free
640        assert_eq!(a.hash_table_size(), 1);
641        // Allocate to consume the only free block — should recycle b1
642        // and evict its hash.
643        let b2 = a.allocate().unwrap();
644        assert_eq!(b2, b1, "LIFO should reuse b1");
645        assert_eq!(a.hash_table_size(), 0, "stale hash erased on realloc");
646        assert_eq!(a.try_acquire_by_hash(h), None);
647    }
648
649    #[test]
650    fn hash_table_replace_block_hash() {
651        let mut a = BlockAllocator::new(4);
652        let b = a.allocate().unwrap();
653        a.register_block_hash(b, 1);
654        a.register_block_hash(b, 2); // overwrite
655        assert_eq!(a.hash_table_size(), 1);
656        assert_eq!(a.try_acquire_by_hash(1), None);
657        assert_eq!(a.try_acquire_by_hash(2), Some(b));
658    }
659}