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