Skip to main content

poly_kv/
codec.rs

1use serde::{Deserialize, Serialize};
2
3use crate::error::Result;
4use crate::policy::CodecId;
5
6/// A codec compresses and decompresses KV vectors.
7pub trait KVecCodec: Send + Sync {
8    /// Return the codec identifier ("fib_k4_n32", "turbo_8bit", etc.).
9    fn codec_id(&self) -> CodecId;
10
11    /// Encode a vector of f32 values into a compressed byte payload.
12    fn encode(&self, vector: &[f32], seed: u64) -> Result<Vec<u8>>;
13
14    /// Encode a batch of vectors in one call.
15    ///
16    /// The default implementation calls `encode` in a loop. Codecs that can
17    /// exploit batch-level parallelism (e.g. fib-quant with `gpu-backend`)
18    /// override this to issue a single batched dispatch.
19    ///
20    /// The returned byte payloads are in the same order as `vectors`.
21    fn encode_batch(&self, vectors: &[&[f32]], seed: u64) -> Result<Vec<Vec<u8>>> {
22        vectors.iter().map(|v| self.encode(v, seed)).collect()
23    }
24
25    /// Decode a compressed byte payload back into a vector of f32 values.
26    fn decode(&self, payload: &[u8], seed: u64) -> Result<Vec<f32>>;
27
28    /// Decode a batch of compressed payloads. Default loops over `decode`.
29    fn decode_batch(&self, payloads: &[&[u8]], seed: u64) -> Result<Vec<Vec<f32>>> {
30        payloads.iter().map(|p| self.decode(p, seed)).collect()
31    }
32
33    /// Encode a batch into one amortized binary payload when supported.
34    ///
35    /// Default `None` preserves legacy per-vector block storage. The fib adapter
36    /// overrides this with FB2 so pool storage pays the codec profile/header once
37    /// per layer side instead of once per vector.
38    fn encode_batch_compact(&self, vectors: &[&[f32]], seed: u64) -> Result<Option<Vec<u8>>> {
39        let _ = (vectors, seed);
40        Ok(None)
41    }
42
43    /// Decode one compact batch payload back into vectors when supported.
44    fn decode_batch_compact(&self, payload: &[u8], seed: u64) -> Result<Option<Vec<Vec<f32>>>> {
45        let _ = (payload, seed);
46        Ok(None)
47    }
48
49    /// The expected dimension of input/output vectors.
50    fn dim(&self) -> usize;
51
52    /// Expected compression ratio (nominal).
53    fn compression_ratio(&self) -> f64;
54
55    /// True if this adapter has access to GPU acceleration at runtime.
56    ///
57    /// This is distinct from the `gpu` feature being compiled in: a corpus
58    /// too small for the GPU's batch threshold will fall through to CPU even
59    /// when the feature is on. The pool build receipt uses this to set the
60    /// `backend` field honestly.
61    ///
62    /// The default probes device availability only. Codecs that gate on
63    /// batch size / dim should override [`Self::is_gpu_accelerated_for`].
64    fn is_gpu_accelerated(&self) -> bool {
65        false
66    }
67
68    /// True if a batch of `n` vectors at dimension `d` would actually
69    /// dispatch to GPU. Default falls back to the device-availability probe;
70    /// codecs with a runtime threshold should override.
71    fn is_gpu_accelerated_for(&self, n: usize, d: usize) -> bool {
72        let _ = (n, d);
73        self.is_gpu_accelerated()
74    }
75
76    /// Codebook digest (BLAKE3 hex) for provenance receipts.
77    /// Returns None if the codec doesn't support this.
78    fn codebook_digest(&self, _seed: u64) -> Option<String> {
79        None
80    }
81
82    /// Rotation digest (BLAKE3 hex) for provenance receipts.
83    /// Returns None if the codec doesn't support this.
84    fn rotation_digest(&self, _seed: u64) -> Option<String> {
85        None
86    }
87}
88
89/// A serialized compressed block with codec metadata.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct CompressedBlock {
92    /// Codec identifier.
93    pub codec: CodecId,
94    /// The compressed payload bytes.
95    pub encoded_payload: Vec<u8>,
96    /// Blake3 digest of the encoded payload.
97    pub payload_digest: crate::digest_compat::Digest,
98    /// Original (uncompressed) vector dimension.
99    pub original_dim: usize,
100    /// Size of the compressed payload in bytes.
101    pub compressed_bytes: usize,
102}
103
104impl CompressedBlock {
105    /// Create a new CompressedBlock from encoded payload.
106    pub fn new(codec: CodecId, encoded_payload: Vec<u8>, original_dim: usize) -> Self {
107        let compressed_bytes = encoded_payload.len();
108        let payload_digest = crate::digest_compat::compute(&encoded_payload);
109        Self {
110            codec,
111            encoded_payload,
112            payload_digest,
113            original_dim,
114            compressed_bytes,
115        }
116    }
117
118    /// Compression ratio: original f32 bytes / compressed bytes.
119    pub fn compression_ratio(&self) -> f64 {
120        let raw_bytes = self.original_dim * 4; // 4 bytes per f32
121        if self.compressed_bytes == 0 {
122            return f64::INFINITY;
123        }
124        raw_bytes as f64 / self.compressed_bytes as f64
125    }
126}
127
128// ── Exact fallback codec (no compression) ──
129
130/// Exact fallback codec: stores raw f32 bytes with no compression.
131pub struct ExactFallbackCodec {
132    dim: usize,
133}
134
135impl ExactFallbackCodec {
136    pub fn new(dim: usize) -> Self {
137        Self { dim }
138    }
139}
140
141impl KVecCodec for ExactFallbackCodec {
142    fn codec_id(&self) -> CodecId {
143        crate::policy::CODEC_EXACT_FALLBACK.into()
144    }
145
146    fn encode(&self, vector: &[f32], _seed: u64) -> Result<Vec<u8>> {
147        if vector.len() != self.dim {
148            return Err(crate::error::PolyKvError::DimensionMismatch {
149                expected: self.dim,
150                got: vector.len(),
151            });
152        }
153        // Store raw f32 bytes in little-endian
154        let bytes: Vec<u8> = vector.iter().flat_map(|v| v.to_le_bytes()).collect();
155        Ok(bytes)
156    }
157
158    fn decode(&self, payload: &[u8], _seed: u64) -> Result<Vec<f32>> {
159        let expected_len = self.dim * 4;
160        if payload.len() != expected_len {
161            return Err(crate::error::PolyKvError::CorruptPayload(format!(
162                "exact fallback payload size {} != expected {}",
163                payload.len(),
164                expected_len
165            )));
166        }
167        let mut vec = Vec::with_capacity(self.dim);
168        for chunk in payload.chunks_exact(4) {
169            let arr: [u8; 4] = chunk.try_into().unwrap();
170            vec.push(f32::from_le_bytes(arr));
171        }
172        Ok(vec)
173    }
174
175    fn dim(&self) -> usize {
176        self.dim
177    }
178
179    fn compression_ratio(&self) -> f64 {
180        1.0
181    }
182}
183
184// ── TurboQuant adapter ──
185
186/// Adapter for the turbo-quant crate (8-bit, 32 projections).
187#[cfg(feature = "turbo")]
188pub struct TurboQuantAdapter {
189    dim: usize,
190    bits: u8,
191    projections: usize,
192}
193
194#[cfg(feature = "turbo")]
195impl TurboQuantAdapter {
196    #[allow(clippy::too_many_arguments)]
197    pub fn new(dim: usize, bits: u8, projections: usize) -> Result<Self> {
198        if dim == 0 {
199            return Err(crate::error::PolyKvError::InvalidPolicy(
200                "turbo dim must be > 0".into(),
201            ));
202        }
203        if dim % 2 != 0 {
204            // Pad to even — turbo-quant requires even dimensions
205            return Err(crate::error::PolyKvError::InvalidPolicy(format!(
206                "turbo requires even dimension, got {}",
207                dim
208            )));
209        }
210        Ok(Self {
211            dim,
212            bits,
213            projections,
214        })
215    }
216}
217
218#[cfg(feature = "turbo")]
219impl KVecCodec for TurboQuantAdapter {
220    fn codec_id(&self) -> CodecId {
221        crate::policy::CODEC_TURBO_8BIT.into()
222    }
223
224    fn encode(&self, vector: &[f32], seed: u64) -> Result<Vec<u8>> {
225        let quantizer =
226            turbo_quant::TurboQuantizer::new(self.dim, self.bits, self.projections, seed).map_err(
227                |e| {
228                    crate::error::PolyKvError::CompressionFailed(format!(
229                        "turbo quantizer init failed: {}",
230                        e
231                    ))
232                },
233            )?;
234
235        let code = quantizer.encode(vector).map_err(|e| {
236            crate::error::PolyKvError::CompressionFailed(format!("turbo encode failed: {}", e))
237        })?;
238
239        // Use the compact binary wire format from turbo-quant's TurboCodeWireV1
240        // instead of the JSON envelope. The JSON envelope was 472 bytes/block
241        // around ~26 bytes of actual data for the b=8 / 32-projections path;
242        // the compact format is header (46 bytes) + radii (dim/2 × 4 bytes) +
243        // packed angles (dim/2 × bits/8 bytes) + packed signs (projections/8 bytes).
244        // For head_dim=64, b=8, projections=32: 46 + 128 + 28 + 4 = 206 bytes
245        // (vs ~472 bytes JSON). The compact format is 2.3× smaller per block.
246        turbo_quant::TurboCodeWireV1::encode(&code, &quantizer).map_err(|e| {
247            crate::error::PolyKvError::CompressionFailed(format!("turbo wire encode failed: {}", e))
248        })
249    }
250
251    fn decode(&self, payload: &[u8], seed: u64) -> Result<Vec<f32>> {
252        // Compact binary format is preferred (header starts with TURBO_CODE_WIRE_MAGIC
253        // = "TQW1", 4 bytes), but fall back to JSON for backward compat with shells
254        // written by older poly-kv versions.
255        let code: turbo_quant::TurboCode =
256            if payload.len() >= 4 && &payload[0..4] == turbo_quant::TURBO_CODE_WIRE_MAGIC {
257                let quantizer =
258                    turbo_quant::TurboQuantizer::new(self.dim, self.bits, self.projections, seed)
259                        .map_err(|e| {
260                        crate::error::PolyKvError::DecompressionFailed(format!(
261                            "turbo quantizer init failed: {}",
262                            e
263                        ))
264                    })?;
265                turbo_quant::TurboCodeWireV1::decode(payload, &quantizer).map_err(|e| {
266                    crate::error::PolyKvError::DecompressionFailed(format!(
267                        "turbo wire decode failed: {}",
268                        e
269                    ))
270                })?
271            } else {
272                serde_json::from_slice(payload).map_err(|e| {
273                    crate::error::PolyKvError::DecompressionFailed(format!(
274                        "turbo code deserialize failed: {}",
275                        e
276                    ))
277                })?
278            };
279
280        // Reconstruct from polar component via independent PolarQuantizer.
281        // QJL residual is lossy and not invertible, so we return the polar
282        // approximation.
283        let polar_quant =
284            turbo_quant::PolarQuantizer::new(self.dim, self.bits - 1, seed).map_err(|e| {
285                crate::error::PolyKvError::DecompressionFailed(format!(
286                    "turbo polar quantizer init failed: {}",
287                    e
288                ))
289            })?;
290
291        let reconstructed = polar_quant.decode(&code.polar_code).map_err(|e| {
292            crate::error::PolyKvError::DecompressionFailed(format!("turbo decode failed: {}", e))
293        })?;
294
295        Ok(reconstructed)
296    }
297
298    fn dim(&self) -> usize {
299        self.dim
300    }
301
302    fn compression_ratio(&self) -> f64 {
303        8.0
304    }
305}
306
307// ── FibQuant adapter ──
308
309/// Adapter for the fib-quant crate (k=4, N=32, paper core path).
310#[cfg(feature = "fib")]
311pub struct FibQuantAdapter {
312    dim: usize,
313    k: u32,
314    n: u32,
315    training_samples: u32,
316    lloyd_restarts: u32,
317    lloyd_iterations: u32,
318}
319
320#[cfg(feature = "fib")]
321impl FibQuantAdapter {
322    #[allow(clippy::too_many_arguments)]
323    pub fn new(
324        dim: usize,
325        k: u32,
326        n: u32,
327        training_samples: u32,
328        lloyd_restarts: u32,
329        lloyd_iterations: u32,
330    ) -> Result<Self> {
331        if dim == 0 {
332            return Err(crate::error::PolyKvError::InvalidPolicy(
333                "fib dim must be > 0".into(),
334            ));
335        }
336        if dim % k as usize != 0 {
337            return Err(crate::error::PolyKvError::InvalidPolicy(format!(
338                "fib ambient dim ({}) must be divisible by k ({})",
339                dim, k
340            )));
341        }
342        Ok(Self {
343            dim,
344            k,
345            n,
346            training_samples,
347            lloyd_restarts,
348            lloyd_iterations,
349        })
350    }
351
352    /// Build a FibQuantizer for the given seed.
353    pub fn build_quantizer(
354        &self,
355        seed: u64,
356    ) -> std::result::Result<fib_quant::FibQuantizer, crate::error::PolyKvError> {
357        let mut profile = fib_quant::FibQuantProfileV1::paper_default(
358            self.dim,
359            self.k as usize,
360            self.n as usize,
361            seed,
362        )
363        .map_err(|e| {
364            crate::error::PolyKvError::CompressionFailed(format!("fib profile build failed: {}", e))
365        })?;
366
367        // Override training parameters
368        profile.training_samples = self.training_samples;
369        profile.lloyd_restarts = self.lloyd_restarts;
370        profile.lloyd_iterations = self.lloyd_iterations;
371
372        fib_quant::FibQuantizer::new(profile).map_err(|e| {
373            crate::error::PolyKvError::CompressionFailed(format!(
374                "fib quantizer build failed: {}",
375                e
376            ))
377        })
378    }
379
380    /// Decode one or more `FibCodeV1` values from a pool/shell payload without
381    /// reconstructing the f32 vector values. This is the cold-pool compressed
382    /// read path used by query-aware attention selection.
383    pub fn decode_codes_payload(
384        &self,
385        payload: &[u8],
386        seed: u64,
387    ) -> Result<Vec<fib_quant::FibCodeV1>> {
388        let quantizer = self.build_quantizer(seed)?;
389        let profile = quantizer.profile().clone();
390        let profile_digest = profile.digest().map_err(|e| {
391            crate::error::PolyKvError::DecompressionFailed(format!("fib profile digest: {e}"))
392        })?;
393        let mut codes = if payload.len() >= 4 && payload[0..4] == FIB_WIRE_BATCH_MAGIC {
394            decode_fib_batch_payload_wire(payload)?
395        } else if payload.len() >= 3 && payload[0..3] == FIB_BATCHED_MAGIC {
396            decode_fib_batch_payload(payload, &profile)?
397        } else if payload.len() >= 3 && payload[0..3] == fib_quant::COMPACT_MAGIC {
398            vec![
399                fib_quant::FibCodeV1::from_compact_bytes(payload, &profile).map_err(|e| {
400                    crate::error::PolyKvError::DecompressionFailed(format!(
401                        "fib compact decode failed: {e}"
402                    ))
403                })?,
404            ]
405        } else {
406            vec![serde_json::from_slice(payload).map_err(|e| {
407                crate::error::PolyKvError::DecompressionFailed(format!(
408                    "fib code deserialize failed: {e}"
409                ))
410            })?]
411        };
412        for code in &mut codes {
413            code.profile_digest = profile_digest.clone();
414        }
415        Ok(codes)
416    }
417}
418
419/// Magic for the new self-describing wire batch format: "FBWB" (Fib Wire Batch v1).
420#[cfg(feature = "fib")]
421const FIB_WIRE_BATCH_MAGIC: [u8; 4] = [b'F', b'B', b'W', b'B'];
422
423// Legacy FB2 constants — kept only for backward-compat fallback decode.
424#[cfg(feature = "fib")]
425const FIB_BATCHED_MAGIC: [u8; 3] = [b'F', b'B', b'2'];
426#[cfg(feature = "fib")]
427const FIB_BATCHED_VERSION: u8 = 1;
428
429#[cfg(feature = "fib")]
430fn fib_code_indices_len(block_count: u32, wire_index_bits: u8) -> Result<usize> {
431    (block_count as usize)
432        .checked_mul(wire_index_bits as usize)
433        .map(|bits| bits.div_ceil(8))
434        .ok_or_else(|| {
435            crate::error::PolyKvError::CorruptPayload("FB2 packed index length overflow".into())
436        })
437}
438
439/// Encode a batch using the FBWB shared-header format.
440///
441/// Layout:
442///   [FBWB magic: 4][count: u32][first_wire_len: u32][first_code_wire_bytes: ...]
443///   [remaining codes: (norm_payload + indices)] × (count - 1)
444///
445/// The first code is a complete FibCodeWireV1 blob (self-describing). Subsequent
446/// codes share the same profile shape and store only their payload bytes, keeping
447/// batch overhead proportional to number of codes rather than per-code header cost.
448#[cfg(feature = "fib")]
449fn encode_fib_batch_payload(
450    codes: &[fib_quant::FibCodeV1],
451    profile: &fib_quant::FibQuantProfileV1,
452) -> Result<Vec<u8>> {
453    if codes.is_empty() {
454        return Err(crate::error::PolyKvError::CompressionFailed(
455            "cannot encode empty wire batch".into(),
456        ));
457    }
458    let first_wire = fib_quant::FibCodeWireV1::to_wire_bytes(&codes[0], profile).map_err(|e| {
459        crate::error::PolyKvError::CompressionFailed(format!("fib wire encode failed: {e}"))
460    })?;
461    let norm_len = codes[0].norm_payload.len();
462    let indices_len = codes[0].indices.len();
463    let mut out = Vec::with_capacity(
464        4 + 4 + 4 + first_wire.len() + (codes.len() - 1) * (norm_len + indices_len),
465    );
466    out.extend_from_slice(&FIB_WIRE_BATCH_MAGIC);
467    out.extend_from_slice(&(codes.len() as u32).to_le_bytes());
468    out.extend_from_slice(&(first_wire.len() as u32).to_le_bytes());
469    out.extend_from_slice(&first_wire);
470    for code in &codes[1..] {
471        if code.norm_payload.len() != norm_len || code.indices.len() != indices_len {
472            return Err(crate::error::PolyKvError::CompressionFailed(
473                "wire batch contains heterogeneous FibCodeV1 shapes".into(),
474            ));
475        }
476        out.extend_from_slice(&code.norm_payload);
477        out.extend_from_slice(&code.indices);
478    }
479    Ok(out)
480}
481
482/// Decode the FBWB shared-header batch format. No external profile needed.
483/// Subsequent codes are reconstructed using the shape inferred from the first wire code.
484#[cfg(feature = "fib")]
485fn decode_fib_batch_payload_wire(payload: &[u8]) -> Result<Vec<fib_quant::FibCodeV1>> {
486    // payload[0..4] = FBWB magic (already checked by caller)
487    if payload.len() < 12 {
488        return Err(crate::error::PolyKvError::CorruptPayload(format!(
489            "wire batch too short: {} bytes",
490            payload.len()
491        )));
492    }
493    let count = u32::from_le_bytes([payload[4], payload[5], payload[6], payload[7]]) as usize;
494    if count == 0 {
495        return Ok(Vec::new());
496    }
497    let first_len = u32::from_le_bytes([payload[8], payload[9], payload[10], payload[11]]) as usize;
498    let first_end = 12 + first_len;
499    if first_end > payload.len() {
500        return Err(crate::error::PolyKvError::CorruptPayload(format!(
501            "wire batch first code truncated: need {} bytes, have {}",
502            first_end,
503            payload.len()
504        )));
505    }
506    let (first_code, _profile) = fib_quant::FibCodeWireV1::from_wire_bytes(&payload[12..first_end])
507        .map_err(|e| {
508            crate::error::PolyKvError::DecompressionFailed(format!(
509                "fib wire decode failed at code 0: {e}"
510            ))
511        })?;
512    let norm_len = first_code.norm_payload.len();
513    let indices_len = first_code.indices.len();
514    let per_code = norm_len + indices_len;
515    let remaining = count - 1;
516    if payload.len() < first_end + remaining * per_code {
517        return Err(crate::error::PolyKvError::CorruptPayload(format!(
518            "wire batch body truncated: need {} bytes for {} remaining codes, have {}",
519            remaining * per_code,
520            remaining,
521            payload.len() - first_end
522        )));
523    }
524    let mut codes = Vec::with_capacity(count);
525    codes.push(first_code.clone());
526    let mut cursor = first_end;
527    for _ in 1..count {
528        let norm_payload = payload[cursor..cursor + norm_len].to_vec();
529        cursor += norm_len;
530        let indices = payload[cursor..cursor + indices_len].to_vec();
531        cursor += indices_len;
532        codes.push(fib_quant::FibCodeV1 {
533            schema_version: first_code.schema_version.clone(),
534            profile_digest: first_code.profile_digest.clone(),
535            codebook_digest: first_code.codebook_digest.clone(),
536            rotation_digest: first_code.rotation_digest.clone(),
537            ambient_dim: first_code.ambient_dim,
538            block_dim: first_code.block_dim,
539            norm_format: first_code.norm_format.clone(),
540            norm_payload,
541            wire_index_bits: first_code.wire_index_bits,
542            block_count: first_code.block_count,
543            indices,
544        });
545    }
546    Ok(codes)
547}
548
549/// Legacy FB2 fallback decode — used only when the payload starts with the old FB2 magic.
550#[cfg(feature = "fib")]
551fn decode_fib_batch_payload(
552    payload: &[u8],
553    profile: &fib_quant::FibQuantProfileV1,
554) -> Result<Vec<fib_quant::FibCodeV1>> {
555    if payload.len() < 15 {
556        return Err(crate::error::PolyKvError::CorruptPayload(format!(
557            "FB2 payload too short: {} bytes",
558            payload.len()
559        )));
560    }
561    if payload[0..3] != FIB_BATCHED_MAGIC {
562        return Err(crate::error::PolyKvError::CorruptPayload(
563            "FB2 payload bad magic".into(),
564        ));
565    }
566    if payload[3] != FIB_BATCHED_VERSION {
567        return Err(crate::error::PolyKvError::CorruptPayload(format!(
568            "FB2 version {} not supported",
569            payload[3]
570        )));
571    }
572    let count = u32::from_le_bytes([payload[4], payload[5], payload[6], payload[7]]) as usize;
573    let wire_index_bits = payload[8];
574    let block_count = u32::from_le_bytes([payload[9], payload[10], payload[11], payload[12]]);
575    let norm_len = u16::from_le_bytes([payload[13], payload[14]]) as usize;
576    let indices_len = fib_code_indices_len(block_count, wire_index_bits)?;
577    let per_code = norm_len.checked_add(indices_len).ok_or_else(|| {
578        crate::error::PolyKvError::CorruptPayload("FB2 per-code length overflow".into())
579    })?;
580    let expected = 15usize
581        .checked_add(count.checked_mul(per_code).ok_or_else(|| {
582            crate::error::PolyKvError::CorruptPayload("FB2 payload length overflow".into())
583        })?)
584        .ok_or_else(|| {
585            crate::error::PolyKvError::CorruptPayload("FB2 payload length overflow".into())
586        })?;
587    if payload.len() != expected {
588        return Err(crate::error::PolyKvError::CorruptPayload(format!(
589            "FB2 payload length {} != expected {} (count={count}, per_code={per_code})",
590            payload.len(),
591            expected
592        )));
593    }
594    let profile_digest = profile.digest().map_err(|e| {
595        crate::error::PolyKvError::DecompressionFailed(format!("fib profile digest failed: {e}"))
596    })?;
597    let mut codes = Vec::with_capacity(count);
598    let mut cursor = 15;
599    for _ in 0..count {
600        let norm_payload = payload[cursor..cursor + norm_len].to_vec();
601        cursor += norm_len;
602        let indices = payload[cursor..cursor + indices_len].to_vec();
603        cursor += indices_len;
604        codes.push(fib_quant::FibCodeV1 {
605            schema_version: fib_quant::codec::CODE_SCHEMA.into(),
606            profile_digest: profile_digest.clone(),
607            codebook_digest: String::new(),
608            rotation_digest: String::new(),
609            ambient_dim: profile.ambient_dim,
610            block_dim: profile.block_dim,
611            norm_format: profile.norm_format.clone(),
612            norm_payload,
613            wire_index_bits,
614            block_count,
615            indices,
616        });
617    }
618    Ok(codes)
619}
620
621#[cfg(feature = "fib")]
622impl KVecCodec for FibQuantAdapter {
623    fn codec_id(&self) -> CodecId {
624        crate::policy::CODEC_FIB_K4_N32.into()
625    }
626
627    fn encode(&self, vector: &[f32], seed: u64) -> Result<Vec<u8>> {
628        let quantizer = self.build_quantizer(seed)?;
629        let code = quantizer.encode(vector).map_err(|e| {
630            crate::error::PolyKvError::CompressionFailed(format!("fib encode failed: {}", e))
631        })?;
632
633        // Use the compact binary wire format (23 bytes vs 472 bytes JSON
634        // for fib_k4_n32 with head_dim=64 — 20.5x smaller). The compact
635        // format drops profile_digest, codebook_digest, rotation_digest,
636        // ambient_dim, block_dim, norm_format — all of which the decoder
637        // re-derives from its own profile. See fib_quant::FibCodeV1::to_compact_bytes.
638        Ok(code.to_compact_bytes())
639    }
640
641    fn encode_batch(&self, vectors: &[&[f32]], seed: u64) -> Result<Vec<Vec<u8>>> {
642        // Build a single quantizer shared across the batch. The codebook and
643        // rotation are deterministic functions of the profile, so a single
644        // quantizer is byte-identical to one built per-vector.
645        let quantizer = self.build_quantizer(seed)?;
646        let codes = quantizer.encode_batch(vectors).map_err(|e| {
647            crate::error::PolyKvError::CompressionFailed(format!("fib encode_batch failed: {}", e))
648        })?;
649        let mut out = Vec::with_capacity(codes.len());
650        for code in codes {
651            out.push(code.to_compact_bytes());
652        }
653        Ok(out)
654    }
655
656    fn encode_batch_compact(&self, vectors: &[&[f32]], seed: u64) -> Result<Option<Vec<u8>>> {
657        let quantizer = self.build_quantizer(seed)?;
658        let codes = quantizer.encode_batch(vectors).map_err(|e| {
659            crate::error::PolyKvError::CompressionFailed(format!("fib encode_batch failed: {}", e))
660        })?;
661        Ok(Some(encode_fib_batch_payload(&codes, quantizer.profile())?))
662    }
663
664    fn decode(&self, payload: &[u8], seed: u64) -> Result<Vec<f32>> {
665        let quantizer = self.build_quantizer(seed)?;
666        // Compact binary format is preferred (the pool always writes this
667        // now), but fall back to JSON for backward compat with pools written
668        // by older poly-kv versions.
669        let code = if payload.len() >= 3 && payload[0..3] == fib_quant::COMPACT_MAGIC {
670            let profile = quantizer.profile().clone();
671            fib_quant::FibCodeV1::from_compact_bytes(payload, &profile).map_err(|e| {
672                crate::error::PolyKvError::DecompressionFailed(format!(
673                    "fib compact decode failed: {}",
674                    e
675                ))
676            })?
677        } else {
678            serde_json::from_slice(payload).map_err(|e| {
679                crate::error::PolyKvError::DecompressionFailed(format!(
680                    "fib code deserialize failed: {}",
681                    e
682                ))
683            })?
684        };
685
686        let decoded = quantizer.decode(&code).map_err(|e| {
687            crate::error::PolyKvError::DecompressionFailed(format!("fib decode failed: {}", e))
688        })?;
689
690        Ok(decoded)
691    }
692
693    fn decode_batch(&self, payloads: &[&[u8]], seed: u64) -> Result<Vec<Vec<f32>>> {
694        let quantizer = self.build_quantizer(seed)?;
695        let profile = quantizer.profile().clone();
696        let mut codes = Vec::with_capacity(payloads.len());
697        for p in payloads {
698            let code = if p.len() >= 3 && p[0..3] == fib_quant::COMPACT_MAGIC {
699                fib_quant::FibCodeV1::from_compact_bytes(p, &profile).map_err(|e| {
700                    crate::error::PolyKvError::DecompressionFailed(format!(
701                        "fib compact decode failed: {}",
702                        e
703                    ))
704                })?
705            } else {
706                serde_json::from_slice(p).map_err(|e| {
707                    crate::error::PolyKvError::DecompressionFailed(format!(
708                        "fib code deserialize failed: {}",
709                        e
710                    ))
711                })?
712            };
713            codes.push(code);
714        }
715        quantizer.decode_batch_fast(&codes).map_err(|e| {
716            crate::error::PolyKvError::DecompressionFailed(format!(
717                "fib decode_batch_fast failed: {}",
718                e
719            ))
720        })
721    }
722
723    fn decode_batch_compact(&self, payload: &[u8], seed: u64) -> Result<Option<Vec<Vec<f32>>>> {
724        if payload.len() >= 4 && payload[0..4] == FIB_WIRE_BATCH_MAGIC {
725            // New self-describing wire batch. from_wire_bytes reconstructs the
726            // profile via paper_default (no training-param overrides), so its
727            // profile_digest differs from our adapter's. Patch it to the adapter's
728            // digest before calling decode_batch_fast which validates the field.
729            let mut codes = decode_fib_batch_payload_wire(payload)?;
730            let quantizer = self.build_quantizer(seed)?;
731            let profile_digest = quantizer.profile().digest().map_err(|e| {
732                crate::error::PolyKvError::DecompressionFailed(format!("fib profile digest: {e}"))
733            })?;
734            for code in &mut codes {
735                code.profile_digest = profile_digest.clone();
736            }
737            let decoded = quantizer.decode_batch_fast(&codes).map_err(|e| {
738                crate::error::PolyKvError::DecompressionFailed(format!(
739                    "fib wire batch decode_batch_fast failed: {e}"
740                ))
741            })?;
742            return Ok(Some(decoded));
743        }
744        if payload.len() >= 3 && payload[0..3] == FIB_BATCHED_MAGIC {
745            // Legacy FB2 fallback for payloads written by older poly-kv versions.
746            let quantizer = self.build_quantizer(seed)?;
747            let codes = decode_fib_batch_payload(payload, quantizer.profile())?;
748            let decoded = quantizer.decode_batch_fast(&codes).map_err(|e| {
749                crate::error::PolyKvError::DecompressionFailed(format!(
750                    "fib FB2 decode_batch_fast failed: {e}"
751                ))
752            })?;
753            return Ok(Some(decoded));
754        }
755        Ok(None)
756    }
757
758    fn dim(&self) -> usize {
759        self.dim
760    }
761
762    fn compression_ratio(&self) -> f64 {
763        50.0
764    }
765
766    fn is_gpu_accelerated(&self) -> bool {
767        // Device-level probe — kept for trait compatibility. The pool build
768        // uses is_gpu_accelerated_for(n, d) for honest per-batch reporting.
769        match self.build_quantizer(0) {
770            Ok(q) => q.is_gpu_accelerated(),
771            Err(_) => false,
772        }
773    }
774
775    fn is_gpu_accelerated_for(&self, n: usize, d: usize) -> bool {
776        match self.build_quantizer(0) {
777            Ok(q) => q.is_gpu_accelerated_for(n, d),
778            Err(_) => false,
779        }
780    }
781
782    fn codebook_digest(&self, seed: u64) -> Option<String> {
783        self.build_quantizer(seed)
784            .ok()
785            .map(|q| q.codebook_digest().to_string())
786    }
787
788    fn rotation_digest(&self, seed: u64) -> Option<String> {
789        self.build_quantizer(seed)
790            .ok()
791            .map(|q| q.rotation_digest().to_string())
792    }
793}
794
795/// Create a codec from a policy and vector dimension.
796///
797/// Returns the appropriate codec based on the codec_id in the policy.
798/// If the required compression crate is unavailable, returns an error.
799#[allow(clippy::too_many_arguments)]
800pub fn create_codec(
801    codec_id: &str,
802    dim: usize,
803    fib_config: Option<&crate::policy::FibConfig>,
804    #[cfg_attr(not(feature = "turbo"), allow(unused_variables))] turbo_config: Option<
805        &crate::policy::TurboConfig,
806    >,
807) -> Result<Box<dyn KVecCodec>> {
808    match codec_id {
809        crate::policy::CODEC_FIB_K4_N32 => {
810            #[cfg(feature = "fib")]
811            {
812                let fc = fib_config.ok_or_else(|| {
813                    crate::error::PolyKvError::InvalidPolicy("fib codec requires fib_config".into())
814                })?;
815                let adapter = FibQuantAdapter::new(
816                    dim,
817                    fc.k,
818                    fc.n,
819                    fc.training_samples,
820                    fc.lloyd_restarts,
821                    fc.lloyd_iterations,
822                )?;
823                Ok(Box::new(adapter))
824            }
825            #[cfg(not(feature = "fib"))]
826            {
827                Err(crate::error::PolyKvError::CodecUnavailable {
828                    codec: crate::policy::CODEC_FIB_K4_N32.into(),
829                    feature: "fib".into(),
830                })
831            }
832        }
833        crate::policy::CODEC_TURBO_8BIT => {
834            #[cfg(feature = "turbo")]
835            {
836                let tc = turbo_config.ok_or_else(|| {
837                    crate::error::PolyKvError::InvalidPolicy(
838                        "turbo codec requires turbo_config".into(),
839                    )
840                })?;
841                let adapter = TurboQuantAdapter::new(dim, tc.bits, tc.projections)?;
842                Ok(Box::new(adapter))
843            }
844            #[cfg(not(feature = "turbo"))]
845            {
846                Err(crate::error::PolyKvError::CodecUnavailable {
847                    codec: crate::policy::CODEC_TURBO_8BIT.into(),
848                    feature: "turbo".into(),
849                })
850            }
851        }
852        crate::policy::CODEC_EXACT_FALLBACK => Ok(Box::new(ExactFallbackCodec::new(dim))),
853        other => Err(crate::error::PolyKvError::InvalidPolicy(format!(
854            "unknown codec id: {}",
855            other
856        ))),
857    }
858}