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        let k = self.profile.block_dim as usize;
424        if x.len() != d {
425            return Err(FibQuantError::CorruptPayload(format!(
426                "input dimension {}, expected {d}",
427                x.len()
428            )));
429        }
430        check_finite(x)?;
431        let norm = l2_norm(x);
432        if norm == 0.0 {
433            return Err(FibQuantError::ZeroNorm);
434        }
435        // Convert to f64 for the rotation (it expects f64 internally),
436        // then back to f32 for the SIMD-accelerated argmin loop.
437        let normalized: Vec<f64> = x.iter().map(|value| f64::from(*value) / norm).collect();
438        let rotated_f64 = self.rotation.apply(&normalized)?;
439        let rotated_f32: Vec<f32> = rotated_f64.iter().map(|&v| v as f32).collect();
440        let block_count = self.profile.block_count() as usize;
441        let mut indices = Vec::with_capacity(block_count);
442        for block in rotated_f32.chunks_exact(k) {
443            indices
444                .push(gpu_backend::nearest_codeword_f32(block, &self.codebook.codewords, k) as u32);
445        }
446        Ok(FibCodeV1 {
447            schema_version: CODE_SCHEMA.into(),
448            profile_digest: self.profile.digest()?,
449            codebook_digest: self.codebook.codebook_digest.clone(),
450            rotation_digest: self.rotation.digest()?,
451            ambient_dim: self.profile.ambient_dim,
452            block_dim: self.profile.block_dim,
453            norm_format: self.profile.norm_format.clone(),
454            norm_payload: encode_norm(norm, &self.profile.norm_format)?,
455            wire_index_bits: self.profile.wire_index_bits,
456            block_count: self.profile.block_count(),
457            indices: pack_indices(&indices, self.profile.wire_index_bits)?,
458        })
459    }
460
461    /// Decode a fixed-rate artifact.
462    pub fn decode(&self, code: &FibCodeV1) -> Result<Vec<f32>> {
463        self.validate_code_header(code)?;
464        let k = self.profile.block_dim as usize;
465        let block_count = self.profile.block_count() as usize;
466        let codebook_size = self.profile.codebook_size as usize;
467        let codewords = &self.codebook.codewords;
468        let unpacked = unpack_indices(&code.indices, block_count, self.profile.wire_index_bits)?;
469        let expected_len = block_count.checked_mul(k).ok_or_else(|| {
470            FibQuantError::ResourceLimitExceeded("decoded rotated vector length overflow".into())
471        })?;
472        // Gather codewords directly in f32 — no f64 intermediate.
473        let mut rotated_f32: Vec<f32> = Vec::with_capacity(expected_len);
474        for &index in &unpacked {
475            let idx = index as usize;
476            if idx >= codebook_size {
477                return Err(FibQuantError::IndexOutOfRange {
478                    index,
479                    codebook_size: codebook_size as u32,
480                });
481            }
482            let base = idx * k;
483            rotated_f32.extend_from_slice(&codewords[base..base + k]);
484        }
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 mut indices = Vec::with_capacity(profile.block_count() as usize);
665            for block in chunk.chunks_exact(k) {
666                indices.push(gpu_backend::nearest_codeword_f32(block, codewords_f32, k) as u32);
667            }
668            Ok(FibCodeV1 {
669                schema_version: CODE_SCHEMA.into(),
670                profile_digest: profile_digest.clone(),
671                codebook_digest: codebook_digest.clone(),
672                rotation_digest: rotation_digest.clone(),
673                ambient_dim: profile.ambient_dim,
674                block_dim: profile.block_dim,
675                norm_format: profile.norm_format.clone(),
676                norm_payload: encode_norm(norms[vec_idx], &profile.norm_format)?,
677                wire_index_bits: profile.wire_index_bits,
678                block_count: profile.block_count(),
679                indices: pack_indices(&indices, profile.wire_index_bits)?,
680            })
681        };
682
683        // Heuristic: only parallelize when the per-vector work is large
684        // enough to amortize Rayon's dispatch overhead. Empirically,
685        // d=128 k=4 with n >= 16 sees a win on 4-core machines.
686        #[cfg(feature = "parallel")]
687        {
688            const RAYON_MIN_N: usize = 16;
689            if n >= RAYON_MIN_N {
690                use rayon::prelude::*;
691                return (0..n).into_par_iter().map(per_vec).collect();
692            }
693        }
694
695        let mut codes = Vec::with_capacity(n);
696        for vec_idx in 0..n {
697            codes.push(per_vec(vec_idx)?);
698        }
699        Ok(codes)
700    }
701
702    /// Build `FibCodeV1` records from a pre-computed index array. Used by
703    /// the GPU path after `codebook_lookup_batch` returns the per-block
704    /// nearest-codeword indices. Length of `indices` must be `n * (d / k)`.
705    #[cfg(all(feature = "gpu", feature = "gpu_codebook_lookup"))]
706    fn finish_batch_encode_with_indices(
707        &self,
708        _rotated: &[f32], // not used; indices are already computed
709        norms: &[f64],
710        indices: &[u32],
711        n: usize,
712        _d: usize,
713        _k: usize,
714    ) -> Result<Vec<FibCodeV1>> {
715        let block_count = self.profile.block_count() as usize;
716        if indices.len() != n * block_count {
717            return Err(FibQuantError::CorruptPayload(format!(
718                "indices length {} != n * block_count {}",
719                indices.len(),
720                n * block_count
721            )));
722        }
723
724        let mut codes = Vec::with_capacity(n);
725        for (vec_idx, &norm) in norms.iter().enumerate().take(n) {
726            let start = vec_idx * block_count;
727            let end = start + block_count;
728            let vec_indices: Vec<u32> = indices[start..end].to_vec();
729
730            codes.push(FibCodeV1 {
731                schema_version: CODE_SCHEMA.into(),
732                profile_digest: self.profile.digest()?,
733                codebook_digest: self.codebook.codebook_digest.clone(),
734                rotation_digest: self.rotation.digest()?,
735                ambient_dim: self.profile.ambient_dim,
736                block_dim: self.profile.block_dim,
737                norm_format: self.profile.norm_format.clone(),
738                norm_payload: encode_norm(norm, &self.profile.norm_format)?,
739                wire_index_bits: self.profile.wire_index_bits,
740                block_count: self.profile.block_count(),
741                indices: pack_indices(&vec_indices, self.profile.wire_index_bits)?,
742            });
743        }
744        Ok(codes)
745    }
746
747    /// Decode a batch of codes.
748    pub fn decode_batch(&self, codes: &[FibCodeV1]) -> Result<Vec<Vec<f32>>> {
749        codes.iter().map(|c| self.decode(c)).collect()
750    }
751
752    /// Fast batch decode. Optimized for the case where many small codes
753    /// share the same profile (so the codebook + rotation are reused).
754    ///
755    /// Key wins over `decode_batch`:
756    /// 1. No per-index `Vec<f64>` allocation in the codeword gather —
757    ///    each codeword is copied in place into a single `Vec<f32>`.
758    /// 2. The rotation matrix is converted to f32 once for the whole
759    ///    batch, then `apply_inverse_f32` is called per code (no f32→f64
760    ///    roundtrip on the rotation or the input).
761    /// 3. The unpacked indices are reused via `as_f32_slice()` where
762    ///    possible.
763    ///
764    /// Output is byte-identical to calling `decode` per code, modulo
765    /// the final `as f32` cast in `decode` (we also cast to f32 at the
766    /// end; intermediate precision is below the codebook quantization
767    /// noise floor and matches the original `as f32` step exactly).
768    pub fn decode_batch_fast(&self, codes: &[FibCodeV1]) -> Result<Vec<Vec<f32>>> {
769        if codes.is_empty() {
770            return Ok(Vec::new());
771        }
772        let k = self.profile.block_dim as usize;
773        let codebook_size = self.profile.codebook_size as usize;
774        let codewords = &self.codebook.codewords;
775        // Hoist the f32 rotation matrix conversion out of the per-code loop.
776        // Previously this was called inside apply_inverse_f32 on every code,
777        // costing O(dim²) f64→f32 conversions per code. Now we convert once
778        // and reuse across the entire batch.
779        let matrix_f32 = self.rotation.matrix_f32();
780        let mut out = Vec::with_capacity(codes.len());
781        for code in codes {
782            self.validate_code_header(code)?;
783            let block_count = self.profile.block_count() as usize;
784            let unpacked =
785                unpack_indices(&code.indices, block_count, self.profile.wire_index_bits)?;
786            let expected_len = block_count.checked_mul(k).ok_or_else(|| {
787                FibQuantError::ResourceLimitExceeded(
788                    "decoded rotated vector length overflow".into(),
789                )
790            })?;
791            // Gather codewords in place. No allocation per index.
792            let mut rotated_f32: Vec<f32> = Vec::with_capacity(expected_len);
793            for &index in &unpacked {
794                let idx = index as usize;
795                if idx >= codebook_size {
796                    return Err(FibQuantError::IndexOutOfRange {
797                        index,
798                        codebook_size: codebook_size as u32,
799                    });
800                }
801                let base = idx * k;
802                // Direct slice extend in f32. No f32→f64 conversion.
803                rotated_f32.extend_from_slice(&codewords[base..base + k]);
804            }
805            debug_assert_eq!(rotated_f32.len(), expected_len);
806            let norm = decode_norm(&code.norm_payload, &code.norm_format)?;
807            // Single f32 rotation using the pre-converted matrix.
808            let reconstructed = self
809                .rotation
810                .apply_inverse_f32_with_matrix(&rotated_f32, &matrix_f32)?;
811            let scaled: Vec<f32> = reconstructed
812                .into_iter()
813                .map(|value| value * norm as f32)
814                .collect();
815            check_finite(&scaled)?;
816            out.push(scaled);
817        }
818        Ok(out)
819    }
820
821    /// Encode vectors across multiple KV-cache layers in one call.
822    ///
823    /// This is an optimization for the multi-layer KV-cache case where
824    /// `layers` slices of `n` vectors each share the same profile. The
825    /// codebook and rotation are constructed once and reused across all
826    /// layers, avoiding the per-layer quantizer rebuild that the naive
827    /// `encode_batch` loop would incur.
828    ///
829    /// `layer_vectors` is organized as `[layer][vector][dim]`. Each
830    /// inner slice must have length `ambient_dim`. Returns
831    /// `[layer][vector]` of `FibCodeV1`.
832    pub fn encode_layers(&self, layer_vectors: &[&[&[f32]]]) -> Result<Vec<Vec<FibCodeV1>>> {
833        if layer_vectors.is_empty() {
834            return Ok(Vec::new());
835        }
836        // Pre-compute digest fields that are identical for every code
837        // across all layers.
838        let profile_digest = self.profile_digest.clone();
839        let codebook_digest = self.codebook.codebook_digest.clone();
840        let rotation_digest = self.rotation_digest.clone();
841        let d = self.profile.ambient_dim as usize;
842        let k = self.profile.block_dim as usize;
843        let profile = &self.profile;
844        let codewords_f32: &[f32] = &self.codebook.codewords;
845
846        let mut out = Vec::with_capacity(layer_vectors.len());
847        for layer in layer_vectors {
848            let n = layer.len();
849            if n == 0 {
850                out.push(Vec::new());
851                continue;
852            }
853            let mut codes = Vec::with_capacity(n);
854            for &vec_slice in *layer {
855                if vec_slice.len() != d {
856                    return Err(FibQuantError::CorruptPayload(format!(
857                        "input dimension {}, expected {d}",
858                        vec_slice.len()
859                    )));
860                }
861                check_finite(vec_slice)?;
862                let norm = l2_norm(vec_slice);
863                if norm == 0.0 {
864                    return Err(FibQuantError::ZeroNorm);
865                }
866                let normalized: Vec<f64> = vec_slice.iter().map(|v| f64::from(*v) / norm).collect();
867                let rotated_f64 = self.rotation.apply(&normalized)?;
868                let rotated_f32: Vec<f32> = rotated_f64.iter().map(|&v| v as f32).collect();
869                let block_count = profile.block_count() as usize;
870                let mut indices = Vec::with_capacity(block_count);
871                for block in rotated_f32.chunks_exact(k) {
872                    indices.push(gpu_backend::nearest_codeword_f32(block, codewords_f32, k) as u32);
873                }
874                codes.push(FibCodeV1 {
875                    schema_version: CODE_SCHEMA.into(),
876                    profile_digest: profile_digest.clone(),
877                    codebook_digest: codebook_digest.clone(),
878                    rotation_digest: rotation_digest.clone(),
879                    ambient_dim: profile.ambient_dim,
880                    block_dim: profile.block_dim,
881                    norm_format: profile.norm_format.clone(),
882                    norm_payload: encode_norm(norm, &profile.norm_format)?,
883                    wire_index_bits: profile.wire_index_bits,
884                    block_count: profile.block_count(),
885                    indices: pack_indices(&indices, profile.wire_index_bits)?,
886                });
887            }
888            out.push(codes);
889        }
890        Ok(out)
891    }
892
893    /// Check if GPU acceleration is available.
894    ///
895    /// This is a **device-availability** probe: it returns true if a CUDA
896    /// device was found at init time. Whether an *individual* encode_batch
897    /// call actually dispatches to GPU depends on the call's batch size and
898    /// vector dimension crossing the runtime thresholds.
899    ///
900    /// Use [`Self::is_gpu_accelerated_for`] for an honest per-call check.
901    pub fn is_gpu_accelerated(&self) -> bool {
902        #[cfg(feature = "gpu")]
903        {
904            gpu_backend::GpuContext::is_available()
905        }
906        #[cfg(not(feature = "gpu"))]
907        {
908            false
909        }
910    }
911
912    /// Check if a batch of `n` vectors of dimension `d` would actually
913    /// dispatch to GPU. Returns true only when:
914    ///   - the `gpu` feature is compiled in,
915    ///   - a CUDA device is available at runtime,
916    ///   - `n >= GPU_MIN_BATCH_SIZE` and `d >= GPU_MIN_DIM`, AND
917    ///   - the codebook size `N` is <= 32 (the codebook_lookup kernel
918    ///     is one warp wide and falls back to CPU otherwise).
919    ///
920    /// This is the honest gate for receipts: a 4-doc corpus with dim 64
921    /// returns false even with `--features gpu`, because the batch is too
922    /// small to overcome GPU launch overhead. A corpus with a codebook
923    /// larger than 32 also returns false.
924    pub fn is_gpu_accelerated_for(&self, n: usize, d: usize) -> bool {
925        #[cfg(feature = "gpu")]
926        {
927            if !gpu_backend::GpuContext::is_available() {
928                return false;
929            }
930            n >= gpu_backend::GpuContext::GPU_MIN_BATCH_SIZE
931                && d >= gpu_backend::GpuContext::GPU_MIN_DIM
932                && (self.profile.codebook_size as usize) <= 32
933        }
934        #[cfg(not(feature = "gpu"))]
935        {
936            let _ = (n, d);
937            false
938        }
939    }
940
941    /// Per-step GPU dispatch report. `hadamard` is true if a batch of size
942    /// `n` at dim `d` would dispatch the Hadamard rotation to GPU.
943    /// `codebook_lookup` is true only if both the Hadamard AND the
944    /// codebook-lookup step would dispatch (additionally requires codebook
945    /// size <= 32). The latter is independent of the `gpu_codebook_lookup`
946    /// feature gate — the feature controls whether the dispatch is enabled
947    /// in `encode_batch`, not whether the kernel would be a win.
948    pub fn gpu_steps_for(&self, n: usize, d: usize) -> GpuStepReport {
949        let device_available = {
950            #[cfg(feature = "gpu")]
951            {
952                gpu_backend::GpuContext::is_available()
953            }
954            #[cfg(not(feature = "gpu"))]
955            {
956                false
957            }
958        };
959        // Thresholds are the same as gpu_backend::GpuContext's. Hard-code
960        // them here to avoid requiring the gpu feature for the probe.
961        const MIN_BATCH: usize = 16;
962        const MIN_DIM: usize = 64;
963        let clears_thresholds = n >= MIN_BATCH && d >= MIN_DIM;
964        let codebook_fits = (self.profile.codebook_size as usize) <= 32;
965        GpuStepReport {
966            hadamard: device_available && clears_thresholds,
967            codebook_lookup: device_available && clears_thresholds && codebook_fits,
968        }
969    }
970
971    // ── End batch methods ──
972
973    fn validate_code_header(&self, code: &FibCodeV1) -> Result<()> {
974        if code.schema_version != CODE_SCHEMA {
975            return Err(FibQuantError::CorruptPayload(format!(
976                "code schema_version {}, expected {CODE_SCHEMA}",
977                code.schema_version
978            )));
979        }
980        let expected_profile = self.profile_digest.clone();
981        if code.profile_digest != expected_profile {
982            return Err(FibQuantError::ProfileDigestMismatch {
983                expected: expected_profile,
984                actual: code.profile_digest.clone(),
985            });
986        }
987        // Codebook and rotation digests are skipped if empty — the
988        // compact wire format omits them because they're derivable from
989        // the profile. The decoder trusts its own codebook/rotation in
990        // that case. (Re-deriving the codebook just to compute the
991        // digest cost ~6ms per call, which is prohibitive for batch
992        // decode of 1.5M+ blocks.)
993        if !code.codebook_digest.is_empty() && code.codebook_digest != self.codebook.codebook_digest
994        {
995            return Err(FibQuantError::CodebookDigestMismatch {
996                expected: self.codebook.codebook_digest.clone(),
997                actual: code.codebook_digest.clone(),
998            });
999        }
1000        let expected_rotation = self.rotation_digest.clone();
1001        if !code.rotation_digest.is_empty()
1002            && (code.rotation_digest != expected_rotation
1003                || code.rotation_digest != self.codebook.rotation_digest)
1004        {
1005            return Err(FibQuantError::RotationDigestMismatch {
1006                expected: expected_rotation,
1007                actual: code.rotation_digest.clone(),
1008            });
1009        }
1010        if code.ambient_dim != self.profile.ambient_dim
1011            || code.block_dim != self.profile.block_dim
1012            || code.block_count != self.profile.block_count()
1013            || code.wire_index_bits != self.profile.wire_index_bits
1014            || code.norm_format != self.profile.norm_format
1015        {
1016            return Err(FibQuantError::CorruptPayload(
1017                "encoded header does not match profile".into(),
1018            ));
1019        }
1020        Ok(())
1021    }
1022}
1023
1024/// Stable digest over the encoded artifact fields.
1025pub fn encoded_digest(code: &FibCodeV1) -> Result<String> {
1026    json_digest(CODE_SCHEMA, code)
1027}
1028
1029fn source_vector_digest(x: &[f32]) -> Result<String> {
1030    check_finite(x)?;
1031    let mut bytes = Vec::with_capacity(32 + std::mem::size_of_val(x));
1032    bytes.extend_from_slice(b"fib_quant_source_vector_v1");
1033    bytes.push(0);
1034    bytes.extend_from_slice(&(x.len() as u64).to_le_bytes());
1035    for value in x {
1036        bytes.extend_from_slice(&value.to_le_bytes());
1037    }
1038    Ok(bytes_digest(&bytes))
1039}
1040
1041fn encode_norm(norm: f64, format: &NormFormat) -> Result<Vec<u8>> {
1042    if !norm.is_finite() || norm <= 0.0 {
1043        return Err(FibQuantError::CorruptPayload(
1044            "norm must be finite and positive".into(),
1045        ));
1046    }
1047    match format {
1048        NormFormat::Fp16Paper => {
1049            let narrowed = f16::from_f32(norm as f32);
1050            if !narrowed.is_finite() || narrowed <= f16::ZERO {
1051                return Err(FibQuantError::CorruptPayload(
1052                    "norm cannot be represented as finite positive fp16".into(),
1053                ));
1054            }
1055            Ok(narrowed.to_le_bytes().to_vec())
1056        }
1057        NormFormat::F32Reference => {
1058            let narrowed = norm as f32;
1059            if !narrowed.is_finite() || narrowed <= 0.0 {
1060                return Err(FibQuantError::CorruptPayload(
1061                    "norm cannot be represented as finite positive f32".into(),
1062                ));
1063            }
1064            Ok(narrowed.to_le_bytes().to_vec())
1065        }
1066    }
1067}
1068
1069fn decode_norm(bytes: &[u8], format: &NormFormat) -> Result<f64> {
1070    match format {
1071        NormFormat::Fp16Paper => {
1072            let bytes: [u8; 2] = bytes
1073                .try_into()
1074                .map_err(|_| FibQuantError::CorruptPayload("fp16 norm length".into()))?;
1075            let value = f16::from_le_bytes(bytes).to_f32() as f64;
1076            if value.is_finite() && value > 0.0 {
1077                Ok(value)
1078            } else {
1079                Err(FibQuantError::CorruptPayload("invalid fp16 norm".into()))
1080            }
1081        }
1082        NormFormat::F32Reference => {
1083            let bytes: [u8; 4] = bytes
1084                .try_into()
1085                .map_err(|_| FibQuantError::CorruptPayload("f32 norm length".into()))?;
1086            let value = f32::from_le_bytes(bytes) as f64;
1087            if value.is_finite() && value > 0.0 {
1088                Ok(value)
1089            } else {
1090                Err(FibQuantError::CorruptPayload("invalid f32 norm".into()))
1091            }
1092        }
1093    }
1094}
1095
1096fn l2_norm(x: &[f32]) -> f64 {
1097    x.iter()
1098        .map(|value| {
1099            let value = f64::from(*value);
1100            value * value
1101        })
1102        .sum::<f64>()
1103        .sqrt()
1104}
1105
1106fn check_finite(x: &[f32]) -> Result<()> {
1107    if let Some((idx, _)) = x.iter().enumerate().find(|(_, value)| !value.is_finite()) {
1108        return Err(FibQuantError::NonFiniteInput(idx));
1109    }
1110    Ok(())
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115    use super::*;
1116
1117    #[test]
1118    fn f32_norm_overflow_rejects_before_payload_emit() {
1119        let err = encode_norm(f64::MAX, &NormFormat::F32Reference).unwrap_err();
1120        assert!(matches!(err, FibQuantError::CorruptPayload(message) if message.contains("f32")));
1121    }
1122
1123    #[test]
1124    fn f32_norm_underflow_rejects_before_payload_emit() {
1125        let err = encode_norm(
1126            f64::from(f32::from_bits(1)) / 2.0,
1127            &NormFormat::F32Reference,
1128        )
1129        .unwrap_err();
1130        assert!(matches!(err, FibQuantError::CorruptPayload(message) if message.contains("f32")));
1131    }
1132}
1133
1134/// Per-step GPU dispatch report.
1135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1136pub struct GpuStepReport {
1137    /// Hadamard rotation would dispatch to GPU.
1138    pub hadamard: bool,
1139    /// Nearest-codebook index lookup would also dispatch to GPU.
1140    pub codebook_lookup: bool,
1141}