Skip to main content

fib_quant/
codec.rs

1use half::f16;
2use serde::{Deserialize, Serialize};
3
4use crate::{
5    bitpack::{pack_indices, unpack_indices},
6    codebook::FibCodebookV1,
7    digest::{bytes_digest, json_digest},
8    metrics,
9    profile::{FibQuantProfileV1, NormFormat},
10    receipt::FibQuantCompressionReceiptV1,
11    rotation::StoredRotation,
12    FibQuantError, Result,
13};
14
15pub const CODE_SCHEMA: &str = "fib_code_v1";
16
17/// Canonical codec ID string for fib-quant.
18/// Downstream crates should use this instead of defining their own.
19pub const CODEC_ID: &str = "fib_quant";
20
21/// Magic + version prefix for the compact binary wire format.
22/// `F` `B` `1` = Fib Binary v1. Any decoder that sees a different
23/// magic should reject the payload as corrupt.
24pub const COMPACT_MAGIC: [u8; 3] = [b'F', b'B', b'1'];
25pub const COMPACT_VERSION: u8 = 1;
26
27/// Magic + version for the v2 compact wire format with feature flags.
28/// `F` `B` `2` = Fib Binary v2. Adds a 1-byte feature flags field
29/// after the version byte, enabling forward-compatible extensions
30/// (alternative norm encodings, sparse indices, etc.).
31pub const COMPACT_V2_MAGIC: [u8; 3] = [b'F', b'B', b'2'];
32pub const COMPACT_V2_VERSION: u8 = 1;
33
34/// Feature flags for the v2 compact wire format.
35/// All bits are reserved for future use. Unknown flags must be ignored
36/// by the decoder (forward compatibility).
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct CompactFeatureFlags(pub u8);
39
40impl CompactFeatureFlags {
41    /// No features enabled (plain v2-compatible layout).
42    pub const NONE: Self = Self(0);
43    /// Bit 0: sparse index pattern (codeword indices use run-length encoding).
44    pub const SPARSE_INDICES: Self = Self(1 << 0);
45    /// Bit 1: alternative norm encoding (f32 instead of fp16).
46    pub const ALT_NORM_F32: Self = Self(1 << 1);
47    /// Bit 2: rotation schema indicator present (appended after indices).
48    pub const ROTATION_SCHEMA_TAG: Self = Self(1 << 2);
49
50    /// Check if a specific bit is set.
51    pub fn contains(self, bit: Self) -> bool {
52        self.0 & bit.0 != 0
53    }
54}
55
56/// Encoded fixed-rate FibQuant artifact.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct FibCodeV1 {
59    /// Stable schema marker.
60    pub schema_version: String,
61    /// Profile digest.
62    pub profile_digest: String,
63    /// Codebook digest.
64    pub codebook_digest: String,
65    /// Rotation digest.
66    pub rotation_digest: String,
67    /// Ambient dimension.
68    pub ambient_dim: u32,
69    /// Block dimension.
70    pub block_dim: u32,
71    /// Norm payload format.
72    pub norm_format: NormFormat,
73    /// Norm bytes.
74    pub norm_payload: Vec<u8>,
75    /// Bits per fixed-rate index.
76    pub wire_index_bits: u8,
77    /// Number of indices.
78    pub block_count: u32,
79    /// Packed fixed-rate indices.
80    pub indices: Vec<u8>,
81}
82
83impl FibCodeV1 {
84    /// Compact binary wire format. The FibCodeV1 struct carries a lot of
85    /// metadata for JSON deserialization (schema_version, profile_digest,
86    /// rotation_digest, ambient_dim, block_dim, etc.) that the decoder
87    /// either doesn't need (it has its own profile) or can reconstruct
88    /// from the manifest (profile_digest/codebook_digest/rotation_digest).
89    ///
90    /// Compact layout (little-endian, packed):
91    ///   [0..3]  magic: "FB1"
92    ///   [3]     version: 1
93    ///   [4]     wire_index_bits
94    ///   [5..9]  block_count (u32)
95    ///   [9..11] norm_payload (length-prefixed, max 65535 bytes)
96    ///          actually: [9..11] norm_len (u16) + norm bytes
97    ///   then indices bytes
98    ///
99    /// The decoder must already know the profile (or have the manifest
100    /// supply it). It can re-derive the digests from that profile and
101    /// check them at the manifest level. Per-block we only need
102    /// wire_index_bits, block_count, norm_payload, and indices.
103    ///
104    /// For fib_k4_n32 with head_dim=64: 16 indices * 5 bits = 10 bytes
105    /// indices + 2 bytes norm = 12 bytes payload + 11 bytes header =
106    /// **23 bytes per block** vs **474 bytes for JSON** = 20.6x smaller.
107    pub fn to_compact_bytes(&self) -> Vec<u8> {
108        let mut out = Vec::with_capacity(11 + self.norm_payload.len() + self.indices.len());
109        out.extend_from_slice(&COMPACT_MAGIC);
110        out.push(COMPACT_VERSION);
111        out.push(self.wire_index_bits);
112        out.extend_from_slice(&self.block_count.to_le_bytes());
113        let norm_len = self.norm_payload.len() as u16;
114        out.extend_from_slice(&norm_len.to_le_bytes());
115        out.extend_from_slice(&self.norm_payload);
116        out.extend_from_slice(&self.indices);
117        out
118    }
119
120    /// Decode the compact binary format. The caller must supply the
121    /// profile so that the profile/codebook digests in the resulting
122    /// `FibCodeV1` match what `validate_code_header` expects.
123    ///
124    /// The compact format omits the digests because they're derivable
125    /// from the profile — there's no point storing them when the
126    /// decoder will check them against the profile digest anyway.
127    pub fn from_compact_bytes(bytes: &[u8], profile: &FibQuantProfileV1) -> Result<Self> {
128        if bytes.len() < 11 {
129            return Err(FibQuantError::CorruptPayload(format!(
130                "compact FibCodeV1 too short: {} bytes (need >= 11)",
131                bytes.len()
132            )));
133        }
134        if bytes[0..3] != COMPACT_MAGIC {
135            return Err(FibQuantError::CorruptPayload(format!(
136                "compact FibCodeV1 bad magic: {:?} (expected {:?})",
137                &bytes[0..3],
138                COMPACT_MAGIC
139            )));
140        }
141        if bytes[3] != COMPACT_VERSION {
142            return Err(FibQuantError::CorruptPayload(format!(
143                "compact FibCodeV1 version {} not supported (need {})",
144                bytes[3], COMPACT_VERSION
145            )));
146        }
147        let wire_index_bits = bytes[4];
148        let block_count = u32::from_le_bytes([bytes[5], bytes[6], bytes[7], bytes[8]]);
149        let norm_len = u16::from_le_bytes([bytes[9], bytes[10]]) as usize;
150        let header_len = 11;
151        if bytes.len() < header_len + norm_len {
152            return Err(FibQuantError::CorruptPayload(format!(
153                "compact FibCodeV1 truncated: norm_len={} but only {} bytes remain",
154                norm_len,
155                bytes.len() - header_len
156            )));
157        }
158        let norm_payload = bytes[header_len..header_len + norm_len].to_vec();
159        let indices = bytes[header_len + norm_len..].to_vec();
160
161        // Validate packed index length
162        let expected_packed_len = (block_count as usize)
163            .checked_mul(wire_index_bits as usize)
164            .map(|bits| bits.div_ceil(8))
165            .ok_or_else(|| {
166                FibQuantError::ResourceLimitExceeded("packed index bits overflow".into())
167            })?;
168        if indices.len() != expected_packed_len {
169            return Err(FibQuantError::CorruptPayload(format!(
170                "compact FibCodeV1 indices: got {} bytes, expected {} (block_count={} * wire_index_bits={})",
171                indices.len(),
172                expected_packed_len,
173                block_count,
174                wire_index_bits
175            )));
176        }
177
178        // The compact wire format omits codebook_digest and
179        // rotation_digest because they're derivable from the profile.
180        // The decoder re-derives them itself and skips the mismatch check
181        // (see validate_code_header's empty-string short-circuit). We
182        // still need profile_digest because the decode path uses it
183        // to verify the code matches the decoder's profile.
184        let profile_digest = profile.digest()?;
185        Ok(FibCodeV1 {
186            schema_version: CODE_SCHEMA.into(),
187            profile_digest,
188            // Leave these empty — see validate_code_header for the
189            // short-circuit. The cost of building a FibCodebookV1
190            // per from_compact_bytes call is prohibitive (~6ms each,
191            // 10s of seconds for a real pool).
192            codebook_digest: String::new(),
193            rotation_digest: String::new(),
194            ambient_dim: profile.ambient_dim,
195            block_dim: profile.block_dim,
196            norm_format: profile.norm_format.clone(),
197            norm_payload,
198            wire_index_bits,
199            block_count,
200            indices,
201        })
202    }
203
204    /// Compact size in bytes (does not allocate).
205    pub fn compact_size(&self) -> usize {
206        11 + self.norm_payload.len() + self.indices.len()
207    }
208
209    /// Compact v2 binary wire format with feature flags.
210    ///
211    /// Layout (little-endian, packed):
212    ///   [0..3]   magic: "FB2"
213    ///   [3]      version: 1
214    ///   [4]      feature_flags
215    ///   [5]      wire_index_bits
216    ///   [6..10]  block_count (u32)
217    ///   [10..12] norm_len (u16)
218    ///   [12..12+norm_len] norm bytes
219    ///   then indices bytes
220    ///
221    /// The v2 format adds a 1-byte feature flags field at offset 4,
222    /// enabling forward-compatible extensions. Unknown flags are
223    /// ignored by the decoder.
224    pub fn to_compact_v2_bytes(&self, flags: CompactFeatureFlags) -> Vec<u8> {
225        self.to_compact_v2_bytes_with_flags(flags.0)
226    }
227
228    /// Encode v2 with raw flags byte. Internal helper to avoid the
229    /// `CompactFeatureFlags` wrapper when the caller already has a u8.
230    fn to_compact_v2_bytes_with_flags(&self, flags: u8) -> Vec<u8> {
231        let mut out = Vec::with_capacity(12 + self.norm_payload.len() + self.indices.len());
232        out.extend_from_slice(&COMPACT_V2_MAGIC);
233        out.push(COMPACT_V2_VERSION);
234        out.push(flags);
235        out.push(self.wire_index_bits);
236        out.extend_from_slice(&self.block_count.to_le_bytes());
237        let norm_len = self.norm_payload.len() as u16;
238        out.extend_from_slice(&norm_len.to_le_bytes());
239        out.extend_from_slice(&self.norm_payload);
240        out.extend_from_slice(&self.indices);
241        out
242    }
243
244    /// Decode the v2 compact binary format. Accepts any feature flags —
245    /// unknown flags are silently ignored (forward compatibility).
246    /// Known flags that alter the payload layout are handled.
247    pub fn from_compact_v2_bytes(
248        bytes: &[u8],
249        profile: &FibQuantProfileV1,
250    ) -> Result<(Self, CompactFeatureFlags)> {
251        if bytes.len() < 12 {
252            return Err(FibQuantError::CorruptPayload(format!(
253                "compact v2 FibCodeV1 too short: {} bytes (need >= 12)",
254                bytes.len()
255            )));
256        }
257        if bytes[0..3] != COMPACT_V2_MAGIC {
258            return Err(FibQuantError::CorruptPayload(format!(
259                "compact v2 FibCodeV1 bad magic: {:?} (expected {:?})",
260                &bytes[0..3],
261                COMPACT_V2_MAGIC
262            )));
263        }
264        if bytes[3] != COMPACT_V2_VERSION {
265            return Err(FibQuantError::CorruptPayload(format!(
266                "compact v2 FibCodeV1 version {} not supported (need {})",
267                bytes[3], COMPACT_V2_VERSION
268            )));
269        }
270        let flags = CompactFeatureFlags(bytes[4]);
271        let wire_index_bits = bytes[5];
272        let block_count = u32::from_le_bytes([bytes[6], bytes[7], bytes[8], bytes[9]]);
273        let norm_len = u16::from_le_bytes([bytes[10], bytes[11]]) as usize;
274        let header_len = 12;
275        if bytes.len() < header_len + norm_len {
276            return Err(FibQuantError::CorruptPayload(format!(
277                "compact v2 FibCodeV1 truncated: norm_len={} but only {} bytes remain",
278                norm_len,
279                bytes.len() - header_len
280            )));
281        }
282        let norm_payload = bytes[header_len..header_len + norm_len].to_vec();
283        let indices = bytes[header_len + norm_len..].to_vec();
284
285        // Validate packed index length
286        let expected_packed_len = (block_count as usize)
287            .checked_mul(wire_index_bits as usize)
288            .map(|bits| bits.div_ceil(8))
289            .ok_or_else(|| {
290                FibQuantError::ResourceLimitExceeded("packed index bits overflow".into())
291            })?;
292        if indices.len() != expected_packed_len {
293            return Err(FibQuantError::CorruptPayload(format!(
294                "compact v2 FibCodeV1 indices: got {} bytes, expected {} (block_count={} * wire_index_bits={})",
295                indices.len(),
296                expected_packed_len,
297                block_count,
298                wire_index_bits
299            )));
300        }
301
302        let profile_digest = profile.digest()?;
303        Ok((
304            FibCodeV1 {
305                schema_version: CODE_SCHEMA.into(),
306                profile_digest,
307                codebook_digest: String::new(),
308                rotation_digest: String::new(),
309                ambient_dim: profile.ambient_dim,
310                block_dim: profile.block_dim,
311                norm_format: profile.norm_format.clone(),
312                norm_payload,
313                wire_index_bits,
314                block_count,
315                indices,
316            },
317            flags,
318        ))
319    }
320}
321
322/// FibQuant encoder/decoder bound to one profile and codebook.
323#[derive(Debug, Clone)]
324pub struct FibQuantizer {
325    profile: FibQuantProfileV1,
326    codebook: FibCodebookV1,
327    rotation: StoredRotation,
328    /// Cached profile digest — computed once at construction, reused
329    /// on every encode() call. Previously this was recomputed per
330    /// encode (JSON serialization + BLAKE3 hash of the full profile,
331    /// ~17ms at d=768).
332    profile_digest: String,
333    /// Cached rotation digest — same reasoning.
334    rotation_digest: String,
335}
336
337impl FibQuantizer {
338    /// Build a quantizer by constructing the profile codebook.
339    pub fn new(profile: FibQuantProfileV1) -> Result<Self> {
340        let codebook = FibCodebookV1::build(profile)?;
341        Self::from_codebook(codebook)
342    }
343
344    /// Build a quantizer from a validated codebook.
345    pub fn from_codebook(codebook: FibCodebookV1) -> Result<Self> {
346        codebook.validate()?;
347        let profile = codebook.profile.clone();
348        let rotation = StoredRotation::new(profile.ambient_dim as usize, profile.rotation_seed)?;
349        let profile_digest = profile.digest()?;
350        let rotation_digest = rotation.digest()?;
351        Ok(Self {
352            profile,
353            codebook,
354            rotation,
355            profile_digest,
356            rotation_digest,
357        })
358    }
359
360    /// Access the profile.
361    pub fn profile(&self) -> &FibQuantProfileV1 {
362        &self.profile
363    }
364
365    /// Access the codebook.
366    pub fn codebook(&self) -> &FibCodebookV1 {
367        &self.codebook
368    }
369
370    /// Access the stored rotation. Exposed so downstream consumers
371    /// (scoring, residual, compat) don't need to reconstruct it
372    /// from the seed — saves an O(dim²) QR decomposition per call.
373    pub fn rotation(&self) -> &StoredRotation {
374        &self.rotation
375    }
376
377    /// Return the codebook digest (BLAKE3 hex of the codebook).
378    /// Convenience accessor so downstream consumers (poly-kv, scr-runtime)
379    /// don't need to access the codebook field chain directly.
380    pub fn codebook_digest(&self) -> &str {
381        &self.codebook.codebook_digest
382    }
383
384    /// Return the rotation digest (BLAKE3 hex of the rotation matrix).
385    pub fn rotation_digest(&self) -> &str {
386        &self.codebook.rotation_digest
387    }
388
389    /// Nominal compression ratio (original_bytes / encoded_bytes) for the
390    /// compact binary wire format at this profile's default operating point.
391    /// This is a theoretical ratio: `(ambient_dim * 4) / compact_size()`.
392    /// Actual ratio depends on the vector data (norm magnitude, codebook
393    /// index distribution).
394    pub fn nominal_compression_ratio(&self) -> f64 {
395        let original = (self.profile.ambient_dim as usize) * std::mem::size_of::<f32>();
396        // Average compact size: header (11 bytes) + norm (2 bytes fp16) +
397        // packed indices (block_count * wire_index_bits / 8)
398        let block_count = self.profile.block_count() as usize;
399        let index_bytes = (block_count * self.profile.wire_index_bits as usize).div_ceil(8);
400        let encoded = 11 + 2 + index_bytes;
401        original as f64 / encoded as f64
402    }
403
404    /// Default degradation threshold for this codec — the maximum
405    /// reconstruction MSE beyond which the compressed artifact should
406    /// be considered degraded and exact fallback should be used.
407    /// Based on empirical P26 measurements at k=4, N=32.
408    pub fn default_degradation_threshold(&self) -> f64 {
409        0.03
410    }
411
412    /// Whether this codec is high-fidelity (cosine > 0.85 at default
413    /// operating point). Used by quant-governor for routing decisions.
414    pub fn is_high_fidelity(&self) -> bool {
415        // k=4, N=32 gives cosine ~0.863 (measured). Smaller codebooks
416        // or larger blocks are lower fidelity.
417        self.profile.codebook_size >= 16 && self.profile.block_dim <= 8
418    }
419
420    /// Encode a vector into a fixed-rate artifact.
421    pub fn encode(&self, x: &[f32]) -> Result<FibCodeV1> {
422        let d = self.profile.ambient_dim as usize;
423        if x.len() != d {
424            return Err(FibQuantError::CorruptPayload(format!(
425                "input dimension {}, expected {d}",
426                x.len()
427            )));
428        }
429        check_finite(x)?;
430        let norm = l2_norm(x);
431        if norm == 0.0 {
432            return Err(FibQuantError::ZeroNorm);
433        }
434        // Convert to f64 for the rotation (it expects f64 internally),
435        // then back to f32 for the SIMD-accelerated argmin loop.
436        let normalized: Vec<f64> = x.iter().map(|value| f64::from(*value) / norm).collect();
437        let rotated_f64 = self.rotation.apply(&normalized)?;
438        let rotated_f32: Vec<f32> = rotated_f64.iter().map(|&v| v as f32).collect();
439        let _block_count = self.profile.block_count() as usize;
440        let n = self.profile.codebook_size as usize;
441        let k = self.profile.block_dim as usize;
442        let c_indices =
443            crate::ffi::c_encode_vector_block(&rotated_f32, &self.codebook.codewords, n, k);
444        let indices: Vec<u32> = c_indices.iter().map(|&i| i as u32).collect();
445        Ok(FibCodeV1 {
446            schema_version: CODE_SCHEMA.into(),
447            profile_digest: self.profile.digest()?,
448            codebook_digest: self.codebook.codebook_digest.clone(),
449            rotation_digest: self.rotation.digest()?,
450            ambient_dim: self.profile.ambient_dim,
451            block_dim: self.profile.block_dim,
452            norm_format: self.profile.norm_format.clone(),
453            norm_payload: encode_norm(norm, &self.profile.norm_format)?,
454            wire_index_bits: self.profile.wire_index_bits,
455            block_count: self.profile.block_count(),
456            indices: pack_indices(&indices, self.profile.wire_index_bits)?,
457        })
458    }
459
460    /// Decode a fixed-rate artifact.
461    pub fn decode(&self, code: &FibCodeV1) -> Result<Vec<f32>> {
462        self.validate_code_header(code)?;
463        let k = self.profile.block_dim as usize;
464        let block_count = self.profile.block_count() as usize;
465        let codebook_size = self.profile.codebook_size as usize;
466        let codewords = &self.codebook.codewords;
467        let unpacked = unpack_indices(&code.indices, block_count, self.profile.wire_index_bits)?;
468        let expected_len = block_count.checked_mul(k).ok_or_else(|| {
469            FibQuantError::ResourceLimitExceeded("decoded rotated vector length overflow".into())
470        })?;
471        // Validate indices before passing to C kernel.
472        for &index in &unpacked {
473            let idx = index as usize;
474            if idx >= codebook_size {
475                return Err(FibQuantError::IndexOutOfRange {
476                    index,
477                    codebook_size: codebook_size as u32,
478                });
479            }
480        }
481        // Gather codewords via C kernel — no f64 intermediate.
482        let u16_indices: Vec<u16> = unpacked.iter().map(|&i| i as u16).collect();
483        let rotated_f32 =
484            crate::ffi::c_decode_vector_block(&u16_indices, codewords, codebook_size, k);
485        if rotated_f32.len() != expected_len {
486            return Err(FibQuantError::CorruptPayload(
487                "decoded rotated vector length mismatch".into(),
488            ));
489        }
490        let norm = decode_norm(&code.norm_payload, &code.norm_format)?;
491        // Use f32 rotation (much faster than f64 path at d=768).
492        let matrix_f32 = self.rotation.matrix_f32();
493        let reconstructed = self
494            .rotation
495            .apply_inverse_f32_with_matrix(&rotated_f32, &matrix_f32)?;
496        let out: Vec<f32> = reconstructed
497            .into_iter()
498            .map(|value| value * norm as f32)
499            .collect();
500        check_finite(&out)?;
501        Ok(out)
502    }
503
504    /// Encode and emit a receipt.
505    pub fn encode_with_receipt(
506        &self,
507        x: &[f32],
508    ) -> Result<(FibCodeV1, FibQuantCompressionReceiptV1)> {
509        let code = self.encode(x)?;
510        let source_vector_digest = source_vector_digest(x)?;
511        let original_bytes = std::mem::size_of_val(x);
512        let encoded_bytes = code.compact_size();
513        let lloyd_refined = self.profile.lloyd_mode == crate::profile::LloydMode::Refine;
514        let mut receipt = FibQuantCompressionReceiptV1::new(
515            &self.profile,
516            code.profile_digest.clone(),
517            code.codebook_digest.clone(),
518            code.rotation_digest.clone(),
519            source_vector_digest,
520            encoded_digest(&code)?,
521            original_bytes,
522            encoded_bytes,
523            lloyd_refined,
524        );
525        let decoded = self.decode(&code)?;
526        receipt.mse = Some(metrics::mse(x, &decoded)?);
527        receipt.cosine_similarity = Some(metrics::cosine_similarity(x, &decoded)?);
528        Ok((code, receipt))
529    }
530
531    /// Reconstruction MSE for one vector.
532    pub fn reconstruction_mse(&self, x: &[f32]) -> Result<f64> {
533        let code = self.encode(x)?;
534        let decoded = self.decode(&code)?;
535        metrics::mse(x, &decoded)
536    }
537
538    /// Reconstruction cosine similarity for one vector.
539    pub fn cosine_similarity(&self, x: &[f32]) -> Result<f64> {
540        let code = self.encode(x)?;
541        let decoded = self.decode(&code)?;
542        metrics::cosine_similarity(x, &decoded)
543    }
544
545    // ── Batch encode/decode ──
546
547    /// Encode a batch of vectors. Uses gpu-backend for the Hadamard + Lloyd-Max
548    /// portions when available, keeping the FibCodeV1 format identical to single encode.
549    pub fn encode_batch(&self, vectors: &[&[f32]]) -> Result<Vec<FibCodeV1>> {
550        let d = self.profile.ambient_dim as usize;
551        let k = self.profile.block_dim as usize;
552        let n = vectors.len();
553        if n == 0 {
554            return Ok(vec![]);
555        }
556
557        // Fall back to single encode for small batches
558        if n < 4 {
559            return vectors.iter().map(|v| self.encode(v)).collect();
560        }
561
562        // Flatten input
563        let mut flat = Vec::with_capacity(n * d);
564        let mut norms_f64 = Vec::with_capacity(n);
565        for v in vectors {
566            if v.len() != d {
567                return Err(FibQuantError::CorruptPayload(format!(
568                    "input dimension {}, expected {d}",
569                    v.len()
570                )));
571            }
572            check_finite(v)?;
573            let norm = l2_norm(v);
574            if norm == 0.0 {
575                return Err(FibQuantError::ZeroNorm);
576            }
577            norms_f64.push(norm);
578            for &x in *v {
579                flat.push((x as f64 / norm) as f32);
580            }
581        }
582
583        // Apply Hadamard batch rotation (uses gpu-backend when available)
584        #[cfg(feature = "gpu")]
585        {
586            if let Some(_ctx) = gpu_backend::GpuContext::init() {
587                if n >= gpu_backend::GpuContext::GPU_MIN_BATCH_SIZE
588                    && d >= gpu_backend::GpuContext::GPU_MIN_DIM
589                {
590                    gpu_backend::hadamard_batch(&mut flat, n, d, self.profile.rotation_seed)
591                        .map_err(|e| {
592                            FibQuantError::NumericalFailure(format!("gpu hadamard: {}", e))
593                        })?;
594
595                    // GPU codebook lookup: the dominant cost in encode_batch
596                    // for k=4, N=32. Falls back to CPU if N > 32 or other
597                    // gates fail; the indices produced are byte-identical to
598                    // the CPU path (verified by gpu-backend parity test).
599                    //
600                    // The `gpu_codebook_lookup` cfg switches this on. When
601                    // off, the rotated data goes back to the CPU for the
602                    // codebook loop. The current dispatch path through
603                    // gpu_backend pays H2D + D2H per call, which can be
604                    // slower than a tight CPU loop for small batches.
605                    #[cfg(feature = "gpu_codebook_lookup")]
606                    {
607                        let block_count = self.profile.block_count() as usize;
608                        if let Ok(indices) = gpu_backend::codebook_lookup_batch(
609                            &flat,
610                            &self.codebook.codewords,
611                            n,
612                            d,
613                            k,
614                        ) {
615                            if indices.len() == n * block_count {
616                                return self.finish_batch_encode_with_indices(
617                                    &flat, &norms_f64, &indices, n, d, k,
618                                );
619                            }
620                            // Length mismatch — fall through to CPU for safety.
621                        }
622                    }
623
624                    // CPU fallback for the codebook lookup (Hadamard already on GPU).
625                    return self.finish_batch_encode(&flat, &norms_f64, n, d, k);
626                }
627            }
628        }
629
630        // CPU fallback: use StoredRotation on each vector
631        let mut rotated_flat = Vec::with_capacity(n * d);
632        for chunk in flat.chunks_exact(d) {
633            let f64_chunk: Vec<f64> = chunk.iter().map(|&v| v as f64).collect();
634            let rot = self.rotation.apply(&f64_chunk)?;
635            rotated_flat.extend(rot.iter().map(|&v| v as f32));
636        }
637
638        self.finish_batch_encode(&rotated_flat, &norms_f64, n, d, k)
639    }
640
641    fn finish_batch_encode(
642        &self,
643        rotated: &[f32],
644        norms: &[f64],
645        n: usize,
646        d: usize,
647        k: usize,
648    ) -> Result<Vec<FibCodeV1>> {
649        // Precompute digest fields that are identical for every code in
650        // this batch. Saves a digest call per vector (the profile digest
651        // is the same for all codes).
652        let profile_digest = self.profile_digest.clone();
653        let codebook_digest = self.codebook.codebook_digest.clone();
654        let rotation_digest = self.rotation_digest.clone();
655        let profile = &self.profile;
656        let codewords_f32: &[f32] = &self.codebook.codewords;
657
658        // Per-vector work. Independent across vec_idx, so we can either
659        // run it serially or via Rayon. The Rayon threshold is set so
660        // that small batches don't pay the parallel-dispatch tax.
661        let per_vec = |vec_idx: usize| -> Result<FibCodeV1> {
662            let start = vec_idx * d;
663            let chunk = &rotated[start..start + d];
664            let c_indices = crate::ffi::c_encode_vector_block(
665                chunk,
666                codewords_f32,
667                self.profile.codebook_size as usize,
668                k,
669            );
670            let indices: Vec<u32> = c_indices.iter().map(|&i| i as u32).collect();
671            Ok(FibCodeV1 {
672                schema_version: CODE_SCHEMA.into(),
673                profile_digest: profile_digest.clone(),
674                codebook_digest: codebook_digest.clone(),
675                rotation_digest: rotation_digest.clone(),
676                ambient_dim: profile.ambient_dim,
677                block_dim: profile.block_dim,
678                norm_format: profile.norm_format.clone(),
679                norm_payload: encode_norm(norms[vec_idx], &profile.norm_format)?,
680                wire_index_bits: profile.wire_index_bits,
681                block_count: profile.block_count(),
682                indices: pack_indices(&indices, profile.wire_index_bits)?,
683            })
684        };
685
686        // Heuristic: only parallelize when the per-vector work is large
687        // enough to amortize Rayon's dispatch overhead. Empirically,
688        // d=128 k=4 with n >= 16 sees a win on 4-core machines.
689        #[cfg(feature = "parallel")]
690        {
691            const RAYON_MIN_N: usize = 16;
692            if n >= RAYON_MIN_N {
693                use rayon::prelude::*;
694                return (0..n).into_par_iter().map(per_vec).collect();
695            }
696        }
697
698        let mut codes = Vec::with_capacity(n);
699        for vec_idx in 0..n {
700            codes.push(per_vec(vec_idx)?);
701        }
702        Ok(codes)
703    }
704
705    /// Build `FibCodeV1` records from a pre-computed index array. Used by
706    /// the GPU path after `codebook_lookup_batch` returns the per-block
707    /// nearest-codeword indices. Length of `indices` must be `n * (d / k)`.
708    #[cfg(all(feature = "gpu", feature = "gpu_codebook_lookup"))]
709    fn finish_batch_encode_with_indices(
710        &self,
711        _rotated: &[f32], // not used; indices are already computed
712        norms: &[f64],
713        indices: &[u32],
714        n: usize,
715        _d: usize,
716        _k: usize,
717    ) -> Result<Vec<FibCodeV1>> {
718        let block_count = self.profile.block_count() as usize;
719        if indices.len() != n * block_count {
720            return Err(FibQuantError::CorruptPayload(format!(
721                "indices length {} != n * block_count {}",
722                indices.len(),
723                n * block_count
724            )));
725        }
726
727        let mut codes = Vec::with_capacity(n);
728        for (vec_idx, &norm) in norms.iter().enumerate().take(n) {
729            let start = vec_idx * block_count;
730            let end = start + block_count;
731            let vec_indices: Vec<u32> = indices[start..end].to_vec();
732
733            codes.push(FibCodeV1 {
734                schema_version: CODE_SCHEMA.into(),
735                profile_digest: self.profile.digest()?,
736                codebook_digest: self.codebook.codebook_digest.clone(),
737                rotation_digest: self.rotation.digest()?,
738                ambient_dim: self.profile.ambient_dim,
739                block_dim: self.profile.block_dim,
740                norm_format: self.profile.norm_format.clone(),
741                norm_payload: encode_norm(norm, &self.profile.norm_format)?,
742                wire_index_bits: self.profile.wire_index_bits,
743                block_count: self.profile.block_count(),
744                indices: pack_indices(&vec_indices, self.profile.wire_index_bits)?,
745            });
746        }
747        Ok(codes)
748    }
749
750    /// Decode a batch of codes.
751    pub fn decode_batch(&self, codes: &[FibCodeV1]) -> Result<Vec<Vec<f32>>> {
752        codes.iter().map(|c| self.decode(c)).collect()
753    }
754
755    /// Fast batch decode. Optimized for the case where many small codes
756    /// share the same profile (so the codebook + rotation are reused).
757    ///
758    /// Key wins over `decode_batch`:
759    /// 1. No per-index `Vec<f64>` allocation in the codeword gather —
760    ///    each codeword is copied in place into a single `Vec<f32>`.
761    /// 2. The rotation matrix is converted to f32 once for the whole
762    ///    batch, then `apply_inverse_f32` is called per code (no f32→f64
763    ///    roundtrip on the rotation or the input).
764    /// 3. The unpacked indices are reused via `as_f32_slice()` where
765    ///    possible.
766    ///
767    /// Output is byte-identical to calling `decode` per code, modulo
768    /// the final `as f32` cast in `decode` (we also cast to f32 at the
769    /// end; intermediate precision is below the codebook quantization
770    /// noise floor and matches the original `as f32` step exactly).
771    pub fn decode_batch_fast(&self, codes: &[FibCodeV1]) -> Result<Vec<Vec<f32>>> {
772        if codes.is_empty() {
773            return Ok(Vec::new());
774        }
775        let k = self.profile.block_dim as usize;
776        let codebook_size = self.profile.codebook_size as usize;
777        let codewords = &self.codebook.codewords;
778        // Hoist the f32 rotation matrix conversion out of the per-code loop.
779        // Previously this was called inside apply_inverse_f32 on every code,
780        // costing O(dim²) f64→f32 conversions per code. Now we convert once
781        // and reuse across the entire batch.
782        let matrix_f32 = self.rotation.matrix_f32();
783        let mut out = Vec::with_capacity(codes.len());
784        for code in codes {
785            self.validate_code_header(code)?;
786            let block_count = self.profile.block_count() as usize;
787            let unpacked =
788                unpack_indices(&code.indices, block_count, self.profile.wire_index_bits)?;
789            let expected_len = block_count.checked_mul(k).ok_or_else(|| {
790                FibQuantError::ResourceLimitExceeded(
791                    "decoded rotated vector length overflow".into(),
792                )
793            })?;
794            // Gather codewords in place. No allocation per index.
795            let mut rotated_f32: Vec<f32> = Vec::with_capacity(expected_len);
796            for &index in &unpacked {
797                let idx = index as usize;
798                if idx >= codebook_size {
799                    return Err(FibQuantError::IndexOutOfRange {
800                        index,
801                        codebook_size: codebook_size as u32,
802                    });
803                }
804                let base = idx * k;
805                // Direct slice extend in f32. No f32→f64 conversion.
806                rotated_f32.extend_from_slice(&codewords[base..base + k]);
807            }
808            debug_assert_eq!(rotated_f32.len(), expected_len);
809            let norm = decode_norm(&code.norm_payload, &code.norm_format)?;
810            // Single f32 rotation using the pre-converted matrix.
811            let reconstructed = self
812                .rotation
813                .apply_inverse_f32_with_matrix(&rotated_f32, &matrix_f32)?;
814            let scaled: Vec<f32> = reconstructed
815                .into_iter()
816                .map(|value| value * norm as f32)
817                .collect();
818            check_finite(&scaled)?;
819            out.push(scaled);
820        }
821        Ok(out)
822    }
823
824    /// Encode vectors across multiple KV-cache layers in one call.
825    ///
826    /// This is an optimization for the multi-layer KV-cache case where
827    /// `layers` slices of `n` vectors each share the same profile. The
828    /// codebook and rotation are constructed once and reused across all
829    /// layers, avoiding the per-layer quantizer rebuild that the naive
830    /// `encode_batch` loop would incur.
831    ///
832    /// `layer_vectors` is organized as `[layer][vector][dim]`. Each
833    /// inner slice must have length `ambient_dim`. Returns
834    /// `[layer][vector]` of `FibCodeV1`.
835    pub fn encode_layers(&self, layer_vectors: &[&[&[f32]]]) -> Result<Vec<Vec<FibCodeV1>>> {
836        if layer_vectors.is_empty() {
837            return Ok(Vec::new());
838        }
839        // Pre-compute digest fields that are identical for every code
840        // across all layers.
841        let profile_digest = self.profile_digest.clone();
842        let codebook_digest = self.codebook.codebook_digest.clone();
843        let rotation_digest = self.rotation_digest.clone();
844        let d = self.profile.ambient_dim as usize;
845        let k = self.profile.block_dim as usize;
846        let profile = &self.profile;
847        let codewords_f32: &[f32] = &self.codebook.codewords;
848
849        let mut out = Vec::with_capacity(layer_vectors.len());
850        for layer in layer_vectors {
851            let n = layer.len();
852            if n == 0 {
853                out.push(Vec::new());
854                continue;
855            }
856            let mut codes = Vec::with_capacity(n);
857            for &vec_slice in *layer {
858                if vec_slice.len() != d {
859                    return Err(FibQuantError::CorruptPayload(format!(
860                        "input dimension {}, expected {d}",
861                        vec_slice.len()
862                    )));
863                }
864                check_finite(vec_slice)?;
865                let norm = l2_norm(vec_slice);
866                if norm == 0.0 {
867                    return Err(FibQuantError::ZeroNorm);
868                }
869                let normalized: Vec<f64> = vec_slice.iter().map(|v| f64::from(*v) / norm).collect();
870                let rotated_f64 = self.rotation.apply(&normalized)?;
871                let rotated_f32: Vec<f32> = rotated_f64.iter().map(|&v| v as f32).collect();
872                let block_count = profile.block_count() as usize;
873                let mut indices = Vec::with_capacity(block_count);
874                for block in rotated_f32.chunks_exact(k) {
875                    indices.push(gpu_backend::nearest_codeword_f32(block, codewords_f32, k) as u32);
876                }
877                codes.push(FibCodeV1 {
878                    schema_version: CODE_SCHEMA.into(),
879                    profile_digest: profile_digest.clone(),
880                    codebook_digest: codebook_digest.clone(),
881                    rotation_digest: rotation_digest.clone(),
882                    ambient_dim: profile.ambient_dim,
883                    block_dim: profile.block_dim,
884                    norm_format: profile.norm_format.clone(),
885                    norm_payload: encode_norm(norm, &profile.norm_format)?,
886                    wire_index_bits: profile.wire_index_bits,
887                    block_count: profile.block_count(),
888                    indices: pack_indices(&indices, profile.wire_index_bits)?,
889                });
890            }
891            out.push(codes);
892        }
893        Ok(out)
894    }
895
896    /// Check if GPU acceleration is available.
897    ///
898    /// This is a **device-availability** probe: it returns true if a CUDA
899    /// device was found at init time. Whether an *individual* encode_batch
900    /// call actually dispatches to GPU depends on the call's batch size and
901    /// vector dimension crossing the runtime thresholds.
902    ///
903    /// Use [`Self::is_gpu_accelerated_for`] for an honest per-call check.
904    pub fn is_gpu_accelerated(&self) -> bool {
905        #[cfg(feature = "gpu")]
906        {
907            gpu_backend::GpuContext::is_available()
908        }
909        #[cfg(not(feature = "gpu"))]
910        {
911            false
912        }
913    }
914
915    /// Check if a batch of `n` vectors of dimension `d` would actually
916    /// dispatch to GPU. Returns true only when:
917    ///   - the `gpu` feature is compiled in,
918    ///   - a CUDA device is available at runtime,
919    ///   - `n >= GPU_MIN_BATCH_SIZE` and `d >= GPU_MIN_DIM`, AND
920    ///   - the codebook size `N` is <= 32 (the codebook_lookup kernel
921    ///     is one warp wide and falls back to CPU otherwise).
922    ///
923    /// This is the honest gate for receipts: a 4-doc corpus with dim 64
924    /// returns false even with `--features gpu`, because the batch is too
925    /// small to overcome GPU launch overhead. A corpus with a codebook
926    /// larger than 32 also returns false.
927    pub fn is_gpu_accelerated_for(&self, n: usize, d: usize) -> bool {
928        #[cfg(feature = "gpu")]
929        {
930            if !gpu_backend::GpuContext::is_available() {
931                return false;
932            }
933            n >= gpu_backend::GpuContext::GPU_MIN_BATCH_SIZE
934                && d >= gpu_backend::GpuContext::GPU_MIN_DIM
935                && (self.profile.codebook_size as usize) <= 32
936        }
937        #[cfg(not(feature = "gpu"))]
938        {
939            let _ = (n, d);
940            false
941        }
942    }
943
944    /// Per-step GPU dispatch report. `hadamard` is true if a batch of size
945    /// `n` at dim `d` would dispatch the Hadamard rotation to GPU.
946    /// `codebook_lookup` is true only if both the Hadamard AND the
947    /// codebook-lookup step would dispatch (additionally requires codebook
948    /// size <= 32). The latter is independent of the `gpu_codebook_lookup`
949    /// feature gate — the feature controls whether the dispatch is enabled
950    /// in `encode_batch`, not whether the kernel would be a win.
951    pub fn gpu_steps_for(&self, n: usize, d: usize) -> GpuStepReport {
952        let device_available = {
953            #[cfg(feature = "gpu")]
954            {
955                gpu_backend::GpuContext::is_available()
956            }
957            #[cfg(not(feature = "gpu"))]
958            {
959                false
960            }
961        };
962        // Thresholds are the same as gpu_backend::GpuContext's. Hard-code
963        // them here to avoid requiring the gpu feature for the probe.
964        const MIN_BATCH: usize = 16;
965        const MIN_DIM: usize = 64;
966        let clears_thresholds = n >= MIN_BATCH && d >= MIN_DIM;
967        let codebook_fits = (self.profile.codebook_size as usize) <= 32;
968        GpuStepReport {
969            hadamard: device_available && clears_thresholds,
970            codebook_lookup: device_available && clears_thresholds && codebook_fits,
971        }
972    }
973
974    // ── End batch methods ──
975
976    fn validate_code_header(&self, code: &FibCodeV1) -> Result<()> {
977        if code.schema_version != CODE_SCHEMA {
978            return Err(FibQuantError::CorruptPayload(format!(
979                "code schema_version {}, expected {CODE_SCHEMA}",
980                code.schema_version
981            )));
982        }
983        let expected_profile = self.profile_digest.clone();
984        if code.profile_digest != expected_profile {
985            return Err(FibQuantError::ProfileDigestMismatch {
986                expected: expected_profile,
987                actual: code.profile_digest.clone(),
988            });
989        }
990        // Codebook and rotation digests are skipped if empty — the
991        // compact wire format omits them because they're derivable from
992        // the profile. The decoder trusts its own codebook/rotation in
993        // that case. (Re-deriving the codebook just to compute the
994        // digest cost ~6ms per call, which is prohibitive for batch
995        // decode of 1.5M+ blocks.)
996        if !code.codebook_digest.is_empty() && code.codebook_digest != self.codebook.codebook_digest
997        {
998            return Err(FibQuantError::CodebookDigestMismatch {
999                expected: self.codebook.codebook_digest.clone(),
1000                actual: code.codebook_digest.clone(),
1001            });
1002        }
1003        let expected_rotation = self.rotation_digest.clone();
1004        if !code.rotation_digest.is_empty()
1005            && (code.rotation_digest != expected_rotation
1006                || code.rotation_digest != self.codebook.rotation_digest)
1007        {
1008            return Err(FibQuantError::RotationDigestMismatch {
1009                expected: expected_rotation,
1010                actual: code.rotation_digest.clone(),
1011            });
1012        }
1013        if code.ambient_dim != self.profile.ambient_dim
1014            || code.block_dim != self.profile.block_dim
1015            || code.block_count != self.profile.block_count()
1016            || code.wire_index_bits != self.profile.wire_index_bits
1017            || code.norm_format != self.profile.norm_format
1018        {
1019            return Err(FibQuantError::CorruptPayload(
1020                "encoded header does not match profile".into(),
1021            ));
1022        }
1023        Ok(())
1024    }
1025}
1026
1027/// Stable digest over the encoded artifact fields.
1028pub fn encoded_digest(code: &FibCodeV1) -> Result<String> {
1029    json_digest(CODE_SCHEMA, code)
1030}
1031
1032fn source_vector_digest(x: &[f32]) -> Result<String> {
1033    check_finite(x)?;
1034    let mut bytes = Vec::with_capacity(32 + std::mem::size_of_val(x));
1035    bytes.extend_from_slice(b"fib_quant_source_vector_v1");
1036    bytes.push(0);
1037    bytes.extend_from_slice(&(x.len() as u64).to_le_bytes());
1038    for value in x {
1039        bytes.extend_from_slice(&value.to_le_bytes());
1040    }
1041    Ok(bytes_digest(&bytes))
1042}
1043
1044fn encode_norm(norm: f64, format: &NormFormat) -> Result<Vec<u8>> {
1045    if !norm.is_finite() || norm <= 0.0 {
1046        return Err(FibQuantError::CorruptPayload(
1047            "norm must be finite and positive".into(),
1048        ));
1049    }
1050    match format {
1051        NormFormat::Fp16Paper => {
1052            let narrowed = f16::from_f32(norm as f32);
1053            if !narrowed.is_finite() || narrowed <= f16::ZERO {
1054                return Err(FibQuantError::CorruptPayload(
1055                    "norm cannot be represented as finite positive fp16".into(),
1056                ));
1057            }
1058            Ok(narrowed.to_le_bytes().to_vec())
1059        }
1060        NormFormat::F32Reference => {
1061            let narrowed = norm as f32;
1062            if !narrowed.is_finite() || narrowed <= 0.0 {
1063                return Err(FibQuantError::CorruptPayload(
1064                    "norm cannot be represented as finite positive f32".into(),
1065                ));
1066            }
1067            Ok(narrowed.to_le_bytes().to_vec())
1068        }
1069    }
1070}
1071
1072fn decode_norm(bytes: &[u8], format: &NormFormat) -> Result<f64> {
1073    match format {
1074        NormFormat::Fp16Paper => {
1075            let bytes: [u8; 2] = bytes
1076                .try_into()
1077                .map_err(|_| FibQuantError::CorruptPayload("fp16 norm length".into()))?;
1078            let value = f16::from_le_bytes(bytes).to_f32() as f64;
1079            if value.is_finite() && value > 0.0 {
1080                Ok(value)
1081            } else {
1082                Err(FibQuantError::CorruptPayload("invalid fp16 norm".into()))
1083            }
1084        }
1085        NormFormat::F32Reference => {
1086            let bytes: [u8; 4] = bytes
1087                .try_into()
1088                .map_err(|_| FibQuantError::CorruptPayload("f32 norm length".into()))?;
1089            let value = f32::from_le_bytes(bytes) as f64;
1090            if value.is_finite() && value > 0.0 {
1091                Ok(value)
1092            } else {
1093                Err(FibQuantError::CorruptPayload("invalid f32 norm".into()))
1094            }
1095        }
1096    }
1097}
1098
1099fn l2_norm(x: &[f32]) -> f64 {
1100    x.iter()
1101        .map(|value| {
1102            let value = f64::from(*value);
1103            value * value
1104        })
1105        .sum::<f64>()
1106        .sqrt()
1107}
1108
1109fn check_finite(x: &[f32]) -> Result<()> {
1110    if let Some((idx, _)) = x.iter().enumerate().find(|(_, value)| !value.is_finite()) {
1111        return Err(FibQuantError::NonFiniteInput(idx));
1112    }
1113    Ok(())
1114}
1115
1116#[cfg(test)]
1117mod tests {
1118    use super::*;
1119
1120    #[test]
1121    fn f32_norm_overflow_rejects_before_payload_emit() {
1122        let err = encode_norm(f64::MAX, &NormFormat::F32Reference).unwrap_err();
1123        assert!(matches!(err, FibQuantError::CorruptPayload(message) if message.contains("f32")));
1124    }
1125
1126    #[test]
1127    fn f32_norm_underflow_rejects_before_payload_emit() {
1128        let err = encode_norm(
1129            f64::from(f32::from_bits(1)) / 2.0,
1130            &NormFormat::F32Reference,
1131        )
1132        .unwrap_err();
1133        assert!(matches!(err, FibQuantError::CorruptPayload(message) if message.contains("f32")));
1134    }
1135}
1136
1137/// Per-step GPU dispatch report.
1138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1139pub struct GpuStepReport {
1140    /// Hadamard rotation would dispatch to GPU.
1141    pub hadamard: bool,
1142    /// Nearest-codebook index lookup would also dispatch to GPU.
1143    pub codebook_lookup: bool,
1144}