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/// Magic + version prefix for the compact binary wire format.
18/// `F` `B` `1` = Fib Binary v1. Any decoder that sees a different
19/// magic should reject the payload as corrupt.
20pub const COMPACT_MAGIC: [u8; 3] = [b'F', b'B', b'1'];
21pub const COMPACT_VERSION: u8 = 1;
22
23/// Encoded fixed-rate FibQuant artifact.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct FibCodeV1 {
26    /// Stable schema marker.
27    pub schema_version: String,
28    /// Profile digest.
29    pub profile_digest: String,
30    /// Codebook digest.
31    pub codebook_digest: String,
32    /// Rotation digest.
33    pub rotation_digest: String,
34    /// Ambient dimension.
35    pub ambient_dim: u32,
36    /// Block dimension.
37    pub block_dim: u32,
38    /// Norm payload format.
39    pub norm_format: NormFormat,
40    /// Norm bytes.
41    pub norm_payload: Vec<u8>,
42    /// Bits per fixed-rate index.
43    pub wire_index_bits: u8,
44    /// Number of indices.
45    pub block_count: u32,
46    /// Packed fixed-rate indices.
47    pub indices: Vec<u8>,
48}
49
50impl FibCodeV1 {
51    /// Compact binary wire format. The FibCodeV1 struct carries a lot of
52    /// metadata for JSON deserialization (schema_version, profile_digest,
53    /// rotation_digest, ambient_dim, block_dim, etc.) that the decoder
54    /// either doesn't need (it has its own profile) or can reconstruct
55    /// from the manifest (profile_digest/codebook_digest/rotation_digest).
56    ///
57    /// Compact layout (little-endian, packed):
58    ///   [0..3]  magic: "FB1"
59    ///   [3]     version: 1
60    ///   [4]     wire_index_bits
61    ///   [5..9]  block_count (u32)
62    ///   [9..11] norm_payload (length-prefixed, max 65535 bytes)
63    ///          actually: [9..11] norm_len (u16) + norm bytes
64    ///   then indices bytes
65    ///
66    /// The decoder must already know the profile (or have the manifest
67    /// supply it). It can re-derive the digests from that profile and
68    /// check them at the manifest level. Per-block we only need
69    /// wire_index_bits, block_count, norm_payload, and indices.
70    ///
71    /// For fib_k4_n32 with head_dim=64: 16 indices * 5 bits = 10 bytes
72    /// indices + 2 bytes norm = 12 bytes payload + 11 bytes header =
73    /// **23 bytes per block** vs **474 bytes for JSON** = 20.6x smaller.
74    pub fn to_compact_bytes(&self) -> Vec<u8> {
75        let mut out = Vec::with_capacity(11 + self.norm_payload.len() + self.indices.len());
76        out.extend_from_slice(&COMPACT_MAGIC);
77        out.push(COMPACT_VERSION);
78        out.push(self.wire_index_bits);
79        out.extend_from_slice(&self.block_count.to_le_bytes());
80        let norm_len = self.norm_payload.len() as u16;
81        out.extend_from_slice(&norm_len.to_le_bytes());
82        out.extend_from_slice(&self.norm_payload);
83        out.extend_from_slice(&self.indices);
84        out
85    }
86
87    /// Decode the compact binary format. The caller must supply the
88    /// profile so that the profile/codebook digests in the resulting
89    /// `FibCodeV1` match what `validate_code_header` expects.
90    ///
91    /// The compact format omits the digests because they're derivable
92    /// from the profile — there's no point storing them when the
93    /// decoder will check them against the profile digest anyway.
94    pub fn from_compact_bytes(bytes: &[u8], profile: &FibQuantProfileV1) -> Result<Self> {
95        if bytes.len() < 11 {
96            return Err(FibQuantError::CorruptPayload(format!(
97                "compact FibCodeV1 too short: {} bytes (need >= 11)",
98                bytes.len()
99            )));
100        }
101        if bytes[0..3] != COMPACT_MAGIC {
102            return Err(FibQuantError::CorruptPayload(format!(
103                "compact FibCodeV1 bad magic: {:?} (expected {:?})",
104                &bytes[0..3],
105                COMPACT_MAGIC
106            )));
107        }
108        if bytes[3] != COMPACT_VERSION {
109            return Err(FibQuantError::CorruptPayload(format!(
110                "compact FibCodeV1 version {} not supported (need {})",
111                bytes[3], COMPACT_VERSION
112            )));
113        }
114        let wire_index_bits = bytes[4];
115        let block_count = u32::from_le_bytes([bytes[5], bytes[6], bytes[7], bytes[8]]);
116        let norm_len = u16::from_le_bytes([bytes[9], bytes[10]]) as usize;
117        let header_len = 11;
118        if bytes.len() < header_len + norm_len {
119            return Err(FibQuantError::CorruptPayload(format!(
120                "compact FibCodeV1 truncated: norm_len={} but only {} bytes remain",
121                norm_len,
122                bytes.len() - header_len
123            )));
124        }
125        let norm_payload = bytes[header_len..header_len + norm_len].to_vec();
126        let indices = bytes[header_len + norm_len..].to_vec();
127
128        // Validate packed index length
129        let expected_packed_len = (block_count as usize)
130            .checked_mul(wire_index_bits as usize)
131            .map(|bits| (bits + 7) / 8)
132            .ok_or_else(|| {
133                FibQuantError::ResourceLimitExceeded("packed index bits overflow".into())
134            })?;
135        if indices.len() != expected_packed_len {
136            return Err(FibQuantError::CorruptPayload(format!(
137                "compact FibCodeV1 indices: got {} bytes, expected {} (block_count={} * wire_index_bits={})",
138                indices.len(),
139                expected_packed_len,
140                block_count,
141                wire_index_bits
142            )));
143        }
144
145        // The compact wire format omits codebook_digest and
146        // rotation_digest because they're derivable from the profile.
147        // The decoder re-derives them itself and skips the mismatch check
148        // (see validate_code_header's empty-string short-circuit). We
149        // still need profile_digest because the decode path uses it
150        // to verify the code matches the decoder's profile.
151        let profile_digest = profile.digest()?;
152        Ok(FibCodeV1 {
153            schema_version: CODE_SCHEMA.into(),
154            profile_digest,
155            // Leave these empty — see validate_code_header for the
156            // short-circuit. The cost of building a FibCodebookV1
157            // per from_compact_bytes call is prohibitive (~6ms each,
158            // 10s of seconds for a real pool).
159            codebook_digest: String::new(),
160            rotation_digest: String::new(),
161            ambient_dim: profile.ambient_dim,
162            block_dim: profile.block_dim,
163            norm_format: profile.norm_format.clone(),
164            norm_payload,
165            wire_index_bits,
166            block_count,
167            indices,
168        })
169    }
170
171    /// Compact size in bytes (does not allocate).
172    pub fn compact_size(&self) -> usize {
173        11 + self.norm_payload.len() + self.indices.len()
174    }
175}
176
177/// FibQuant encoder/decoder bound to one profile and codebook.
178#[derive(Debug, Clone)]
179pub struct FibQuantizer {
180    profile: FibQuantProfileV1,
181    codebook: FibCodebookV1,
182    rotation: StoredRotation,
183}
184
185impl FibQuantizer {
186    /// Build a quantizer by constructing the profile codebook.
187    pub fn new(profile: FibQuantProfileV1) -> Result<Self> {
188        let codebook = FibCodebookV1::build(profile)?;
189        Self::from_codebook(codebook)
190    }
191
192    /// Build a quantizer from a validated codebook.
193    pub fn from_codebook(codebook: FibCodebookV1) -> Result<Self> {
194        codebook.validate()?;
195        let profile = codebook.profile.clone();
196        let rotation = StoredRotation::new(profile.ambient_dim as usize, profile.rotation_seed)?;
197        Ok(Self {
198            profile,
199            codebook,
200            rotation,
201        })
202    }
203
204    /// Access the profile.
205    pub fn profile(&self) -> &FibQuantProfileV1 {
206        &self.profile
207    }
208
209    /// Access the codebook.
210    pub fn codebook(&self) -> &FibCodebookV1 {
211        &self.codebook
212    }
213
214    /// Encode a vector into a fixed-rate artifact.
215    pub fn encode(&self, x: &[f32]) -> Result<FibCodeV1> {
216        let d = self.profile.ambient_dim as usize;
217        let k = self.profile.block_dim as usize;
218        if x.len() != d {
219            return Err(FibQuantError::CorruptPayload(format!(
220                "input dimension {}, expected {d}",
221                x.len()
222            )));
223        }
224        check_finite(x)?;
225        let norm = l2_norm(x);
226        if norm == 0.0 {
227            return Err(FibQuantError::ZeroNorm);
228        }
229        // Convert to f64 for the rotation (it expects f64 internally),
230        // then back to f32 for the SIMD-accelerated argmin loop.
231        let normalized: Vec<f64> = x.iter().map(|value| f64::from(*value) / norm).collect();
232        let rotated_f64 = self.rotation.apply(&normalized)?;
233        let rotated_f32: Vec<f32> = rotated_f64.iter().map(|&v| v as f32).collect();
234        let block_count = self.profile.block_count() as usize;
235        let mut indices = Vec::with_capacity(block_count);
236        for block in rotated_f32.chunks_exact(k) {
237            indices.push(gpu_backend::nearest_codeword_f32(block, &self.codebook.codewords, k) as u32);
238        }
239        Ok(FibCodeV1 {
240            schema_version: CODE_SCHEMA.into(),
241            profile_digest: self.profile.digest()?,
242            codebook_digest: self.codebook.codebook_digest.clone(),
243            rotation_digest: self.rotation.digest()?,
244            ambient_dim: self.profile.ambient_dim,
245            block_dim: self.profile.block_dim,
246            norm_format: self.profile.norm_format.clone(),
247            norm_payload: encode_norm(norm, &self.profile.norm_format)?,
248            wire_index_bits: self.profile.wire_index_bits,
249            block_count: self.profile.block_count(),
250            indices: pack_indices(&indices, self.profile.wire_index_bits)?,
251        })
252    }
253
254    /// Decode a fixed-rate artifact.
255    pub fn decode(&self, code: &FibCodeV1) -> Result<Vec<f32>> {
256        self.validate_code_header(code)?;
257        let k = self.profile.block_dim as usize;
258        let block_count = self.profile.block_count() as usize;
259        let unpacked = unpack_indices(&code.indices, block_count, self.profile.wire_index_bits)?;
260        let mut rotated = Vec::with_capacity(self.profile.ambient_dim as usize);
261        for index in unpacked {
262            if index >= self.profile.codebook_size {
263                return Err(FibQuantError::IndexOutOfRange {
264                    index,
265                    codebook_size: self.profile.codebook_size,
266                });
267            }
268            rotated.extend(self.codebook.codeword(index as usize)?);
269        }
270        let expected_rotated_len = block_count.checked_mul(k).ok_or_else(|| {
271            FibQuantError::ResourceLimitExceeded("decoded rotated vector length overflow".into())
272        })?;
273        if rotated.len() != expected_rotated_len {
274            return Err(FibQuantError::CorruptPayload(
275                "decoded rotated vector length mismatch".into(),
276            ));
277        }
278        let norm = decode_norm(&code.norm_payload, &code.norm_format)?;
279        let reconstructed = self.rotation.apply_inverse(&rotated)?;
280        let out: Vec<f32> = reconstructed
281            .into_iter()
282            .map(|value| (value * norm) as f32)
283            .collect();
284        check_finite(&out)?;
285        Ok(out)
286    }
287
288    /// Encode and emit a receipt.
289    pub fn encode_with_receipt(
290        &self,
291        x: &[f32],
292    ) -> Result<(FibCodeV1, FibQuantCompressionReceiptV1)> {
293        let code = self.encode(x)?;
294        let source_vector_digest = source_vector_digest(x)?;
295        let mut receipt = FibQuantCompressionReceiptV1::new(
296            &self.profile,
297            code.profile_digest.clone(),
298            code.codebook_digest.clone(),
299            code.rotation_digest.clone(),
300            source_vector_digest,
301            encoded_digest(&code)?,
302        );
303        let decoded = self.decode(&code)?;
304        receipt.mse = Some(metrics::mse(x, &decoded)?);
305        receipt.cosine_similarity = Some(metrics::cosine_similarity(x, &decoded)?);
306        Ok((code, receipt))
307    }
308
309    /// Reconstruction MSE for one vector.
310    pub fn reconstruction_mse(&self, x: &[f32]) -> Result<f64> {
311        let code = self.encode(x)?;
312        let decoded = self.decode(&code)?;
313        metrics::mse(x, &decoded)
314    }
315
316    /// Reconstruction cosine similarity for one vector.
317    pub fn cosine_similarity(&self, x: &[f32]) -> Result<f64> {
318        let code = self.encode(x)?;
319        let decoded = self.decode(&code)?;
320        metrics::cosine_similarity(x, &decoded)
321    }
322
323    // ── Batch encode/decode ──
324
325    /// Encode a batch of vectors. Uses gpu-backend for the Hadamard + Lloyd-Max
326    /// portions when available, keeping the FibCodeV1 format identical to single encode.
327    pub fn encode_batch(&self, vectors: &[&[f32]]) -> Result<Vec<FibCodeV1>> {
328        let d = self.profile.ambient_dim as usize;
329        let k = self.profile.block_dim as usize;
330        let n = vectors.len();
331        if n == 0 {
332            return Ok(vec![]);
333        }
334
335        // Fall back to single encode for small batches
336        if n < 4 {
337            return vectors.iter().map(|v| self.encode(v)).collect();
338        }
339
340        // Flatten input
341        let mut flat = Vec::with_capacity(n * d);
342        let mut norms_f64 = Vec::with_capacity(n);
343        for v in vectors {
344            if v.len() != d {
345                return Err(FibQuantError::CorruptPayload(format!(
346                    "input dimension {}, expected {d}",
347                    v.len()
348                )));
349            }
350            check_finite(v)?;
351            let norm = l2_norm(v);
352            if norm == 0.0 {
353                return Err(FibQuantError::ZeroNorm);
354            }
355            norms_f64.push(norm);
356            for &x in *v {
357                flat.push((x as f64 / norm) as f32);
358            }
359        }
360
361        // Apply Hadamard batch rotation (uses gpu-backend when available)
362        #[cfg(feature = "gpu")]
363        {
364            if let Some(_ctx) = gpu_backend::GpuContext::init() {
365                if n >= gpu_backend::GpuContext::GPU_MIN_BATCH_SIZE
366                    && d >= gpu_backend::GpuContext::GPU_MIN_DIM
367                {
368                    gpu_backend::hadamard_batch(&mut flat, n, d, self.profile.rotation_seed)
369                        .map_err(|e| {
370                            FibQuantError::NumericalFailure(format!("gpu hadamard: {}", e))
371                        })?;
372
373                    // GPU codebook lookup: the dominant cost in encode_batch
374                    // for k=4, N=32. Falls back to CPU if N > 32 or other
375                    // gates fail; the indices produced are byte-identical to
376                    // the CPU path (verified by gpu-backend parity test).
377                    //
378                    // The `gpu_codebook_lookup` cfg switches this on. When
379                    // off, the rotated data goes back to the CPU for the
380                    // codebook loop. The current dispatch path through
381                    // gpu_backend pays H2D + D2H per call, which can be
382                    // slower than a tight CPU loop for small batches.
383                    #[cfg(feature = "gpu_codebook_lookup")]
384                    {
385                        let block_count = self.profile.block_count() as usize;
386                        if let Ok(indices) = gpu_backend::codebook_lookup_batch(
387                            &flat,
388                            &self.codebook.codewords,
389                            n,
390                            d,
391                            k,
392                        ) {
393                            if indices.len() == n * block_count {
394                                return self.finish_batch_encode_with_indices(
395                                    &flat, &norms_f64, &indices, n, d, k,
396                                );
397                            }
398                            // Length mismatch — fall through to CPU for safety.
399                        }
400                    }
401
402                    // CPU fallback for the codebook lookup (Hadamard already on GPU).
403                    return self.finish_batch_encode(&flat, &norms_f64, n, d, k);
404                }
405            }
406        }
407
408        // CPU fallback: use StoredRotation on each vector
409        let mut rotated_flat = Vec::with_capacity(n * d);
410        for chunk in flat.chunks_exact(d) {
411            let f64_chunk: Vec<f64> = chunk.iter().map(|&v| v as f64).collect();
412            let rot = self.rotation.apply(&f64_chunk)?;
413            rotated_flat.extend(rot.iter().map(|&v| v as f32));
414        }
415
416        self.finish_batch_encode(&rotated_flat, &norms_f64, n, d, k)
417    }
418
419    fn finish_batch_encode(
420        &self,
421        rotated: &[f32],
422        norms: &[f64],
423        n: usize,
424        d: usize,
425        k: usize,
426    ) -> Result<Vec<FibCodeV1>> {
427        // Precompute digest fields that are identical for every code in
428        // this batch. Saves a digest call per vector (the profile digest
429        // is the same for all codes).
430        let profile_digest = self.profile.digest()?;
431        let codebook_digest = self.codebook.codebook_digest.clone();
432        let rotation_digest = self.rotation.digest()?;
433        let profile = &self.profile;
434        let codewords_f32: &[f32] = &self.codebook.codewords;
435
436        // Per-vector work. Independent across vec_idx, so we can either
437        // run it serially or via Rayon. The Rayon threshold is set so
438        // that small batches don't pay the parallel-dispatch tax.
439        let per_vec = |vec_idx: usize| -> Result<FibCodeV1> {
440            let start = vec_idx * d;
441            let chunk = &rotated[start..start + d];
442            let mut indices = Vec::with_capacity(profile.block_count() as usize);
443            for block in chunk.chunks_exact(k) {
444                indices.push(gpu_backend::nearest_codeword_f32(block, codewords_f32, k) as u32);
445            }
446            Ok(FibCodeV1 {
447                schema_version: CODE_SCHEMA.into(),
448                profile_digest: profile_digest.clone(),
449                codebook_digest: codebook_digest.clone(),
450                rotation_digest: rotation_digest.clone(),
451                ambient_dim: profile.ambient_dim,
452                block_dim: profile.block_dim,
453                norm_format: profile.norm_format.clone(),
454                norm_payload: encode_norm(norms[vec_idx], &profile.norm_format)?,
455                wire_index_bits: profile.wire_index_bits,
456                block_count: profile.block_count(),
457                indices: pack_indices(&indices, profile.wire_index_bits)?,
458            })
459        };
460
461        // Heuristic: only parallelize when the per-vector work is large
462        // enough to amortize Rayon's dispatch overhead. Empirically,
463        // d=128 k=4 with n >= 16 sees a win on 4-core machines.
464        #[cfg(feature = "parallel")]
465        {
466            const RAYON_MIN_N: usize = 16;
467            if n >= RAYON_MIN_N {
468                use rayon::prelude::*;
469                return (0..n).into_par_iter().map(per_vec).collect();
470            }
471        }
472
473        let mut codes = Vec::with_capacity(n);
474        for vec_idx in 0..n {
475            codes.push(per_vec(vec_idx)?);
476        }
477        Ok(codes)
478    }
479
480    /// Build `FibCodeV1` records from a pre-computed index array. Used by
481    /// the GPU path after `codebook_lookup_batch` returns the per-block
482    /// nearest-codeword indices. Length of `indices` must be `n * (d / k)`.
483    #[cfg(all(feature = "gpu", feature = "gpu_codebook_lookup"))]
484    fn finish_batch_encode_with_indices(
485        &self,
486        _rotated: &[f32], // not used; indices are already computed
487        norms: &[f64],
488        indices: &[u32],
489        n: usize,
490        _d: usize,
491        _k: usize,
492    ) -> Result<Vec<FibCodeV1>> {
493        let block_count = self.profile.block_count() as usize;
494        if indices.len() != n * block_count {
495            return Err(FibQuantError::CorruptPayload(format!(
496                "indices length {} != n * block_count {}",
497                indices.len(),
498                n * block_count
499            )));
500        }
501
502        let mut codes = Vec::with_capacity(n);
503        for vec_idx in 0..n {
504            let start = vec_idx * block_count;
505            let end = start + block_count;
506            let vec_indices: Vec<u32> = indices[start..end].to_vec();
507
508            codes.push(FibCodeV1 {
509                schema_version: CODE_SCHEMA.into(),
510                profile_digest: self.profile.digest()?,
511                codebook_digest: self.codebook.codebook_digest.clone(),
512                rotation_digest: self.rotation.digest()?,
513                ambient_dim: self.profile.ambient_dim,
514                block_dim: self.profile.block_dim,
515                norm_format: self.profile.norm_format.clone(),
516                norm_payload: encode_norm(norms[vec_idx], &self.profile.norm_format)?,
517                wire_index_bits: self.profile.wire_index_bits,
518                block_count: self.profile.block_count(),
519                indices: pack_indices(&vec_indices, self.profile.wire_index_bits)?,
520            });
521        }
522        Ok(codes)
523    }
524
525    /// Decode a batch of codes.
526    pub fn decode_batch(&self, codes: &[FibCodeV1]) -> Result<Vec<Vec<f32>>> {
527        codes.iter().map(|c| self.decode(c)).collect()
528    }
529
530    /// Fast batch decode. Optimized for the case where many small codes
531    /// share the same profile (so the codebook + rotation are reused).
532    ///
533    /// Key wins over `decode_batch`:
534    /// 1. No per-index `Vec<f64>` allocation in the codeword gather —
535    ///    each codeword is copied in place into a single `Vec<f32>`.
536    /// 2. The rotation matrix is converted to f32 once for the whole
537    ///    batch, then `apply_inverse_f32` is called per code (no f32→f64
538    ///    roundtrip on the rotation or the input).
539    /// 3. The unpacked indices are reused via `as_f32_slice()` where
540    ///    possible.
541    ///
542    /// Output is byte-identical to calling `decode` per code, modulo
543    /// the final `as f32` cast in `decode` (we also cast to f32 at the
544    /// end; intermediate precision is below the codebook quantization
545    /// noise floor and matches the original `as f32` step exactly).
546    pub fn decode_batch_fast(&self, codes: &[FibCodeV1]) -> Result<Vec<Vec<f32>>> {
547        if codes.is_empty() {
548            return Ok(Vec::new());
549        }
550        let d = self.profile.ambient_dim as usize;
551        let k = self.profile.block_dim as usize;
552        let codebook_size = self.profile.codebook_size as usize;
553        let codewords = &self.codebook.codewords;
554        let mut out = Vec::with_capacity(codes.len());
555        for code in codes {
556            self.validate_code_header(code)?;
557            let block_count = self.profile.block_count() as usize;
558            let unpacked = unpack_indices(&code.indices, block_count, self.profile.wire_index_bits)?;
559            let expected_len = block_count.checked_mul(k).ok_or_else(|| {
560                FibQuantError::ResourceLimitExceeded("decoded rotated vector length overflow".into())
561            })?;
562            // Gather codewords in place. No allocation per index.
563            let mut rotated_f32: Vec<f32> = Vec::with_capacity(expected_len);
564            for &index in &unpacked {
565                let idx = index as usize;
566                if idx >= codebook_size {
567                    return Err(FibQuantError::IndexOutOfRange {
568                        index,
569                        codebook_size: codebook_size as u32,
570                    });
571                }
572                let base = idx * k;
573                // Direct slice extend in f32. No f32→f64 conversion.
574                rotated_f32.extend_from_slice(&codewords[base..base + k]);
575            }
576            debug_assert_eq!(rotated_f32.len(), expected_len);
577            let norm = decode_norm(&code.norm_payload, &code.norm_format)?;
578            // Single f32 rotation. The original decode() does
579            // f32→f64, f64 rotation, then f64→f32. We do f32 rotation
580            // directly, matching the (matrix * input) as f32 of the
581            // original final cast within f32 precision.
582            let reconstructed = self.rotation.apply_inverse_f32(&rotated_f32)?;
583            let scaled: Vec<f32> = reconstructed
584                .into_iter()
585                .map(|value| (value * norm as f32))
586                .collect();
587            check_finite(&scaled)?;
588            out.push(scaled);
589        }
590        Ok(out)
591    }
592
593    /// Check if GPU acceleration is available.
594    ///
595    /// This is a **device-availability** probe: it returns true if a CUDA
596    /// device was found at init time. Whether an *individual* encode_batch
597    /// call actually dispatches to GPU depends on the call's batch size and
598    /// vector dimension crossing the runtime thresholds.
599    ///
600    /// Use [`Self::is_gpu_accelerated_for`] for an honest per-call check.
601    pub fn is_gpu_accelerated(&self) -> bool {
602        #[cfg(feature = "gpu")]
603        {
604            gpu_backend::GpuContext::is_available()
605        }
606        #[cfg(not(feature = "gpu"))]
607        {
608            false
609        }
610    }
611
612    /// Check if a batch of `n` vectors of dimension `d` would actually
613    /// dispatch to GPU. Returns true only when:
614    ///   - the `gpu` feature is compiled in,
615    ///   - a CUDA device is available at runtime,
616    ///   - `n >= GPU_MIN_BATCH_SIZE` and `d >= GPU_MIN_DIM`, AND
617    ///   - the codebook size `N` is <= 32 (the codebook_lookup kernel
618    ///     is one warp wide and falls back to CPU otherwise).
619    ///
620    /// This is the honest gate for receipts: a 4-doc corpus with dim 64
621    /// returns false even with `--features gpu`, because the batch is too
622    /// small to overcome GPU launch overhead. A corpus with a codebook
623    /// larger than 32 also returns false.
624    pub fn is_gpu_accelerated_for(&self, n: usize, d: usize) -> bool {
625        #[cfg(feature = "gpu")]
626        {
627            if !gpu_backend::GpuContext::is_available() {
628                return false;
629            }
630            n >= gpu_backend::GpuContext::GPU_MIN_BATCH_SIZE
631                && d >= gpu_backend::GpuContext::GPU_MIN_DIM
632                && (self.profile.codebook_size as usize) <= 32
633        }
634        #[cfg(not(feature = "gpu"))]
635        {
636            let _ = (n, d);
637            false
638        }
639    }
640
641    /// Per-step GPU dispatch report. `hadamard` is true if a batch of size
642    /// `n` at dim `d` would dispatch the Hadamard rotation to GPU.
643    /// `codebook_lookup` is true only if both the Hadamard AND the
644    /// codebook-lookup step would dispatch (additionally requires codebook
645    /// size <= 32). The latter is independent of the `gpu_codebook_lookup`
646    /// feature gate — the feature controls whether the dispatch is enabled
647    /// in `encode_batch`, not whether the kernel would be a win.
648    pub fn gpu_steps_for(&self, n: usize, d: usize) -> GpuStepReport {
649        let device_available = {
650            #[cfg(feature = "gpu")]
651            {
652                gpu_backend::GpuContext::is_available()
653            }
654            #[cfg(not(feature = "gpu"))]
655            {
656                false
657            }
658        };
659        // Thresholds are the same as gpu_backend::GpuContext's. Hard-code
660        // them here to avoid requiring the gpu feature for the probe.
661        const MIN_BATCH: usize = 16;
662        const MIN_DIM: usize = 64;
663        let clears_thresholds = n >= MIN_BATCH && d >= MIN_DIM;
664        let codebook_fits = (self.profile.codebook_size as usize) <= 32;
665        GpuStepReport {
666            hadamard: device_available && clears_thresholds,
667            codebook_lookup: device_available && clears_thresholds && codebook_fits,
668        }
669    }
670
671    // ── End batch methods ──
672
673    fn validate_code_header(&self, code: &FibCodeV1) -> Result<()> {
674        if code.schema_version != CODE_SCHEMA {
675            return Err(FibQuantError::CorruptPayload(format!(
676                "code schema_version {}, expected {CODE_SCHEMA}",
677                code.schema_version
678            )));
679        }
680        let expected_profile = self.profile.digest()?;
681        if code.profile_digest != expected_profile {
682            return Err(FibQuantError::ProfileDigestMismatch {
683                expected: expected_profile,
684                actual: code.profile_digest.clone(),
685            });
686        }
687        // Codebook and rotation digests are skipped if empty — the
688        // compact wire format omits them because they're derivable from
689        // the profile. The decoder trusts its own codebook/rotation in
690        // that case. (Re-deriving the codebook just to compute the
691        // digest cost ~6ms per call, which is prohibitive for batch
692        // decode of 1.5M+ blocks.)
693        if !code.codebook_digest.is_empty()
694            && code.codebook_digest != self.codebook.codebook_digest
695        {
696            return Err(FibQuantError::CodebookDigestMismatch {
697                expected: self.codebook.codebook_digest.clone(),
698                actual: code.codebook_digest.clone(),
699            });
700        }
701        let expected_rotation = self.rotation.digest()?;
702        if !code.rotation_digest.is_empty()
703            && (code.rotation_digest != expected_rotation
704                || code.rotation_digest != self.codebook.rotation_digest)
705        {
706            return Err(FibQuantError::RotationDigestMismatch {
707                expected: expected_rotation,
708                actual: code.rotation_digest.clone(),
709            });
710        }
711        if code.ambient_dim != self.profile.ambient_dim
712            || code.block_dim != self.profile.block_dim
713            || code.block_count != self.profile.block_count()
714            || code.wire_index_bits != self.profile.wire_index_bits
715            || code.norm_format != self.profile.norm_format
716        {
717            return Err(FibQuantError::CorruptPayload(
718                "encoded header does not match profile".into(),
719            ));
720        }
721        Ok(())
722    }
723}
724
725/// Stable digest over the encoded artifact fields.
726pub fn encoded_digest(code: &FibCodeV1) -> Result<String> {
727    json_digest(CODE_SCHEMA, code)
728}
729
730fn source_vector_digest(x: &[f32]) -> Result<String> {
731    check_finite(x)?;
732    let mut bytes = Vec::with_capacity(32 + std::mem::size_of_val(x));
733    bytes.extend_from_slice(b"fib_quant_source_vector_v1");
734    bytes.push(0);
735    bytes.extend_from_slice(&(x.len() as u64).to_le_bytes());
736    for value in x {
737        bytes.extend_from_slice(&value.to_le_bytes());
738    }
739    Ok(bytes_digest(&bytes))
740}
741
742fn encode_norm(norm: f64, format: &NormFormat) -> Result<Vec<u8>> {
743    if !norm.is_finite() || norm <= 0.0 {
744        return Err(FibQuantError::CorruptPayload(
745            "norm must be finite and positive".into(),
746        ));
747    }
748    match format {
749        NormFormat::Fp16Paper => {
750            let narrowed = f16::from_f32(norm as f32);
751            if !narrowed.is_finite() || narrowed <= f16::ZERO {
752                return Err(FibQuantError::CorruptPayload(
753                    "norm cannot be represented as finite positive fp16".into(),
754                ));
755            }
756            Ok(narrowed.to_le_bytes().to_vec())
757        }
758        NormFormat::F32Reference => {
759            let narrowed = norm as f32;
760            if !narrowed.is_finite() || narrowed <= 0.0 {
761                return Err(FibQuantError::CorruptPayload(
762                    "norm cannot be represented as finite positive f32".into(),
763                ));
764            }
765            Ok(narrowed.to_le_bytes().to_vec())
766        }
767    }
768}
769
770fn decode_norm(bytes: &[u8], format: &NormFormat) -> Result<f64> {
771    match format {
772        NormFormat::Fp16Paper => {
773            let bytes: [u8; 2] = bytes
774                .try_into()
775                .map_err(|_| FibQuantError::CorruptPayload("fp16 norm length".into()))?;
776            let value = f16::from_le_bytes(bytes).to_f32() as f64;
777            if value.is_finite() && value > 0.0 {
778                Ok(value)
779            } else {
780                Err(FibQuantError::CorruptPayload("invalid fp16 norm".into()))
781            }
782        }
783        NormFormat::F32Reference => {
784            let bytes: [u8; 4] = bytes
785                .try_into()
786                .map_err(|_| FibQuantError::CorruptPayload("f32 norm length".into()))?;
787            let value = f32::from_le_bytes(bytes) as f64;
788            if value.is_finite() && value > 0.0 {
789                Ok(value)
790            } else {
791                Err(FibQuantError::CorruptPayload("invalid f32 norm".into()))
792            }
793        }
794    }
795}
796
797fn l2_norm(x: &[f32]) -> f64 {
798    x.iter()
799        .map(|value| {
800            let value = f64::from(*value);
801            value * value
802        })
803        .sum::<f64>()
804        .sqrt()
805}
806
807fn check_finite(x: &[f32]) -> Result<()> {
808    if let Some((idx, _)) = x.iter().enumerate().find(|(_, value)| !value.is_finite()) {
809        return Err(FibQuantError::NonFiniteInput(idx));
810    }
811    Ok(())
812}
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817
818    #[test]
819    fn f32_norm_overflow_rejects_before_payload_emit() {
820        let err = encode_norm(f64::MAX, &NormFormat::F32Reference).unwrap_err();
821        assert!(matches!(err, FibQuantError::CorruptPayload(message) if message.contains("f32")));
822    }
823
824    #[test]
825    fn f32_norm_underflow_rejects_before_payload_emit() {
826        let err = encode_norm(
827            f64::from(f32::from_bits(1)) / 2.0,
828            &NormFormat::F32Reference,
829        )
830        .unwrap_err();
831        assert!(matches!(err, FibQuantError::CorruptPayload(message) if message.contains("f32")));
832    }
833}
834
835/// Per-step GPU dispatch report.
836#[derive(Debug, Clone, Copy, PartialEq, Eq)]
837pub struct GpuStepReport {
838    /// Hadamard rotation would dispatch to GPU.
839    pub hadamard: bool,
840    /// Nearest-codebook index lookup would also dispatch to GPU.
841    pub codebook_lookup: bool,
842}