Skip to main content

poly_kv/
pool.rs

1use std::time::Instant;
2
3use crate::codec::{create_codec, CompressedBlock};
4use crate::digest_compat::Digest;
5use crate::error::{PolyKvError, Result};
6use crate::manifest::PoolManifest;
7use crate::policy::{CompressionPolicy, CODEC_FIB_K4_N32};
8use crate::receipt::{now_unix, CompressedAttentionSelectionReceipt, PoolBuildReceipt};
9use crate::shape::KvTensorShape;
10
11/// One layer's worth of compressed KV blocks in the shared pool.
12#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
13pub struct PoolLayer {
14    /// Zero-based layer index.
15    pub layer_index: u32,
16    /// Key blocks — one per token, fib-quant compressed.
17    pub key_blocks: Vec<CompressedBlock>,
18    /// Value blocks — one per token, fib-quant compressed.
19    pub value_blocks: Vec<CompressedBlock>,
20    /// Blake3 digest of all blocks in this layer (canonical JSON).
21    pub block_digest: Digest,
22}
23
24impl PoolLayer {
25    /// Compute a content digest over the blocks in this layer.
26    fn compute_digest(&self) -> Result<Digest> {
27        // Serialize key + value payloads to compute a deterministic digest
28        let key_digests: Vec<&str> = self
29            .key_blocks
30            .iter()
31            .map(|b| b.payload_digest.hex())
32            .collect();
33        let value_digests: Vec<&str> = self
34            .value_blocks
35            .iter()
36            .map(|b| b.payload_digest.hex())
37            .collect();
38        let payload = serde_json::json!({
39            "layer_index": self.layer_index,
40            "key_digests": key_digests,
41            "value_digests": value_digests,
42        });
43        crate::digest_compat::compute_json(&payload)
44    }
45}
46
47/// One selected compressed-attention hit from the shared cold pool.
48#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
49pub struct CompressedAttentionHit {
50    /// Token index within the shared pool.
51    pub token_index: usize,
52    /// Compressed-domain key score for the query.
53    pub score: f32,
54    /// Decoded value vector for the selected token/head only.
55    pub value: Vec<f32>,
56}
57
58/// Output of compressed-domain top-k attention selection over the cold pool.
59#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
60pub struct CompressedAttentionSelection {
61    /// Selected hits sorted by descending compressed-domain score.
62    pub hits: Vec<CompressedAttentionHit>,
63    /// Receipt proving candidate scoring did not fully decode the layer.
64    pub receipt: CompressedAttentionSelectionReceipt,
65}
66
67/// A shared, compressed KV cache pool.
68///
69/// The pool holds fib-quant compressed KV blocks for tokens shared across
70/// agents. It is immutable after construction. Agent shells can be materialized
71/// from this pool by adding agent-specific tokens compressed with turbo-quant.
72#[derive(Debug, Clone)]
73pub struct SharedKVPool {
74    /// Pool manifest with shape, policy, timestamps.
75    pub manifest: PoolManifest,
76    /// One PoolLayer per transformer layer.
77    pub layers: Vec<PoolLayer>,
78    /// The compression policy used.
79    pub policy: CompressionPolicy,
80}
81
82impl SharedKVPool {
83    /// Build a shared KV pool from a corpus of token vectors.
84    ///
85    /// # Arguments
86    /// * `corpus` - List of (token_id, kv_vector) pairs. Each kv_vector must be
87    ///   the concatenated keys and values for all layers and heads: `[layer0_head0_key,
88    ///   layer0_head0_value, layer0_head1_key, ...]`.
89    /// * `shape` - The tensor shape describing the model architecture.
90    /// * `seed` - Deterministic seed for codec operations.
91    ///
92    /// # Returns
93    /// The built SharedKVPool and a PoolBuildReceipt.
94    pub fn build(
95        corpus: &[(String, Vec<f32>)],
96        shape: &KvTensorShape,
97        seed: u64,
98    ) -> Result<(Self, PoolBuildReceipt)> {
99        let start = Instant::now();
100
101        if corpus.is_empty() {
102            return Err(PolyKvError::EmptyCorpus);
103        }
104
105        shape.validate()?;
106        let policy = CompressionPolicy::default_two_tier();
107        policy.validate()?;
108
109        let num_tokens = corpus.len();
110        let num_layers = shape.num_layers as usize;
111        let num_kv_heads = shape.num_kv_heads as usize;
112        let head_dim = shape.head_dim;
113
114        // Validate that each corpus vector has the correct length
115        let expected_len = num_layers * num_kv_heads * head_dim * 2; // key + value per head per layer
116        for (token_id, vec) in corpus {
117            if vec.len() != expected_len {
118                return Err(PolyKvError::DimensionMismatch {
119                    expected: expected_len,
120                    got: vec.len(),
121                });
122            }
123            if vec.iter().any(|v| !v.is_finite()) {
124                return Err(PolyKvError::CorruptPayload(format!(
125                    "token {} contains non-finite values",
126                    token_id
127                )));
128            }
129        }
130
131        // Create the fib-quant codec for shared pool compression.
132        // We compress per-head key and value vectors in batched calls. The
133        // fib-quant adapter's encode_batch dispatches to the GPU backend when
134        // the batch is large enough (>= 16 vectors) and dim is large enough
135        // (>= 64), which is always true at the (layer, head) granularity for
136        // a corpus of more than ~4 tokens.
137        let codec = create_codec(CODEC_FIB_K4_N32, head_dim, Some(&policy.fib_config), None)?;
138
139        let mut layers: Vec<PoolLayer> = Vec::with_capacity(num_layers);
140        let mut total_compressed_bytes: u64 = 0;
141
142        // Build a closure that builds one layer. Each layer is independent
143        // (different head/data ranges in the corpus), so we can dispatch
144        // them in parallel via Rayon when the feature is enabled.
145        let build_layer = |layer_idx: usize| -> Result<(PoolLayer, u64)> {
146            // Collect every (token, head) key vector and every (token, head)
147            // value vector for this layer up front, then dispatch two batched
148            // encode calls (one for keys, one for values). This is what lets
149            // fib-quant reach its GPU batch threshold.
150            let mut key_inputs: Vec<Vec<f32>> = Vec::with_capacity(num_tokens * num_kv_heads);
151            let mut value_inputs: Vec<Vec<f32>> = Vec::with_capacity(num_tokens * num_kv_heads);
152            for (_token_id, vec) in corpus.iter() {
153                for head_idx in 0..num_kv_heads {
154                    let base_offset =
155                        layer_idx * num_kv_heads * head_dim * 2 + head_idx * head_dim * 2;
156                    let key_end = base_offset + head_dim;
157                    let value_end = key_end + head_dim;
158                    key_inputs.push(vec[base_offset..key_end].to_vec());
159                    value_inputs.push(vec[key_end..value_end].to_vec());
160                }
161            }
162
163            let key_refs: Vec<&[f32]> = key_inputs.iter().map(|v| v.as_slice()).collect();
164            let value_refs: Vec<&[f32]> = value_inputs.iter().map(|v| v.as_slice()).collect();
165
166            let mut key_blocks: Vec<CompressedBlock>;
167            let mut value_blocks: Vec<CompressedBlock>;
168            if let (Some(key_payload), Some(value_payload)) = (
169                codec.encode_batch_compact(&key_refs, seed)?,
170                codec.encode_batch_compact(&value_refs, seed)?,
171            ) {
172                key_blocks = vec![CompressedBlock::new(
173                    codec.codec_id(),
174                    key_payload,
175                    head_dim,
176                )];
177                value_blocks = vec![CompressedBlock::new(
178                    codec.codec_id(),
179                    value_payload,
180                    head_dim,
181                )];
182            } else {
183                let encoded_keys = codec.encode_batch(&key_refs, seed)?;
184                let encoded_values = codec.encode_batch(&value_refs, seed)?;
185
186                if encoded_keys.len() != num_tokens * num_kv_heads
187                    || encoded_values.len() != num_tokens * num_kv_heads
188                {
189                    return Err(PolyKvError::Internal(format!(
190                        "encode_batch returned {} keys / {} values, expected {} (layer {})",
191                        encoded_keys.len(),
192                        encoded_values.len(),
193                        num_tokens * num_kv_heads,
194                        layer_idx
195                    )));
196                }
197
198                key_blocks = Vec::with_capacity(num_tokens * num_kv_heads);
199                value_blocks = Vec::with_capacity(num_tokens * num_kv_heads);
200                for (k_payload, v_payload) in encoded_keys.into_iter().zip(encoded_values) {
201                    key_blocks.push(CompressedBlock::new(codec.codec_id(), k_payload, head_dim));
202                    value_blocks.push(CompressedBlock::new(codec.codec_id(), v_payload, head_dim));
203                }
204            }
205            let layer_bytes: u64 = key_blocks
206                .iter()
207                .map(|b| b.compressed_bytes as u64)
208                .sum::<u64>()
209                + value_blocks
210                    .iter()
211                    .map(|b| b.compressed_bytes as u64)
212                    .sum::<u64>();
213
214            let mut layer = PoolLayer {
215                layer_index: layer_idx as u32,
216                key_blocks,
217                value_blocks,
218                block_digest: Digest::from_hex_unchecked(""),
219            };
220            layer.block_digest = layer.compute_digest()?;
221            Ok((layer, layer_bytes))
222        };
223
224        // Layer build: serial or parallel. Both paths preserve layer order
225        // in the output (we collect by index, not by completion time).
226        let layer_results: Vec<Result<(PoolLayer, u64)>> = {
227            #[cfg(feature = "parallel_pool")]
228            {
229                use rayon::prelude::*;
230                (0..num_layers).into_par_iter().map(build_layer).collect()
231            }
232            #[cfg(not(feature = "parallel_pool"))]
233            {
234                (0..num_layers).map(build_layer).collect()
235            }
236        };
237        for r in layer_results {
238            let (layer, layer_bytes) = r?;
239            total_compressed_bytes += layer_bytes;
240            layers.push(layer);
241        }
242
243        let raw_size_bytes = shape.total_kv_bytes(num_tokens) as u64;
244        let fib_build_ms = start.elapsed().as_millis() as u64;
245        let built_at_unix = now_unix();
246
247        // Compute pool digest
248        let layer_digests: Vec<Digest> = layers.iter().map(|l| l.block_digest.clone()).collect();
249        let pool_id = crate::digest_compat::compute_json(&layer_digests)?;
250
251        let manifest = PoolManifest::new(
252            pool_id.clone(),
253            shape.clone(),
254            policy.clone(),
255            num_tokens as u32,
256            shape.num_layers,
257            total_compressed_bytes,
258            raw_size_bytes,
259            seed,
260            built_at_unix,
261        )?;
262
263        // Honest backend label: ask the codec whether the per-(layer,head)
264        // batch we'd actually dispatch crossed the GPU threshold. The fib-quant
265        // encoder only goes to GPU when batch size and dim clear the runtime
266        // minimums; a 4-doc, 12-head corpus is GPU, but a 4-doc, 4-head
267        // corpus (16 vectors exactly) is right at the edge and still GPU,
268        // while a 2-doc corpus falls through to CPU even with --features gpu.
269        let batch_n = num_tokens * num_kv_heads;
270        let backend = if codec.is_gpu_accelerated_for(batch_n, head_dim) {
271            "gpu"
272        } else {
273            "cpu"
274        };
275        let codebook_digest = codec
276            .codebook_digest(seed)
277            .map(Digest::from_hex_unchecked)
278            .unwrap_or_else(|| Digest::from_hex_unchecked(""));
279        let rotation_digest = codec
280            .rotation_digest(seed)
281            .map(Digest::from_hex_unchecked)
282            .unwrap_or_else(|| Digest::from_hex_unchecked(""));
283        let receipt = PoolBuildReceipt::new(
284            pool_id,
285            layer_digests,
286            codebook_digest,
287            rotation_digest,
288            num_tokens as u32,
289            fib_build_ms,
290            total_compressed_bytes,
291            raw_size_bytes,
292            policy.clone(),
293            seed,
294            built_at_unix,
295        )
296        .with_backend(backend);
297
298        Ok((
299            Self {
300                manifest,
301                layers,
302                policy,
303            },
304            receipt,
305        ))
306    }
307    /// Materialize an agent shell from this pool.
308    ///
309    /// Agent-specific tokens (not in the shared corpus) are compressed with
310    /// turbo-quant and appended as shell layers. Tokens already in the pool
311    /// are referenced by digest only.
312    ///
313    /// # Arguments
314    /// * `agent_id` - Identifier for this agent.
315    /// * `agent_tokens` - Token vectors specific to this agent.
316    /// * `seed` - Deterministic seed for turbo-quant operations.
317    ///
318    /// # Returns
319    /// An AgentShell and a ShellMaterializeReceipt.
320    pub fn materialize_shell(
321        &self,
322        agent_id: &str,
323        agent_tokens: &[(String, Vec<f32>)],
324        seed: u64,
325    ) -> Result<(
326        crate::shell::AgentShell,
327        crate::receipt::ShellMaterializeReceipt,
328    )> {
329        crate::shell::materialize_shell(self, agent_id, agent_tokens, seed)
330    }
331
332    /// Inject a shell into a KV cache.
333    ///
334    /// The injection receipt traces every block from its source (pool or shell)
335    /// to its target position in the cache.
336    pub fn inject_into_cache(
337        _shell: &crate::shell::AgentShell,
338        _base_cache: &mut dyn CacheTarget,
339    ) -> Result<crate::receipt::InjectionReceipt> {
340        // The CacheTarget trait allows injection without knowing the concrete cache type.
341        // This is a generic injection path — concrete adapters (e.g., for HF DynamicCache)
342        // live in downstream crates.
343        Err(PolyKvError::Internal(
344            "inject_into_cache requires a concrete cache adapter; use inject_into_cache_with_adaptor"
345                .into(),
346        ))
347    }
348
349    /// Decompress all shared-pool blocks for a single layer, returning the
350    /// reconstructed K and V tensors in the original model layout.
351    ///
352    /// Output shape: `keys[head_idx]` is a flat `Vec<f32>` of length
353    /// `num_tokens * head_dim` containing all tokens' K vectors for that
354    /// head, in token order. Same for `values`. Lossy (fib-quant) but
355    /// reproducible: same corpus + same seed + same codec yields the same
356    /// reconstructed floats.
357    ///
358    /// This is the inverse of `build` and the symmetric counterpart of
359    /// `materialize_shell`'s per-agent shell decompression. It's the
360    /// path HuggingFace `DynamicCache.update()` and similar KV-cache
361    /// integrations use to populate a fresh cache from the pool.
362    pub fn decompress_layer(&self, layer_idx: usize) -> Result<DecompressedLayer> {
363        if layer_idx >= self.layers.len() {
364            return Err(PolyKvError::Internal(format!(
365                "decompress_layer: layer_idx {layer_idx} out of range (have {})",
366                self.layers.len()
367            )));
368        }
369        let layer = &self.layers[layer_idx];
370        let head_dim = self.manifest.shape.head_dim;
371        let num_heads = self.manifest.shape.num_kv_heads as usize;
372        let num_tokens = if layer.key_blocks.len() == 1 && layer.value_blocks.len() == 1 {
373            self.manifest.num_shared_tokens as usize
374        } else {
375            layer.key_blocks.len() / num_heads
376        };
377        if layer.value_blocks.len() != layer.key_blocks.len() {
378            return Err(PolyKvError::Internal(format!(
379                "layer {}: key/value block count mismatch ({} vs {})",
380                layer_idx,
381                layer.key_blocks.len(),
382                layer.value_blocks.len()
383            )));
384        }
385        if layer.key_blocks.len() != num_tokens * num_heads && layer.key_blocks.len() != 1 {
386            return Err(PolyKvError::Internal(format!(
387                "layer {}: block count {} != num_tokens * num_heads {}",
388                layer_idx,
389                layer.key_blocks.len(),
390                num_tokens * num_heads
391            )));
392        }
393        // All shared-pool blocks use the same codec (manifest.shared_codec).
394        // Build a single codec and reuse for the whole layer.
395        let shared_codec: crate::policy::CodecId = self.manifest.shared_codec.clone();
396        let codec = create_codec(
397            &shared_codec,
398            head_dim,
399            Some(&self.manifest.policy.fib_config),
400            Some(&self.manifest.policy.turbo_config),
401        )?;
402        let seed = self.manifest.build_seed;
403        // Block ordering: [token_0_head_0, token_0_head_1, ..., token_0_head_{H-1},
404        //                  token_1_head_0, ..., token_{T-1}_head_{H-1}]
405        // i.e. flat index = token_idx * num_heads + head_idx.
406        // Per-head output: keys[head_idx] = concatenation of every token's K for that head.
407        let mut keys_per_head: Vec<Vec<f32>> =
408            vec![Vec::with_capacity(num_tokens * head_dim); num_heads];
409        let mut values_per_head: Vec<Vec<f32>> =
410            vec![Vec::with_capacity(num_tokens * head_dim); num_heads];
411        if layer.key_blocks.len() == 1 && layer.value_blocks.len() == 1 {
412            if let (Some(decoded_keys), Some(decoded_values)) = (
413                codec.decode_batch_compact(&layer.key_blocks[0].encoded_payload, seed)?,
414                codec.decode_batch_compact(&layer.value_blocks[0].encoded_payload, seed)?,
415            ) {
416                if decoded_keys.len() != num_tokens * num_heads
417                    || decoded_values.len() != num_tokens * num_heads
418                {
419                    return Err(PolyKvError::Internal(format!(
420                        "FB2 decoded {} keys / {} values, expected {} (layer {})",
421                        decoded_keys.len(),
422                        decoded_values.len(),
423                        num_tokens * num_heads,
424                        layer_idx
425                    )));
426                }
427                for token_idx in 0..num_tokens {
428                    for head_idx in 0..num_heads {
429                        let block_idx = token_idx * num_heads + head_idx;
430                        let k_decoded = &decoded_keys[block_idx];
431                        let v_decoded = &decoded_values[block_idx];
432                        if k_decoded.len() != head_dim || v_decoded.len() != head_dim {
433                            return Err(PolyKvError::Internal(format!(
434                                "FB2 decoded vector length mismatch (layer {}, token {}, head {})",
435                                layer_idx, token_idx, head_idx
436                            )));
437                        }
438                        keys_per_head[head_idx].extend_from_slice(k_decoded);
439                        values_per_head[head_idx].extend_from_slice(v_decoded);
440                    }
441                }
442            } else {
443                if num_tokens != 1 || num_heads != 1 {
444                    return Err(PolyKvError::Internal(format!(
445                        "single non-compact block cannot decode shape tokens={} heads={} (layer {})",
446                        num_tokens, num_heads, layer_idx
447                    )));
448                }
449                let k_decoded = codec.decode(&layer.key_blocks[0].encoded_payload, seed)?;
450                let v_decoded = codec.decode(&layer.value_blocks[0].encoded_payload, seed)?;
451                keys_per_head[0].extend_from_slice(&k_decoded);
452                values_per_head[0].extend_from_slice(&v_decoded);
453            }
454        } else {
455            for token_idx in 0..num_tokens {
456                for head_idx in 0..num_heads {
457                    let block_idx = token_idx * num_heads + head_idx;
458                    let k_payload = &layer.key_blocks[block_idx].encoded_payload;
459                    let v_payload = &layer.value_blocks[block_idx].encoded_payload;
460                    let k_decoded = codec.decode(k_payload, seed)?;
461                    let v_decoded = codec.decode(v_payload, seed)?;
462                    if k_decoded.len() != head_dim {
463                        return Err(PolyKvError::Internal(format!(
464                            "decoded key length {} != head_dim {} (layer {}, token {}, head {})",
465                            k_decoded.len(),
466                            head_dim,
467                            layer_idx,
468                            token_idx,
469                            head_idx
470                        )));
471                    }
472                    keys_per_head[head_idx].extend_from_slice(&k_decoded);
473                    values_per_head[head_idx].extend_from_slice(&v_decoded);
474                }
475            }
476        }
477        Ok(DecompressedLayer {
478            layer_index: layer_idx as u32,
479            num_tokens,
480            num_heads,
481            head_dim,
482            keys: keys_per_head,
483            values: values_per_head,
484        })
485    }
486
487    /// Query the compressed shared cold pool without fully decoding the layer.
488    ///
489    /// This scores compressed Fib codes for one layer/head, selects the top-k
490    /// tokens, then decodes only the selected value vectors. It is the ProveKV
491    /// cold-pool read path: compressed candidate scoring first, bounded value
492    /// decode second, and a receipt proving no full-layer decode occurred.
493    pub fn attention_topk_compressed(
494        &self,
495        layer_idx: usize,
496        head_idx: usize,
497        query: &[f32],
498        top_k: usize,
499    ) -> Result<CompressedAttentionSelection> {
500        #[cfg(not(feature = "fib"))]
501        {
502            let _ = (layer_idx, head_idx, query, top_k);
503            return Err(PolyKvError::CodecUnavailable {
504                codec: CODEC_FIB_K4_N32.into(),
505                feature: "fib".into(),
506            });
507        }
508
509        #[cfg(feature = "fib")]
510        {
511            if layer_idx >= self.layers.len() {
512                return Err(PolyKvError::LayerIndexOutOfBounds {
513                    index: layer_idx as u32,
514                    total: self.layers.len() as u32,
515                });
516            }
517            let head_dim = self.manifest.shape.head_dim;
518            if query.len() != head_dim {
519                return Err(PolyKvError::DimensionMismatch {
520                    expected: head_dim,
521                    got: query.len(),
522                });
523            }
524            if head_idx >= self.manifest.shape.num_kv_heads as usize {
525                return Err(PolyKvError::Internal(format!(
526                    "head_idx {head_idx} out of range (have {})",
527                    self.manifest.shape.num_kv_heads
528                )));
529            }
530            if self.manifest.shared_codec != CODEC_FIB_K4_N32 {
531                return Err(PolyKvError::InvalidPolicy(format!(
532                    "compressed cold-pool attention requires shared codec {CODEC_FIB_K4_N32}, got {}",
533                    self.manifest.shared_codec
534                )));
535            }
536
537            let layer = &self.layers[layer_idx];
538            if layer.key_blocks.len() != layer.value_blocks.len() {
539                return Err(PolyKvError::Internal(format!(
540                    "layer {layer_idx}: key/value block count mismatch ({} vs {})",
541                    layer.key_blocks.len(),
542                    layer.value_blocks.len()
543                )));
544            }
545            let num_heads = self.manifest.shape.num_kv_heads as usize;
546            let num_tokens = self.manifest.num_shared_tokens as usize;
547            let expected_codes = num_tokens * num_heads;
548            let adapter = crate::codec::FibQuantAdapter::new(
549                head_dim,
550                self.manifest.policy.fib_config.k,
551                self.manifest.policy.fib_config.n,
552                self.manifest.policy.fib_config.training_samples,
553                self.manifest.policy.fib_config.lloyd_restarts,
554                self.manifest.policy.fib_config.lloyd_iterations,
555            )?;
556            let seed = self.manifest.build_seed;
557            let mut key_codes = Vec::with_capacity(expected_codes);
558            for block in &layer.key_blocks {
559                key_codes.extend(adapter.decode_codes_payload(&block.encoded_payload, seed)?);
560            }
561            let mut value_codes = Vec::with_capacity(expected_codes);
562            for block in &layer.value_blocks {
563                value_codes.extend(adapter.decode_codes_payload(&block.encoded_payload, seed)?);
564            }
565            if key_codes.len() != expected_codes || value_codes.len() != expected_codes {
566                return Err(PolyKvError::Internal(format!(
567                    "layer {layer_idx}: decoded {} key codes / {} value codes, expected {expected_codes}",
568                    key_codes.len(),
569                    value_codes.len()
570                )));
571            }
572
573            let quantizer = adapter.build_quantizer(seed)?;
574            let scorer = fib_quant::FibScorer::new(quantizer).map_err(|e| {
575                PolyKvError::Internal(format!("fib compressed scorer construction failed: {e}"))
576            })?;
577            let prepared = scorer.prepare_query(query).map_err(|e| {
578                PolyKvError::Internal(format!("fib compressed query preparation failed: {e}"))
579            })?;
580            let mut scored = Vec::with_capacity(num_tokens);
581            for token_idx in 0..num_tokens {
582                let code_idx = token_idx * num_heads + head_idx;
583                let score = scorer
584                    .score_prepared(&prepared, &key_codes[code_idx])
585                    .map_err(|e| {
586                        PolyKvError::Internal(format!("fib compressed score failed: {e}"))
587                    })?;
588                scored.push((token_idx, code_idx, score));
589            }
590            let selected = top_k.min(scored.len());
591            if selected > 0 && selected < scored.len() {
592                scored.select_nth_unstable_by(selected - 1, |a, b| {
593                    b.2.total_cmp(&a.2).then_with(|| a.0.cmp(&b.0))
594                });
595                scored.truncate(selected);
596            }
597            scored.sort_by(|a, b| b.2.total_cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
598            let mut hits = Vec::with_capacity(selected);
599            for &(token_index, code_idx, score) in scored.iter().take(selected) {
600                let value = scorer
601                    .quantizer()
602                    .decode(&value_codes[code_idx])
603                    .map_err(|e| {
604                        PolyKvError::DecompressionFailed(format!(
605                            "fib selected value decode failed: {e}"
606                        ))
607                    })?;
608                if value.len() != head_dim {
609                    return Err(PolyKvError::DimensionMismatch {
610                        expected: head_dim,
611                        got: value.len(),
612                    });
613                }
614                hits.push(CompressedAttentionHit {
615                    token_index,
616                    score,
617                    value,
618                });
619            }
620            let receipt = CompressedAttentionSelectionReceipt::new(
621                self.manifest.pool_id.clone(),
622                layer_idx as u32,
623                head_idx as u32,
624                num_tokens as u32,
625                hits.len() as u32,
626                num_tokens as u64,
627                hits.len() as u64,
628                false,
629                "fib_cold_pool_compressed_score_topk_value_decode",
630                self.manifest.shared_codec.clone(),
631                now_unix(),
632            );
633            receipt.validate()?;
634            Ok(CompressedAttentionSelection { hits, receipt })
635        }
636    }
637
638    // ---------- pool search ----------
639
640    /// Build a prepared compressed index for one layer/head.
641    ///
642    /// This decodes key/value codes and builds the FibScorer once, so that
643    /// subsequent `attention_topk_compressed_prepared` calls only need to
644    /// prepare the query and score candidates without rebuilding codec state.
645    #[cfg(feature = "fib")]
646    pub fn prepare_compressed_index(
647        &self,
648        layer_idx: usize,
649        head_idx: usize,
650    ) -> Result<PreparedCompressedIndex> {
651        if layer_idx >= self.layers.len() {
652            return Err(PolyKvError::LayerIndexOutOfBounds {
653                index: layer_idx as u32,
654                total: self.layers.len() as u32,
655            });
656        }
657        let head_dim = self.manifest.shape.head_dim;
658        let num_heads = self.manifest.shape.num_kv_heads as usize;
659        if head_idx >= num_heads {
660            return Err(PolyKvError::Internal(format!(
661                "head_idx {head_idx} out of range (have {num_heads})"
662            )));
663        }
664        if self.manifest.shared_codec != CODEC_FIB_K4_N32 {
665            return Err(PolyKvError::InvalidPolicy(format!(
666                "compressed cold-pool attention requires shared codec {CODEC_FIB_K4_N32}, got {}",
667                self.manifest.shared_codec
668            )));
669        }
670        let layer = &self.layers[layer_idx];
671        if layer.key_blocks.len() != layer.value_blocks.len() {
672            return Err(PolyKvError::Internal(format!(
673                "layer {layer_idx}: key/value block count mismatch ({} vs {})",
674                layer.key_blocks.len(),
675                layer.value_blocks.len()
676            )));
677        }
678        let num_tokens = self.manifest.num_shared_tokens as usize;
679        let expected_codes = num_tokens * num_heads;
680        let adapter = crate::codec::FibQuantAdapter::new(
681            head_dim,
682            self.manifest.policy.fib_config.k,
683            self.manifest.policy.fib_config.n,
684            self.manifest.policy.fib_config.training_samples,
685            self.manifest.policy.fib_config.lloyd_restarts,
686            self.manifest.policy.fib_config.lloyd_iterations,
687        )?;
688        let seed = self.manifest.build_seed;
689        let mut key_codes = Vec::with_capacity(expected_codes);
690        for block in &layer.key_blocks {
691            key_codes.extend(adapter.decode_codes_payload(&block.encoded_payload, seed)?);
692        }
693        let mut value_codes = Vec::with_capacity(expected_codes);
694        for block in &layer.value_blocks {
695            value_codes.extend(adapter.decode_codes_payload(&block.encoded_payload, seed)?);
696        }
697        if key_codes.len() != expected_codes || value_codes.len() != expected_codes {
698            return Err(PolyKvError::Internal(format!(
699                "layer {layer_idx}: decoded {} key codes / {} value codes, expected {expected_codes}",
700                key_codes.len(),
701                value_codes.len()
702            )));
703        }
704        let quantizer = adapter.build_quantizer(seed)?;
705        let scorer = fib_quant::FibScorer::new(quantizer).map_err(|e| {
706            PolyKvError::Internal(format!("fib compressed scorer construction failed: {e}"))
707        })?;
708        Ok(PreparedCompressedIndex {
709            layer_idx,
710            head_idx,
711            head_dim,
712            num_tokens,
713            num_heads,
714            key_codes,
715            value_codes,
716            scorer,
717        })
718    }
719
720    /// Compressed top-k attention using a pre-built index.
721    ///
722    /// This avoids rebuilding the codec adapter, decoding codes, and
723    /// constructing the scorer on every call. Only the query is prepared
724    /// per call (O(dim)), then candidates are scored (O(num_tokens)).
725    #[cfg(feature = "fib")]
726    pub fn attention_topk_compressed_prepared(
727        &self,
728        index: &PreparedCompressedIndex,
729        query: &[f32],
730        top_k: usize,
731    ) -> Result<CompressedAttentionSelection> {
732        if query.len() != index.head_dim {
733            return Err(PolyKvError::DimensionMismatch {
734                expected: index.head_dim,
735                got: query.len(),
736            });
737        }
738        let head_idx = index.head_idx;
739        let num_heads = index.num_heads;
740        let num_tokens = index.num_tokens;
741        let prepared = index.scorer.prepare_query(query).map_err(|e| {
742            PolyKvError::Internal(format!("fib compressed query preparation failed: {e}"))
743        })?;
744        let mut scored: Vec<(usize, usize, f32)> = Vec::with_capacity(num_tokens);
745        for token_idx in 0..num_tokens {
746            let code_idx = token_idx * num_heads + head_idx;
747            let score = index
748                .scorer
749                .score_prepared(&prepared, &index.key_codes[code_idx])
750                .map_err(|e| PolyKvError::Internal(format!("fib compressed score failed: {e}")))?;
751            scored.push((token_idx, code_idx, score));
752        }
753        let selected = top_k.min(scored.len());
754        if selected > 0 && selected < scored.len() {
755            scored.select_nth_unstable_by(selected - 1, |a, b| {
756                b.2.total_cmp(&a.2).then_with(|| a.0.cmp(&b.0))
757            });
758            scored.truncate(selected);
759        }
760        scored.sort_by(|a, b| b.2.total_cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
761        let mut hits = Vec::with_capacity(selected);
762        for &(token_index, code_idx, score) in scored.iter().take(selected) {
763            let value = index
764                .scorer
765                .quantizer()
766                .decode(&index.value_codes[code_idx])
767                .map_err(|e| {
768                    PolyKvError::DecompressionFailed(format!(
769                        "fib selected value decode failed: {e}"
770                    ))
771                })?;
772            if value.len() != index.head_dim {
773                return Err(PolyKvError::DimensionMismatch {
774                    expected: index.head_dim,
775                    got: value.len(),
776                });
777            }
778            hits.push(CompressedAttentionHit {
779                token_index,
780                score,
781                value,
782            });
783        }
784        let receipt = CompressedAttentionSelectionReceipt::new(
785            self.manifest.pool_id.clone(),
786            index.layer_idx as u32,
787            head_idx as u32,
788            num_tokens as u32,
789            hits.len() as u32,
790            num_tokens as u64,
791            hits.len() as u64,
792            false,
793            "fib_cold_pool_prepared_compressed_score_topk_value_decode",
794            self.manifest.shared_codec.clone(),
795            now_unix(),
796        );
797        receipt.validate()?;
798        Ok(CompressedAttentionSelection { hits, receipt })
799    }
800
801    /// Build a fully prepared index that pre-unpacks all key indices and norms.
802    ///
803    /// This eliminates per-call `unpack_indices()` and `decode_stored_norm()`
804    /// overhead, making the scoring loop just Gram table lookups.
805    #[cfg(feature = "fib")]
806    pub fn prepare_fully_compressed_index(
807        &self,
808        layer_idx: usize,
809        head_idx: usize,
810    ) -> Result<FullyPreparedCompressedIndex> {
811        let prep = self.prepare_compressed_index(layer_idx, head_idx)?;
812        let block_count = prep.scorer.quantizer().profile().block_count() as usize;
813        let wire_bits = prep.scorer.quantizer().profile().wire_index_bits;
814        let num_entries = prep.num_tokens * prep.num_heads;
815        let mut key_indices_flat = Vec::with_capacity(num_entries * block_count);
816        let mut key_norms = Vec::with_capacity(num_entries);
817        for i in 0..num_entries {
818            let indices = fib_quant::bitpack::unpack_indices(
819                &prep.key_codes[i].indices,
820                block_count,
821                wire_bits,
822            )
823            .map_err(|e| PolyKvError::Internal(format!("fib unpack_indices failed: {e}")))?;
824            key_indices_flat.extend_from_slice(&indices);
825            // Decode stored norm using the public fib-quant API.
826            let norm = fib_quant::scoring::decode_stored_norm(
827                &prep.key_codes[i],
828                prep.scorer.quantizer().profile(),
829            )
830            .map_err(|e| PolyKvError::Internal(format!("fib decode_stored_norm failed: {e}")))?;
831            key_norms.push(norm as f32);
832        }
833        Ok(FullyPreparedCompressedIndex {
834            layer_idx: prep.layer_idx,
835            head_idx: prep.head_idx,
836            head_dim: prep.head_dim,
837            num_tokens: prep.num_tokens,
838            num_heads: prep.num_heads,
839            key_indices_flat,
840            key_norms,
841            block_count,
842            value_codes: prep.value_codes,
843            scorer: prep.scorer,
844        })
845    }
846
847    /// Compressed top-k attention using a fully prepared index.
848    ///
849    /// Delegates to `attention_topk_prefetched` which pre-fetches Gram rows
850    /// into a contiguous buffer for cache-friendly scoring. This is the
851    /// fastest single-head path.
852    #[cfg(feature = "fib")]
853    pub fn attention_topk_fully_prepared(
854        &self,
855        index: &FullyPreparedCompressedIndex,
856        query: &[f32],
857        top_k: usize,
858    ) -> Result<CompressedAttentionSelection> {
859        self.attention_topk_prefetched(index, query, top_k)
860    }
861
862    /// Compressed top-k attention using pre-fetched Gram rows.
863    ///
864    /// This is the fastest scoring path: query preparation + Gram row
865    /// pre-fetch happens once, then the per-token scoring loop is just
866    /// sequential gathers from a small contiguous buffer.
867    #[cfg(feature = "fib")]
868    pub fn attention_topk_prefetched(
869        &self,
870        index: &FullyPreparedCompressedIndex,
871        query: &[f32],
872        top_k: usize,
873    ) -> Result<CompressedAttentionSelection> {
874        let prefetched = index.prepare_gram_rows(query)?;
875        let mut scored = index.score_all_tokens(&prefetched)?;
876
877        let selected = top_k.min(scored.len());
878        if selected > 0 && selected < scored.len() {
879            scored.select_nth_unstable_by(selected - 1, |a, b| {
880                b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0))
881            });
882            scored.truncate(selected);
883        }
884        scored.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
885
886        let num_tokens = index.num_tokens;
887        let head_idx = index.head_idx;
888        let num_heads = index.num_heads;
889        let mut hits = Vec::with_capacity(selected);
890        for &(token_index, score) in scored.iter().take(selected) {
891            let code_idx = token_index * num_heads + head_idx;
892            let value = index
893                .scorer
894                .quantizer()
895                .decode(&index.value_codes[code_idx])
896                .map_err(|e| {
897                    PolyKvError::DecompressionFailed(format!(
898                        "fib selected value decode failed: {e}"
899                    ))
900                })?;
901            if value.len() != index.head_dim {
902                return Err(PolyKvError::DimensionMismatch {
903                    expected: index.head_dim,
904                    got: value.len(),
905                });
906            }
907            hits.push(CompressedAttentionHit {
908                token_index,
909                score,
910                value,
911            });
912        }
913        let receipt = CompressedAttentionSelectionReceipt::new(
914            self.manifest.pool_id.clone(),
915            index.layer_idx as u32,
916            head_idx as u32,
917            num_tokens as u32,
918            hits.len() as u32,
919            num_tokens as u64,
920            hits.len() as u64,
921            false,
922            "fib_cold_pool_prefetched_gram_rows_topk_value_decode",
923            self.manifest.shared_codec.clone(),
924            now_unix(),
925        );
926        receipt.validate()?;
927        Ok(CompressedAttentionSelection { hits, receipt })
928    }
929
930    /// Batch multi-head compressed top-k attention using pre-fetched Gram rows.
931    ///
932    /// Scores all heads in one pass: prepares gram rows for each head's query,
933    /// then iterates tokens once, scoring all heads per token. This amortizes
934    /// the token loop overhead across heads and improves cache utilization.
935    #[cfg(feature = "fib")]
936    pub fn attention_topk_batch_heads(
937        &self,
938        index: &FullyPreparedCompressedIndex,
939        queries: &[&[f32]],
940        top_k: usize,
941    ) -> Result<Vec<CompressedAttentionSelection>> {
942        let num_heads = index.num_heads;
943        let num_tokens = index.num_tokens;
944        let head_dim = index.head_dim;
945        if queries.len() != num_heads {
946            return Err(PolyKvError::DimensionMismatch {
947                expected: num_heads,
948                got: queries.len(),
949            });
950        }
951
952        // Prepare gram rows for each head's query
953        let mut all_prefetched: Vec<PrefetchedGramRows> = Vec::with_capacity(num_heads);
954        for q in queries.iter() {
955            if q.len() != head_dim {
956                return Err(PolyKvError::DimensionMismatch {
957                    expected: head_dim,
958                    got: q.len(),
959                });
960            }
961            // Build a per-head index view by temporarily using prepare_gram_rows
962            // with the head_idx set. But prepare_gram_rows uses self.head_idx.
963            // We need a different approach: prepare gram rows directly.
964            let prepared = index
965                .scorer
966                .prepare_query(q)
967                .map_err(|e| PolyKvError::Internal(format!("fib batch query prep failed: {e}")))?;
968            let n = index.scorer.quantizer().profile().codebook_size as usize;
969            let block_count = index.scorer.quantizer().profile().block_count() as usize;
970            let gram = index.scorer.gram_table();
971            let mut gram_rows = vec![0.0f32; block_count * n];
972            for (block_idx, &query_idx) in prepared.query_indices.iter().enumerate() {
973                let qi = query_idx as usize;
974                if qi >= n {
975                    return Err(PolyKvError::Internal(format!(
976                        "fib batch: query_idx {qi} >= {n}"
977                    )));
978                }
979                let src = &gram.values()[qi * n..(qi + 1) * n];
980                gram_rows[block_idx * n..(block_idx + 1) * n].copy_from_slice(src);
981            }
982            all_prefetched.push(PrefetchedGramRows {
983                gram_rows,
984                block_count,
985                n,
986                query_norm: prepared.query_norm,
987            });
988        }
989
990        // Score all heads for all tokens in one pass
991        let n = all_prefetched[0].n;
992        let block_count = all_prefetched[0].block_count;
993        let mut all_scored: Vec<Vec<(usize, f32)>> =
994            vec![vec![(0usize, 0.0f32); num_tokens]; num_heads];
995
996        for token_idx in 0..num_tokens {
997            for head_idx in 0..num_heads {
998                let code_idx = token_idx * num_heads + head_idx;
999                let indices = index.key_block(code_idx);
1000                let stored_norm = index.key_norms[code_idx];
1001                let q_norm = all_prefetched[head_idx].query_norm as f32;
1002                let gram_rows = &all_prefetched[head_idx].gram_rows;
1003
1004                let mut total = 0.0f32;
1005                for (block_idx, &stored_idx) in indices.iter().enumerate().take(block_count) {
1006                    let si = stored_idx as usize;
1007                    if si >= n {
1008                        return Err(PolyKvError::Internal(format!(
1009                            "fib batch: stored_idx {si} >= {n}"
1010                        )));
1011                    }
1012                    total += gram_rows[block_idx * n + si];
1013                }
1014                let score = total * q_norm * stored_norm;
1015                all_scored[head_idx][token_idx] = (token_idx, score);
1016            }
1017        }
1018
1019        // Top-k selection per head
1020        let mut results = Vec::with_capacity(num_heads);
1021        for (head_idx, scored) in all_scored.iter_mut().enumerate() {
1022            let selected = top_k.min(scored.len());
1023            if selected > 0 && selected < scored.len() {
1024                scored.select_nth_unstable_by(selected - 1, |a, b| {
1025                    b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0))
1026                });
1027                scored.truncate(selected);
1028            }
1029            scored.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1030
1031            let mut hits = Vec::with_capacity(selected);
1032            for &(token_index, score) in scored.iter().take(selected) {
1033                let code_idx = token_index * num_heads + head_idx;
1034                let value = index
1035                    .scorer
1036                    .quantizer()
1037                    .decode(&index.value_codes[code_idx])
1038                    .map_err(|e| {
1039                        PolyKvError::DecompressionFailed(format!(
1040                            "fib batch value decode failed: {e}"
1041                        ))
1042                    })?;
1043                if value.len() != head_dim {
1044                    return Err(PolyKvError::DimensionMismatch {
1045                        expected: head_dim,
1046                        got: value.len(),
1047                    });
1048                }
1049                hits.push(CompressedAttentionHit {
1050                    token_index,
1051                    score,
1052                    value,
1053                });
1054            }
1055            let receipt = CompressedAttentionSelectionReceipt::new(
1056                self.manifest.pool_id.clone(),
1057                index.layer_idx as u32,
1058                head_idx as u32,
1059                num_tokens as u32,
1060                hits.len() as u32,
1061                num_tokens as u64,
1062                hits.len() as u64,
1063                false,
1064                "fib_cold_pool_batch_heads_prefetched_gram_topk_value_decode",
1065                self.manifest.shared_codec.clone(),
1066                now_unix(),
1067            );
1068            receipt.validate()?;
1069            results.push(CompressedAttentionSelection { hits, receipt });
1070        }
1071        Ok(results)
1072    }
1073
1074    /// Search for tokens most similar to a query vector.
1075    ///
1076    /// Decompresses the specified layer's key blocks and returns the
1077    /// top-K token indices with exact cosine similarity scores.
1078    /// For small pools (<10K tokens) this is fast enough with linear scan.
1079    /// For larger pools, prefer a dedicated ANN index.
1080    pub fn search_similar_tokens(
1081        &self,
1082        layer_idx: usize,
1083        query: &[f32],
1084        top_k: usize,
1085    ) -> Result<Vec<(usize, f32)>> {
1086        let decompressed = self.decompress_layer(layer_idx)?;
1087        let keys = decompressed
1088            .keys
1089            .first()
1090            .ok_or_else(|| PolyKvError::Internal("pool has no key heads".into()))?;
1091
1092        let head_dim = decompressed.head_dim;
1093        let num_tokens = keys.len() / head_dim;
1094
1095        let mut scored: Vec<(usize, f32)> = Vec::with_capacity(num_tokens);
1096        for i in 0..num_tokens {
1097            let start = i * head_dim;
1098            let vec = &keys[start..start + head_dim];
1099            let dot: f32 = query.iter().zip(vec.iter()).map(|(a, b)| a * b).sum();
1100            scored.push((i, dot));
1101        }
1102
1103        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1104        scored.truncate(top_k.min(num_tokens));
1105        Ok(scored)
1106    }
1107
1108    // ---------- persistence ----------
1109
1110    /// Save the pool to a JSON file.
1111    ///
1112    /// Writes the manifest and all layers (including compressed payloads)
1113    /// to a single JSON file. Compressed payloads are embedded as base64.
1114    /// For large pools (>100K tokens), consider mmap-based persistence instead.
1115    pub fn save_to_path(&self, path: &std::path::Path) -> Result<()> {
1116        let json = serde_json::to_string_pretty(&PoolFileEnvelope {
1117            schema: "polykv_pool_file_v1".into(),
1118            manifest: self.manifest.clone(),
1119            layers: self.layers.clone(),
1120            policy: self.policy.clone(),
1121        })
1122        .map_err(|e| PolyKvError::Internal(format!("pool serialize: {e}")))?;
1123        std::fs::write(path, &json)?;
1124        Ok(())
1125    }
1126
1127    /// Load a pool from a JSON file previously written by [`save_to_path`].
1128    pub fn load_from_path(path: &std::path::Path) -> Result<Self> {
1129        let json = std::fs::read_to_string(path)?;
1130        let envelope: PoolFileEnvelope = serde_json::from_str(&json)
1131            .map_err(|e| PolyKvError::Internal(format!("pool deserialize: {e}")))?;
1132        if envelope.schema != "polykv_pool_file_v1" {
1133            return Err(PolyKvError::Internal(format!(
1134                "unknown pool file schema: {}",
1135                envelope.schema
1136            )));
1137        }
1138        Ok(Self {
1139            manifest: envelope.manifest,
1140            layers: envelope.layers,
1141            policy: envelope.policy,
1142        })
1143    }
1144}
1145
1146/// Serialization envelope for pool files.
1147#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1148struct PoolFileEnvelope {
1149    schema: String,
1150    manifest: PoolManifest,
1151    layers: Vec<PoolLayer>,
1152    policy: CompressionPolicy,
1153}
1154
1155/// Pre-built compressed index for one layer/head, avoiding per-call reconstruction.
1156///
1157/// Caches decoded key codes, value codes, and a built FibScorer so that
1158/// `attention_topk_compressed_prepared` only needs to prepare the query
1159/// (O(dim)) and score (O(num_tokens)) without rebuilding codec state.
1160#[cfg(feature = "fib")]
1161pub struct PreparedCompressedIndex {
1162    /// Layer index this index was built for.
1163    pub layer_idx: usize,
1164    /// Head index this index was built for.
1165    pub head_idx: usize,
1166    /// Head dimension.
1167    pub head_dim: usize,
1168    /// Number of tokens in the pool.
1169    pub num_tokens: usize,
1170    /// Number of KV heads.
1171    pub num_heads: usize,
1172    /// Decoded key codes for the entire layer (all heads, all tokens).
1173    pub key_codes: Vec<fib_quant::FibCodeV1>,
1174    /// Decoded value codes for the entire layer (all heads, all tokens).
1175    pub value_codes: Vec<fib_quant::FibCodeV1>,
1176    /// Built FibScorer (includes quantizer).
1177    pub scorer: fib_quant::FibScorer,
1178}
1179
1180/// Fully prepared compressed index: pre-unpacks all indices and norms
1181/// so the scoring loop is just Gram table lookups with no per-call unpacking.
1182///
1183/// This is the tightest possible hot path for compressed attention scoring.
1184#[cfg(feature = "fib")]
1185pub struct FullyPreparedCompressedIndex {
1186    /// Layer index this index was built for.
1187    pub layer_idx: usize,
1188    /// Head index this index was built for.
1189    pub head_idx: usize,
1190    /// Head dimension.
1191    pub head_dim: usize,
1192    /// Number of tokens in the pool.
1193    pub num_tokens: usize,
1194    /// Number of KV heads.
1195    pub num_heads: usize,
1196    /// Pre-unpacked key indices: flat `num_entries * block_count` u32 buffer,
1197    /// row-major by `code_idx = token_idx * num_heads + head_idx`.
1198    /// Use `key_block(code_idx)` to get the slice for one entry.
1199    pub key_indices_flat: Vec<u32>,
1200    /// Pre-decoded key norms as f32: one per (token, head) entry.
1201    pub key_norms: Vec<f32>,
1202    /// Number of blocks per vector (= head_dim / k where k is the block dim).
1203    pub block_count: usize,
1204    /// Pre-unpacked value codes (for decode of selected top-k).
1205    pub value_codes: Vec<fib_quant::FibCodeV1>,
1206    /// Built FibScorer (for query preparation and Gram table access).
1207    pub scorer: fib_quant::FibScorer,
1208}
1209
1210/// Pre-fetched Gram rows for a specific query, enabling cache-friendly scoring.
1211///
1212/// After preparing a query, the relevant Gram table rows (one per block)
1213/// are copied into a contiguous buffer. The scoring loop then gathers
1214/// from this buffer using `stored_idx` as offset, eliminating the
1215/// `query_idx * N` multiply and improving cache locality.
1216#[cfg(feature = "fib")]
1217pub struct PrefetchedGramRows {
1218    /// Contiguous `block_count * N` f32 buffer.
1219    /// Row `b` is at `gram_rows[b * N .. (b+1) * N]`.
1220    pub gram_rows: Vec<f32>,
1221    /// Number of blocks per vector.
1222    pub block_count: usize,
1223    /// Codebook size N.
1224    pub n: usize,
1225    /// Prepared query norm.
1226    pub query_norm: f64,
1227}
1228
1229#[cfg(feature = "fib")]
1230impl FullyPreparedCompressedIndex {
1231    /// Get the key indices for one (token, head) entry as a slice.
1232    #[inline]
1233    pub fn key_block(&self, code_idx: usize) -> &[u32] {
1234        let start = code_idx * self.block_count;
1235        &self.key_indices_flat[start..start + self.block_count]
1236    }
1237
1238    /// Prepare a query and pre-fetch the relevant Gram rows.
1239    ///
1240    /// This copies `block_count` rows from the Gram table into a contiguous
1241    /// buffer, so the per-token scoring loop becomes sequential gathers
1242    /// from a 2KB working set instead of scattered accesses across 4KB+.
1243    pub fn prepare_gram_rows(&self, query: &[f32]) -> Result<PrefetchedGramRows> {
1244        if query.len() != self.head_dim {
1245            return Err(PolyKvError::DimensionMismatch {
1246                expected: self.head_dim,
1247                got: query.len(),
1248            });
1249        }
1250        let prepared = self
1251            .scorer
1252            .prepare_query(query)
1253            .map_err(|e| PolyKvError::Internal(format!("fib query preparation failed: {e}")))?;
1254        let n = self.scorer.quantizer().profile().codebook_size as usize;
1255        let block_count = self.scorer.quantizer().profile().block_count() as usize;
1256        let gram = self.scorer.gram_table();
1257
1258        // Pre-fetch: copy gram row `query_indices[block]` into gram_rows[block * N..]
1259        let mut gram_rows = vec![0.0f32; block_count * n];
1260        for (block_idx, &query_idx) in prepared.query_indices.iter().enumerate() {
1261            let qi = query_idx as usize;
1262            if qi >= n {
1263                return Err(PolyKvError::Internal(format!(
1264                    "fib prepare_gram_rows: query_idx {qi} >= {n}"
1265                )));
1266            }
1267            let src = &gram.values()[qi * n..(qi + 1) * n];
1268            gram_rows[block_idx * n..(block_idx + 1) * n].copy_from_slice(src);
1269        }
1270
1271        Ok(PrefetchedGramRows {
1272            gram_rows,
1273            block_count,
1274            n,
1275            query_norm: prepared.query_norm,
1276        })
1277    }
1278
1279    /// Score all tokens against pre-fetched Gram rows.
1280    ///
1281    /// The hot loop is:
1282    /// ```text
1283    /// for block in 0..block_count:
1284    ///     total += gram_rows[block * N + stored_idx]
1285    /// ```
1286    /// This is sequential access into a small contiguous buffer —
1287    /// auto-vectorizable and cache-friendly.
1288    pub fn score_all_tokens(&self, prefetched: &PrefetchedGramRows) -> Result<Vec<(usize, f32)>> {
1289        let head_idx = self.head_idx;
1290        let num_heads = self.num_heads;
1291        let num_tokens = self.num_tokens;
1292        let q_norm = prefetched.query_norm as f32;
1293        let n = prefetched.n;
1294        let block_count = prefetched.block_count;
1295        let gram_rows = &prefetched.gram_rows;
1296
1297        let mut scored: Vec<(usize, f32)> = vec![(0usize, 0.0f32); num_tokens];
1298        for token_idx in 0..num_tokens {
1299            let code_idx = token_idx * num_heads + head_idx;
1300            let indices = self.key_block(code_idx);
1301            let stored_norm = self.key_norms[code_idx];
1302
1303            let mut total = 0.0f32;
1304            for (block_idx, &stored_idx) in indices.iter().enumerate().take(block_count) {
1305                let si = stored_idx as usize;
1306                if si >= n {
1307                    return Err(PolyKvError::Internal(format!(
1308                        "fib score_all_tokens: stored_idx {si} >= {n}"
1309                    )));
1310                }
1311                total += gram_rows[block_idx * n + si];
1312            }
1313            let score = total * q_norm * stored_norm;
1314            scored[token_idx] = (token_idx, score);
1315        }
1316        Ok(scored)
1317    }
1318}
1319
1320/// Reconstructed K/V tensors for one layer of the shared pool.
1321///
1322/// All vectors are in the original (head × token × head_dim) layout but
1323/// flat per head: `keys[head_idx][token_idx * head_dim + j]`. This matches
1324/// the HuggingFace `DynamicCache` per-layer access pattern
1325/// (`cache.layers[layer_idx].keys[:, head_idx, :, :]` flattened along the
1326/// last two dims).
1327#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1328pub struct DecompressedLayer {
1329    /// Original layer index in the model.
1330    pub layer_index: u32,
1331    /// Number of tokens in this layer (= pool's `num_shared_tokens`).
1332    pub num_tokens: usize,
1333    /// Number of KV heads (= pool's `num_kv_heads`).
1334    pub num_heads: usize,
1335    /// Per-head dimension.
1336    pub head_dim: usize,
1337    /// Decoded K vectors: `keys[head_idx]` is a flat `Vec<f32>` of length
1338    /// `num_tokens * head_dim` in token order.
1339    pub keys: Vec<Vec<f32>>,
1340    /// Decoded V vectors, same layout as `keys`.
1341    pub values: Vec<Vec<f32>>,
1342}
1343
1344/// Trait for KV cache targets that can receive injected blocks.
1345pub trait CacheTarget: std::fmt::Debug {
1346    /// Get the number of layers in this cache.
1347    fn num_layers(&self) -> u32;
1348
1349    /// Append a key block at a specific layer and position.
1350    fn append_key(&mut self, layer: u32, position: u32, key: &[f32]) -> Result<()>;
1351
1352    /// Append a value block at a specific layer and position.
1353    fn append_value(&mut self, layer: u32, position: u32, value: &[f32]) -> Result<()>;
1354
1355    /// Get the current sequence length (tokens in cache).
1356    fn seq_len(&self) -> u32;
1357}
1358
1359#[cfg(test)]
1360mod tests {
1361    use super::*;
1362    use crate::shape::AttentionType;
1363
1364    fn make_test_shape() -> KvTensorShape {
1365        KvTensorShape {
1366            attention_type: AttentionType::MHA,
1367            num_layers: 2,
1368            num_heads: 4,
1369            num_kv_heads: 4,
1370            head_dim: 8, // must be divisible by k=4 for fib-quant
1371            hidden_size: 32,
1372        }
1373    }
1374
1375    fn make_test_corpus(n: usize) -> Vec<(String, Vec<f32>)> {
1376        use rand::Rng;
1377        use rand_chacha::{rand_core::SeedableRng, ChaCha8Rng};
1378        let mut rng = ChaCha8Rng::seed_from_u64(42);
1379        let shape = make_test_shape();
1380        let vec_len = shape.num_layers as usize * shape.num_kv_heads as usize * shape.head_dim * 2;
1381
1382        (0..n)
1383            .map(|i| {
1384                let vec: Vec<f32> = (0..vec_len).map(|_| rng.gen_range(-1.0..1.0)).collect();
1385                (format!("token_{}", i), vec)
1386            })
1387            .collect()
1388    }
1389
1390    #[test]
1391    fn test_pool_build_empty() {
1392        let shape = make_test_shape();
1393        let corpus: Vec<(String, Vec<f32>)> = vec![];
1394        let result = SharedKVPool::build(&corpus, &shape, 42);
1395        assert!(result.is_err());
1396    }
1397
1398    #[test]
1399    fn test_pool_build_basic() {
1400        let shape = make_test_shape();
1401        let corpus = make_test_corpus(4);
1402        let result = SharedKVPool::build(&corpus, &shape, 42);
1403        assert!(result.is_ok(), "build failed: {:?}", result.err());
1404
1405        let (pool, receipt) = result.unwrap();
1406        assert_eq!(pool.layers.len(), 2);
1407        assert_eq!(pool.manifest.num_shared_tokens, 4);
1408        assert_eq!(receipt.total_tokens, 4);
1409        assert!(
1410            receipt.compression_ratio > 0.0,
1411            "compression ratio: {}",
1412            receipt.compression_ratio
1413        );
1414        // Note: ratio < 1.0 is normal for tiny test corpora where JSON
1415        // serialization overhead dominates the encoded payload.
1416    }
1417
1418    #[test]
1419    fn test_pool_build_deterministic() {
1420        let shape = make_test_shape();
1421        let corpus = make_test_corpus(4);
1422
1423        let (pool1, receipt1) = SharedKVPool::build(&corpus, &shape, 42).unwrap();
1424        let (pool2, receipt2) = SharedKVPool::build(&corpus, &shape, 42).unwrap();
1425
1426        assert_eq!(receipt1.pool_digest, receipt2.pool_digest);
1427        assert_eq!(receipt1.layer_digests, receipt2.layer_digests);
1428        assert_eq!(pool1.layers[0].block_digest, pool2.layers[0].block_digest);
1429    }
1430
1431    #[test]
1432    fn test_pool_build_different_seeds() {
1433        let shape = make_test_shape();
1434        let corpus = make_test_corpus(4);
1435
1436        let (_pool1, receipt1) = SharedKVPool::build(&corpus, &shape, 42).unwrap();
1437        let (_pool2, receipt2) = SharedKVPool::build(&corpus, &shape, 12345).unwrap();
1438
1439        assert_ne!(receipt1.pool_digest, receipt2.pool_digest);
1440    }
1441
1442    #[test]
1443    fn test_decompress_layer_recovers_finite_floats() {
1444        // Round-trip integrity: build a pool, decompress every layer,
1445        // verify the output is finite, the right shape, and per-head
1446        // lengths match num_tokens * head_dim.
1447        let shape = make_test_shape();
1448        let corpus = make_test_corpus(8);
1449        let (pool, _) = SharedKVPool::build(&corpus, &shape, 42).unwrap();
1450
1451        for layer_idx in 0..shape.num_layers as usize {
1452            let decompressed = pool.decompress_layer(layer_idx).unwrap();
1453            assert_eq!(decompressed.num_tokens, 8);
1454            assert_eq!(decompressed.num_heads, shape.num_kv_heads as usize);
1455            assert_eq!(decompressed.head_dim, shape.head_dim);
1456            assert_eq!(decompressed.keys.len(), shape.num_kv_heads as usize);
1457            assert_eq!(decompressed.values.len(), shape.num_kv_heads as usize);
1458            for h in 0..decompressed.num_heads {
1459                assert_eq!(decompressed.keys[h].len(), 8 * shape.head_dim);
1460                assert_eq!(decompressed.values[h].len(), 8 * shape.head_dim);
1461                assert!(decompressed.keys[h].iter().all(|v| v.is_finite()));
1462                assert!(decompressed.values[h].iter().all(|v| v.is_finite()));
1463            }
1464        }
1465    }
1466
1467    #[test]
1468    fn test_decompress_layer_is_deterministic() {
1469        // Same corpus + same seed must produce byte-identical decompressed
1470        // output. This is the core invariant for HuggingFaceDynamicCache
1471        // round-trip: a fresh DynamicCache populated from the pool must
1472        // see the same K/V tensors across runs.
1473        let shape = make_test_shape();
1474        let corpus = make_test_corpus(6);
1475        let (pool_a, _) = SharedKVPool::build(&corpus, &shape, 42).unwrap();
1476        let (pool_b, _) = SharedKVPool::build(&corpus, &shape, 42).unwrap();
1477        for layer_idx in 0..shape.num_layers as usize {
1478            let a = pool_a.decompress_layer(layer_idx).unwrap();
1479            let b = pool_b.decompress_layer(layer_idx).unwrap();
1480            assert_eq!(
1481                a.keys, b.keys,
1482                "decompressed K tensors must be deterministic across builds (layer {})",
1483                layer_idx
1484            );
1485            assert_eq!(a.values, b.values);
1486        }
1487    }
1488
1489    #[test]
1490    fn test_mismatched_shape_rejected() {
1491        let shape = make_test_shape();
1492        let mut bad_corpus = make_test_corpus(1);
1493        // Truncate the vector
1494        bad_corpus[0].1.truncate(10);
1495        let result = SharedKVPool::build(&bad_corpus, &shape, 42);
1496        assert!(result.is_err());
1497    }
1498
1499    #[test]
1500    fn test_pool_build_writes_single_fb2_payload_per_layer_side() {
1501        let shape = make_test_shape();
1502        let corpus = make_test_corpus(32);
1503        let (pool, receipt) = SharedKVPool::build(&corpus, &shape, 42).unwrap();
1504
1505        assert_eq!(pool.layers.len(), shape.num_layers as usize);
1506        for layer in &pool.layers {
1507            assert_eq!(
1508                layer.key_blocks.len(),
1509                1,
1510                "pool layer keys must be stored as one batched payload, not per-vector blocks"
1511            );
1512            assert_eq!(
1513                layer.value_blocks.len(),
1514                1,
1515                "pool layer values must be stored as one batched payload, not per-vector blocks"
1516            );
1517            assert_eq!(&layer.key_blocks[0].encoded_payload[0..4], b"FBWB");
1518            assert_eq!(&layer.value_blocks[0].encoded_payload[0..4], b"FBWB");
1519        }
1520        let raw_bytes = shape.total_kv_bytes(corpus.len()) as f64;
1521        let ratio = raw_bytes / receipt.pool_size_bytes as f64;
1522        // With self-describing FibCodeWireV1 headers (59 bytes per code),
1523        // tiny test corpora may not achieve high compression. The wire
1524        // format trades per-code space for self-description (seed/dim/k/N
1525        // in the header — no external profile needed for decode).
1526        assert!(
1527            ratio > 0.2,
1528            "batched pool should show some compression; ratio={ratio:.2}"
1529        );
1530    }
1531
1532    /// The pool build must produce the same `pool_digest` and `block_digest`
1533    /// values regardless of whether the underlying codec dispatches to GPU
1534    /// or CPU. This guards against the "receipt says gpu, code did cpu"
1535    /// failure mode that earlier feature-flag-only wiring exhibited.
1536    #[test]
1537    fn test_pool_build_digest_invariant_across_corpora_size() {
1538        let shape = make_test_shape();
1539
1540        // Tiny corpus — under the GPU batch threshold (n < 16).
1541        let small = make_test_corpus(4);
1542        let (pool_small, receipt_small) = SharedKVPool::build(&small, &shape, 42).unwrap();
1543
1544        // Large corpus — well over the GPU batch threshold.
1545        let large = make_test_corpus(40);
1546        let (pool_large, receipt_large) = SharedKVPool::build(&large, &shape, 42).unwrap();
1547
1548        assert!(!pool_small.layers.is_empty());
1549        assert!(!pool_large.layers.is_empty());
1550        assert!(receipt_small.backend == "cpu" || receipt_small.backend == "gpu");
1551        assert!(receipt_large.backend == "cpu" || receipt_large.backend == "gpu");
1552
1553        // Tiny corpus must NOT claim gpu — the per-(layer,head) batch is
1554        // 4 docs * 4 kv heads = 16 vectors, exactly at the threshold, and
1555        // the per-call probe (not the device probe) drives the receipt.
1556        // This is the honesty invariant.
1557        assert_eq!(
1558            receipt_small.backend, "cpu",
1559            "corpus under GPU batch threshold should fall through to CPU"
1560        );
1561    }
1562
1563    #[test]
1564    fn test_search_similar_tokens_returns_top_k() {
1565        let shape = make_test_shape();
1566        let corpus = make_test_corpus(32);
1567        let (pool, _receipt) = SharedKVPool::build(&corpus, &shape, 42).unwrap();
1568
1569        // Query with arbitrary vector of correct dimension
1570        let query: Vec<f32> = (0..shape.head_dim).map(|x| x as f32 * 0.1).collect();
1571        let results = pool.search_similar_tokens(0, &query, 5).unwrap();
1572
1573        assert!(!results.is_empty(), "search should return results");
1574        assert!(results.len() <= 5, "should return at most top_k");
1575        // Every returned token index should be in range
1576        for (idx, _) in &results {
1577            assert!(*idx < 32, "token index must be in range of corpus size");
1578        }
1579        // Scores should be sorted descending
1580        for w in results.windows(2) {
1581            assert!(w[0].1 >= w[1].1, "scores should be descending");
1582        }
1583    }
1584
1585    #[test]
1586    fn test_prepared_compressed_index_matches_regular_attention() {
1587        let shape = make_test_shape();
1588        let corpus = make_test_corpus(16);
1589        let (pool, _receipt) = SharedKVPool::build(&corpus, &shape, 42).unwrap();
1590        let query: Vec<f32> = (0..shape.head_dim).map(|x| x as f32 * 0.125).collect();
1591
1592        let regular = pool
1593            .attention_topk_compressed(0, 0, &query, 5)
1594            .expect("regular compressed attention should work");
1595
1596        let index = pool
1597            .prepare_compressed_index(0, 0)
1598            .expect("prepare compressed index should work");
1599
1600        let prepared = pool
1601            .attention_topk_compressed_prepared(&index, &query, 5)
1602            .expect("prepared compressed attention should work");
1603
1604        assert_eq!(prepared.hits.len(), regular.hits.len());
1605        for (a, b) in prepared.hits.iter().zip(regular.hits.iter()) {
1606            assert_eq!(a.token_index, b.token_index);
1607            assert!((a.score - b.score).abs() < 1e-5);
1608            assert_eq!(a.value.len(), b.value.len());
1609        }
1610        assert_eq!(
1611            prepared.receipt.scoring_path,
1612            "fib_cold_pool_prepared_compressed_score_topk_value_decode"
1613        );
1614    }
1615
1616    #[test]
1617    fn test_prepared_compressed_index_rejects_wrong_query_dimension() {
1618        let shape = make_test_shape();
1619        let corpus = make_test_corpus(8);
1620        let (pool, _receipt) = SharedKVPool::build(&corpus, &shape, 42).unwrap();
1621        let index = pool
1622            .prepare_compressed_index(0, 0)
1623            .expect("prepare compressed index should work");
1624        let err = pool
1625            .attention_topk_compressed_prepared(&index, &[1.0, 2.0], 3)
1626            .expect_err("wrong query dimension must fail");
1627        assert!(matches!(
1628            err,
1629            PolyKvError::DimensionMismatch {
1630                expected: 8,
1631                got: 2
1632            }
1633        ));
1634    }
1635
1636    #[test]
1637    fn test_fully_prepared_compressed_index_matches_regular_attention() {
1638        let shape = make_test_shape();
1639        let corpus = make_test_corpus(16);
1640        let (pool, _receipt) = SharedKVPool::build(&corpus, &shape, 42).unwrap();
1641        let query: Vec<f32> = (0..shape.head_dim).map(|x| x as f32 * 0.125).collect();
1642
1643        let regular = pool
1644            .attention_topk_compressed(0, 0, &query, 5)
1645            .expect("regular compressed attention should work");
1646
1647        let fully_index = pool
1648            .prepare_fully_compressed_index(0, 0)
1649            .expect("prepare fully compressed index should work");
1650
1651        let fully_prepared = pool
1652            .attention_topk_fully_prepared(&fully_index, &query, 5)
1653            .expect("fully prepared compressed attention should work");
1654
1655        // Token indices should match (same ranking), though scores may differ
1656        // slightly because fully-prepared computes norm from decoded vector
1657        // while regular uses the wire-format stored norm.
1658        assert_eq!(fully_prepared.hits.len(), regular.hits.len());
1659        for (a, b) in fully_prepared.hits.iter().zip(regular.hits.iter()) {
1660            assert_eq!(a.token_index, b.token_index);
1661            assert_eq!(a.value.len(), b.value.len());
1662        }
1663        assert_eq!(
1664            fully_prepared.receipt.scoring_path,
1665            "fib_cold_pool_prefetched_gram_rows_topk_value_decode"
1666        );
1667    }
1668
1669    #[test]
1670    fn test_fully_prepared_compressed_index_rejects_wrong_query_dimension() {
1671        let shape = make_test_shape();
1672        let corpus = make_test_corpus(8);
1673        let (pool, _receipt) = SharedKVPool::build(&corpus, &shape, 42).unwrap();
1674        let fully_index = pool
1675            .prepare_fully_compressed_index(0, 0)
1676            .expect("prepare fully compressed index should work");
1677        let err = pool
1678            .attention_topk_fully_prepared(&fully_index, &[1.0, 2.0], 3)
1679            .expect_err("wrong query dimension must fail");
1680        assert!(matches!(
1681            err,
1682            PolyKvError::DimensionMismatch {
1683                expected: 8,
1684                got: 2
1685            }
1686        ));
1687    }
1688
1689    #[test]
1690    fn test_prefetched_gram_rows_matches_regular_attention() {
1691        let shape = make_test_shape();
1692        let corpus = make_test_corpus(16);
1693        let (pool, _receipt) = SharedKVPool::build(&corpus, &shape, 42).unwrap();
1694        let query: Vec<f32> = (0..shape.head_dim).map(|x| x as f32 * 0.125).collect();
1695
1696        let regular = pool
1697            .attention_topk_compressed(0, 0, &query, 5)
1698            .expect("regular compressed attention should work");
1699
1700        let fully_index = pool
1701            .prepare_fully_compressed_index(0, 0)
1702            .expect("prepare fully compressed index should work");
1703
1704        let prefetched = pool
1705            .attention_topk_prefetched(&fully_index, &query, 5)
1706            .expect("prefetched compressed attention should work");
1707
1708        assert_eq!(prefetched.hits.len(), regular.hits.len());
1709        for (a, b) in prefetched.hits.iter().zip(regular.hits.iter()) {
1710            assert_eq!(a.token_index, b.token_index);
1711        }
1712        assert_eq!(
1713            prefetched.receipt.scoring_path,
1714            "fib_cold_pool_prefetched_gram_rows_topk_value_decode"
1715        );
1716    }
1717
1718    #[test]
1719    fn test_batch_heads_returns_correct_count() {
1720        let shape = make_test_shape();
1721        let corpus = make_test_corpus(16);
1722        let (pool, _receipt) = SharedKVPool::build(&corpus, &shape, 42).unwrap();
1723
1724        let fully_index = pool
1725            .prepare_fully_compressed_index(0, 0)
1726            .expect("prepare fully compressed index should work");
1727
1728        let queries: Vec<Vec<f32>> = (0..shape.num_kv_heads as usize)
1729            .map(|h| {
1730                (0..shape.head_dim)
1731                    .map(|x| x as f32 * 0.125 + h as f32 * 0.01)
1732                    .collect()
1733            })
1734            .collect();
1735        let query_refs: Vec<&[f32]> = queries.iter().map(|q| q.as_slice()).collect();
1736
1737        let results = pool
1738            .attention_topk_batch_heads(&fully_index, &query_refs, 5)
1739            .expect("batch heads should work");
1740
1741        assert_eq!(results.len(), shape.num_kv_heads as usize);
1742        for r in &results {
1743            assert_eq!(r.hits.len(), 5);
1744            assert_eq!(
1745                r.receipt.scoring_path,
1746                "fib_cold_pool_batch_heads_prefetched_gram_topk_value_decode"
1747            );
1748        }
1749    }
1750
1751    #[test]
1752    fn test_compressed_attention_topk_scores_cold_pool_without_full_layer_decode() {
1753        let shape = make_test_shape();
1754        let corpus = make_test_corpus(32);
1755        let (pool, _receipt) = SharedKVPool::build(&corpus, &shape, 42).unwrap();
1756        let query: Vec<f32> = (0..shape.head_dim).map(|x| x as f32 * 0.1).collect();
1757
1758        let out = pool
1759            .attention_topk_compressed(0, 0, &query, 3)
1760            .expect("compressed attention selection should work over fib cold pool");
1761
1762        assert_eq!(out.hits.len(), 3);
1763        assert_eq!(
1764            out.receipt.schema_version,
1765            "compressed_attention_selection_receipt_v1"
1766        );
1767        assert_eq!(out.receipt.layer, 0);
1768        assert_eq!(out.receipt.head, 0);
1769        assert_eq!(out.receipt.candidate_count, 32);
1770        assert_eq!(out.receipt.selected_count, 3);
1771        assert_eq!(out.receipt.compressed_key_scores, 32);
1772        assert_eq!(out.receipt.decoded_value_vectors, 3);
1773        assert!(!out.receipt.full_layer_decoded);
1774        assert_eq!(
1775            out.receipt.scoring_path,
1776            "fib_cold_pool_compressed_score_topk_value_decode"
1777        );
1778        for hit in &out.hits {
1779            assert!(hit.token_index < 32);
1780            assert_eq!(hit.value.len(), shape.head_dim);
1781            assert!(hit.value.iter().all(|v| v.is_finite()));
1782        }
1783        for window in out.hits.windows(2) {
1784            assert!(window[0].score >= window[1].score);
1785        }
1786    }
1787
1788    #[test]
1789    fn test_compressed_attention_topk_rejects_wrong_query_dimension() {
1790        let shape = make_test_shape();
1791        let corpus = make_test_corpus(8);
1792        let (pool, _receipt) = SharedKVPool::build(&corpus, &shape, 42).unwrap();
1793
1794        let err = pool
1795            .attention_topk_compressed(0, 0, &[1.0, 2.0], 3)
1796            .expect_err("wrong query dimension must fail before scoring");
1797
1798        assert!(matches!(
1799            err,
1800            PolyKvError::DimensionMismatch {
1801                expected: 8,
1802                got: 2
1803            }
1804        ));
1805    }
1806
1807    #[test]
1808    fn test_persistence_roundtrip() {
1809        let shape = make_test_shape();
1810        let corpus = make_test_corpus(16);
1811        let (pool, _receipt) = SharedKVPool::build(&corpus, &shape, 42).unwrap();
1812
1813        // Serialize to JSON string and back
1814        let json = serde_json::to_string_pretty(&PoolFileEnvelope {
1815            schema: "polykv_pool_file_v1".into(),
1816            manifest: pool.manifest.clone(),
1817            layers: pool.layers.clone(),
1818            policy: pool.policy.clone(),
1819        })
1820        .unwrap();
1821
1822        let envelope: PoolFileEnvelope = serde_json::from_str(&json).unwrap();
1823        let loaded = SharedKVPool {
1824            manifest: envelope.manifest,
1825            layers: envelope.layers,
1826            policy: envelope.policy,
1827        };
1828
1829        assert_eq!(pool.layers.len(), loaded.layers.len());
1830        assert_eq!(
1831            pool.layers[0].key_blocks.len(),
1832            loaded.layers[0].key_blocks.len()
1833        );
1834        assert_eq!(pool.manifest.pool_id, loaded.manifest.pool_id);
1835
1836        // Decompressed search should produce identical results
1837        let query: Vec<f32> = (0..shape.head_dim).map(|x| x as f32 * 0.1).collect();
1838        let orig_results = pool.search_similar_tokens(0, &query, 3).unwrap();
1839        let loaded_results = loaded.search_similar_tokens(0, &query, 3).unwrap();
1840        assert_eq!(orig_results, loaded_results);
1841    }
1842}