Skip to main content

fib_quant/
residual.rs

1//! Residual (second-level) quantization for improved reconstruction quality.
2//!
3//! After encoding with the main codebook, the quantization residual
4//! `r = rotated_block - codeword[main_index]` still contains signal.
5//! A second, smaller codebook (typically 4-8 codewords) captures this
6//! residual, adding 2-3 bits per block for a significant quality gain.
7//!
8//! The two-level scheme:
9//! 1. Encode: find main index (as before), compute residual, find residual index.
10//! 2. Store: main indices + residual indices in the packed payload.
11//! 3. Decode: codeword[main_index] + residual_codebook[residual_index].
12//!
13//! Quality impact: cosine fidelity improves from ~0.863 (single-level)
14//! to ~0.93+ (two-level) for typical nomic-768 workloads, at the cost
15//! of 2-3 extra bits per block (~10-15% size increase).
16//!
17//! # Multi-Level Additive Quantization
18//!
19//! The multi-level scheme extends this to N levels:
20//! 1. Encode: find main index, compute residual r1, find level-1 index,
21//!    compute residual r2 = r1 - cw1[idx1], find level-2 index, etc.
22//! 2. Store: main indices + packed residual indices per level.
23//! 3. Decode: codeword[main] + cw1[idx1] + cw2[idx2] + ... then inverse
24//!    rotation and norm scaling.
25//!
26//! Each residual level is trained with Lloyd-Max on the residual distribution
27//! from the previous levels, producing codebooks adapted to the actual data
28//! rather than random directions.
29
30use serde::{Deserialize, Serialize};
31
32use crate::{
33    bitpack::{pack_indices, unpack_indices},
34    codebook::FibCodebookV1,
35    profile::FibQuantProfileV1,
36    FibQuantError, Result,
37};
38
39/// Residual codebook schema marker.
40pub const RESIDUAL_SCHEMA: &str = "fib_residual_codebook_v1";
41
42/// A residual codebook — a small set of codewords for encoding the
43/// quantization residual from the main codebook.
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub struct ResidualCodebookV1 {
46    /// Stable schema marker.
47    pub schema_version: String,
48    /// Number of residual codewords (typically 4-8).
49    pub size: u32,
50    /// Block dimension (must match the main codebook's block_dim).
51    pub block_dim: u32,
52    /// Row-major `size × block_dim` f32 codewords.
53    pub codewords: Vec<f32>,
54    /// Digest of the residual codebook.
55    pub codebook_digest: String,
56}
57
58impl ResidualCodebookV1 {
59    /// Build a deterministic residual codebook.
60    ///
61    /// The residual codebook is seeded from the main profile's codebook_seed
62    /// with a fixed offset. It uses a small set of unit-length directions
63    /// scaled by a fraction of the expected residual magnitude.
64    pub fn build(profile: &FibQuantProfileV1, main_codebook: &FibCodebookV1) -> Result<Self> {
65        let k = profile.block_dim as usize;
66        let n = profile.codebook_size as usize;
67        // Residual codebook size: 4 codewords (2 bits) by default.
68        // This captures the dominant residual direction and its negation,
69        // plus two orthogonal corrections.
70        let residual_n = 4usize;
71        use rand::SeedableRng;
72        let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(
73            profile.codebook_seed.wrapping_add(0x5253_4355_4f4e), // "RSON"
74        );
75        use rand_distr::{Distribution, StandardNormal};
76        let mut codewords = Vec::with_capacity(residual_n * k);
77        // Codeword 0: zero (no residual correction — pass-through).
78        codewords.resize(k, 0.0);
79        // Codewords 1-3: random directions scaled by the average
80        // nearest-neighbor distance in the main codebook.
81        let avg_nn_dist = estimate_avg_nn_distance(&main_codebook.codewords, n, k);
82        for _ in 1..residual_n {
83            let mut dir = Vec::with_capacity(k);
84            let mut norm_sq = 0.0f64;
85            for _ in 0..k {
86                let v: f64 = StandardNormal.sample(&mut rng);
87                dir.push(v);
88                norm_sq += v * v;
89            }
90            let norm = norm_sq.sqrt();
91            let scale = avg_nn_dist * 0.5; // half the NN distance
92            for v in &dir {
93                codewords.push((v / norm * scale) as f32);
94            }
95        }
96        let mut cb = Self {
97            schema_version: RESIDUAL_SCHEMA.into(),
98            size: residual_n as u32,
99            block_dim: profile.block_dim,
100            codewords,
101            codebook_digest: String::new(),
102        };
103        cb.codebook_digest = cb.compute_digest()?;
104        Ok(cb)
105    }
106
107    /// Train a residual codebook using Lloyd-Max on the residual distribution.
108    ///
109    /// Collects residuals after encoding training vectors with the main codebook,
110    /// then runs Lloyd-Max refinement to find optimal residual codewords adapted
111    /// to the actual residual distribution (instead of random directions).
112    ///
113    /// # Arguments
114    /// * `profile` - The quantization profile (provides dims, rotation, seed).
115    /// * `main_codebook` - The main codebook to compute residuals against.
116    /// * `training_vectors` - Full ambient-dim training vectors (will be normalized + rotated).
117    /// * `num_codewords` - Number of residual codewords to train.
118    pub fn train(
119        profile: &FibQuantProfileV1,
120        main_codebook: &FibCodebookV1,
121        training_vectors: &[Vec<f32>],
122        num_codewords: usize,
123    ) -> Result<Self> {
124        let k = profile.block_dim as usize;
125        let block_count = profile.block_count() as usize;
126
127        // Build rotation to compute rotated blocks
128        let rotation = crate::rotation::StoredRotation::new(
129            profile.ambient_dim as usize,
130            profile.rotation_seed,
131        )?;
132
133        // Collect residual blocks from all training vectors
134        let mut residual_blocks: Vec<Vec<f32>> =
135            Vec::with_capacity(training_vectors.len() * block_count);
136        for x in training_vectors {
137            let norm: f64 = x
138                .iter()
139                .map(|v| f64::from(*v) * f64::from(*v))
140                .sum::<f64>()
141                .sqrt();
142            if norm == 0.0 {
143                continue;
144            }
145            let normalized: Vec<f64> = x.iter().map(|v| f64::from(*v) / norm).collect();
146            let rotated = rotation.apply(&normalized)?;
147            let rotated_f32: Vec<f32> = rotated.iter().map(|&v| v as f32).collect();
148
149            for block_idx in 0..block_count {
150                let block = &rotated_f32[block_idx * k..(block_idx + 1) * k];
151                let main_idx = nearest_codeword_f32(block, &main_codebook.codewords, k);
152                let cw = &main_codebook.codewords[main_idx * k..(main_idx + 1) * k];
153                let residual: Vec<f32> = block.iter().zip(cw.iter()).map(|(a, b)| a - b).collect();
154                residual_blocks.push(residual);
155            }
156        }
157
158        if residual_blocks.is_empty() {
159            return Err(FibQuantError::NumericalFailure(
160                "no residual blocks collected for training".into(),
161            ));
162        }
163
164        let codewords = lloyd_max_train(
165            &residual_blocks,
166            k,
167            num_codewords,
168            profile.codebook_seed.wrapping_add(0x5452_4e52_4553), // "TNRES"
169        )?;
170
171        let mut cb = Self {
172            schema_version: RESIDUAL_SCHEMA.into(),
173            size: num_codewords as u32,
174            block_dim: profile.block_dim,
175            codewords,
176            codebook_digest: String::new(),
177        };
178        cb.codebook_digest = cb.compute_digest()?;
179        Ok(cb)
180    }
181
182    /// Validate the residual codebook.
183    pub fn validate(&self) -> Result<()> {
184        if self.schema_version != RESIDUAL_SCHEMA {
185            return Err(FibQuantError::CorruptPayload(format!(
186                "residual codebook schema {}, expected {RESIDUAL_SCHEMA}",
187                self.schema_version
188            )));
189        }
190        let expected_len = (self.size as usize) * (self.block_dim as usize);
191        if self.codewords.len() != expected_len {
192            return Err(FibQuantError::CorruptPayload(format!(
193                "residual codebook has {} values, expected {}",
194                self.codewords.len(),
195                expected_len
196            )));
197        }
198        if self.codewords.iter().any(|v| !v.is_finite()) {
199            return Err(FibQuantError::CorruptPayload(
200                "residual codebook contains non-finite value".into(),
201            ));
202        }
203        if self.codebook_digest != self.compute_digest()? {
204            return Err(FibQuantError::CodebookDigestMismatch {
205                expected: self.compute_digest()?,
206                actual: self.codebook_digest.clone(),
207            });
208        }
209        Ok(())
210    }
211
212    /// Find the nearest residual codeword for a given residual block.
213    pub fn nearest(&self, residual: &[f32]) -> Result<u32> {
214        let k = self.block_dim as usize;
215        if residual.len() != k {
216            return Err(FibQuantError::CorruptPayload(format!(
217                "residual block dim {}, expected {}",
218                residual.len(),
219                k
220            )));
221        }
222        let n = self.size as usize;
223        let mut best_idx = 0u32;
224        let mut best_dist = f32::INFINITY;
225        for i in 0..n {
226            let cw = &self.codewords[i * k..(i + 1) * k];
227            let dist: f32 = residual
228                .iter()
229                .zip(cw.iter())
230                .map(|(a, b)| {
231                    let d = a - b;
232                    d * d
233                })
234                .sum();
235            if dist < best_dist {
236                best_dist = dist;
237                best_idx = i as u32;
238            }
239        }
240        Ok(best_idx)
241    }
242
243    /// Get a residual codeword by index.
244    pub fn codeword(&self, index: u32) -> Result<&[f32]> {
245        let k = self.block_dim as usize;
246        let i = index as usize;
247        if i >= self.size as usize {
248            return Err(FibQuantError::IndexOutOfRange {
249                index,
250                codebook_size: self.size,
251            });
252        }
253        Ok(&self.codewords[i * k..(i + 1) * k])
254    }
255
256    /// Bits per residual index.
257    pub fn bits_per_index(&self) -> u8 {
258        let n = self.size as usize;
259        if n <= 1 {
260            return 0;
261        }
262        // ceil(log2(n)) = number of bits needed to represent indices [0, n)
263        (n as u32).next_power_of_two().trailing_zeros() as u8
264    }
265
266    fn compute_digest(&self) -> Result<String> {
267        #[derive(Serialize)]
268        struct DigestView<'a> {
269            schema_version: &'a str,
270            size: u32,
271            block_dim: u32,
272            codewords: &'a [f32],
273        }
274        crate::digest::json_digest(
275            RESIDUAL_SCHEMA,
276            &DigestView {
277                schema_version: &self.schema_version,
278                size: self.size,
279                block_dim: self.block_dim,
280                codewords: &self.codewords,
281            },
282        )
283    }
284}
285
286/// Estimate the average nearest-neighbor distance between codewords.
287/// This determines the scale of the residual codebook.
288fn estimate_avg_nn_distance(codewords: &[f32], n: usize, k: usize) -> f64 {
289    if n <= 1 {
290        return 1.0;
291    }
292    let mut total = 0.0f64;
293    let mut count = 0usize;
294    // Sample up to 32 codewords to keep this O(n * 32 * k) not O(n^2 * k).
295    let sample = n.min(32);
296    for i in 0..sample {
297        let ci = &codewords[i * k..(i + 1) * k];
298        let mut nearest_dist = f64::INFINITY;
299        for j in 0..n {
300            if j == i {
301                continue;
302            }
303            let cj = &codewords[j * k..(j + 1) * k];
304            let dist: f64 = ci
305                .iter()
306                .zip(cj.iter())
307                .map(|(a, b)| {
308                    let d = f64::from(*a) - f64::from(*b);
309                    d * d
310                })
311                .sum::<f64>()
312                .sqrt();
313            if dist < nearest_dist {
314                nearest_dist = dist;
315            }
316        }
317        if nearest_dist.is_finite() {
318            total += nearest_dist;
319            count += 1;
320        }
321    }
322    if count == 0 {
323        1.0
324    } else {
325        total / count as f64
326    }
327}
328
329/// Two-level encoded artifact: main code + residual code.
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
331pub struct FibResidualCodeV1 {
332    /// The main FibQuant code (same as single-level).
333    pub main_code: crate::codec::FibCodeV1,
334    /// Packed residual indices (same block_count, smaller bit width).
335    pub residual_indices: Vec<u8>,
336    /// Bits per residual index.
337    pub residual_bits: u8,
338}
339
340/// Two-level quantizer: main codebook + residual codebook.
341pub struct FibResidualQuantizer {
342    quantizer: crate::codec::FibQuantizer,
343    residual_codebook: ResidualCodebookV1,
344}
345
346impl FibResidualQuantizer {
347    /// Build a two-level quantizer from a profile.
348    pub fn new(profile: FibQuantProfileV1) -> Result<Self> {
349        let quantizer = crate::codec::FibQuantizer::new(profile.clone())?;
350        let residual_codebook = ResidualCodebookV1::build(&profile, quantizer.codebook())?;
351        Ok(Self {
352            quantizer,
353            residual_codebook,
354        })
355    }
356
357    /// Build a two-level quantizer with a custom (pre-trained) residual codebook.
358    ///
359    /// Useful for comparing random vs trained residual codebooks.
360    pub fn with_residual(
361        quantizer: crate::codec::FibQuantizer,
362        residual_codebook: ResidualCodebookV1,
363    ) -> Result<Self> {
364        Ok(Self {
365            quantizer,
366            residual_codebook,
367        })
368    }
369
370    /// Access the main quantizer.
371    pub fn quantizer(&self) -> &crate::codec::FibQuantizer {
372        &self.quantizer
373    }
374
375    /// Access the residual codebook.
376    pub fn residual_codebook(&self) -> &ResidualCodebookV1 {
377        &self.residual_codebook
378    }
379
380    /// Encode a vector with two-level quantization.
381    pub fn encode(&self, x: &[f32]) -> Result<FibResidualCodeV1> {
382        let d = self.quantizer.profile().ambient_dim as usize;
383        let k = self.quantizer.profile().block_dim as usize;
384        if x.len() != d {
385            return Err(FibQuantError::CorruptPayload(format!(
386                "input dimension {}, expected {d}",
387                x.len()
388            )));
389        }
390        // Encode with the main codebook (produces FibCodeV1 + rotation).
391        let main_code = self.quantizer.encode(x)?;
392
393        // Now compute the residual. We need the rotated vector to find
394        // the per-block residual.
395        let norm: f64 = x
396            .iter()
397            .map(|v| f64::from(*v) * f64::from(*v))
398            .sum::<f64>()
399            .sqrt();
400        if norm == 0.0 {
401            return Err(FibQuantError::ZeroNorm);
402        }
403        let normalized: Vec<f64> = x.iter().map(|v| f64::from(*v) / norm).collect();
404        let rotated = self.quantizer.rotation().apply(&normalized)?;
405        let rotated_f32: Vec<f32> = rotated.iter().map(|&v| v as f32).collect();
406        let block_count = self.quantizer.profile().block_count() as usize;
407
408        // Unpack main indices to find which codeword was selected per block
409        let main_indices = crate::bitpack::unpack_indices(
410            &main_code.indices,
411            block_count,
412            self.quantizer.profile().wire_index_bits,
413        )?;
414
415        // For each block, compute residual and find nearest residual codeword
416        let codewords = &self.quantizer.codebook().codewords;
417        let mut residual_indices_list = Vec::with_capacity(block_count);
418        for (block_idx, &main_idx) in main_indices.iter().enumerate() {
419            let main_idx = main_idx as usize;
420            let block = &rotated_f32[block_idx * k..(block_idx + 1) * k];
421            let cw = &codewords[main_idx * k..(main_idx + 1) * k];
422            let residual: Vec<f32> = block.iter().zip(cw.iter()).map(|(a, b)| a - b).collect();
423            let res_idx = self.residual_codebook.nearest(&residual)?;
424            residual_indices_list.push(res_idx);
425        }
426
427        let residual_bits = self.residual_codebook.bits_per_index();
428        let residual_indices = if residual_bits > 0 {
429            pack_indices(&residual_indices_list, residual_bits)?
430        } else {
431            Vec::new()
432        };
433
434        Ok(FibResidualCodeV1 {
435            main_code,
436            residual_indices,
437            residual_bits,
438        })
439    }
440
441    /// Decode a two-level code.
442    pub fn decode(&self, code: &FibResidualCodeV1) -> Result<Vec<f32>> {
443        let k = self.quantizer.profile().block_dim as usize;
444        let block_count = self.quantizer.profile().block_count() as usize;
445
446        // Unpack main and residual indices
447        let main_indices = crate::bitpack::unpack_indices(
448            &code.main_code.indices,
449            block_count,
450            self.quantizer.profile().wire_index_bits,
451        )?;
452        let residual_indices = if code.residual_bits > 0 && !code.residual_indices.is_empty() {
453            crate::bitpack::unpack_indices(&code.residual_indices, block_count, code.residual_bits)?
454        } else {
455            vec![0u32; block_count]
456        };
457
458        // Reconstruct: codeword[main] + residual_cw[residual] per block
459        let codewords = &self.quantizer.codebook().codewords;
460        let mut rotated_f32 = Vec::with_capacity(self.quantizer.profile().ambient_dim as usize);
461        for (main_idx, res_idx) in main_indices.iter().zip(residual_indices.iter()) {
462            let main_idx = *main_idx as usize;
463            let res_idx = *res_idx as usize;
464            let main_cw = &codewords[main_idx * k..(main_idx + 1) * k];
465            let res_cw = self.residual_codebook.codeword(res_idx as u32)?;
466            for (m, r) in main_cw.iter().zip(res_cw.iter()) {
467                rotated_f32.push(m + r);
468            }
469        }
470
471        // Apply inverse rotation and scale by norm
472        let norm = decode_norm_from_code(&code.main_code)?;
473        let reconstructed = self
474            .quantizer
475            .rotation()
476            .apply_inverse(&rotated_f32.iter().map(|&v| v as f64).collect::<Vec<_>>())?;
477        let out: Vec<f32> = reconstructed
478            .into_iter()
479            .map(|v| (v * norm) as f32)
480            .collect();
481        Ok(out)
482    }
483
484    /// Reconstruction cosine similarity.
485    pub fn cosine_similarity(&self, x: &[f32]) -> Result<f64> {
486        let code = self.encode(x)?;
487        let decoded = self.decode(&code)?;
488        crate::metrics::cosine_similarity(x, &decoded)
489    }
490}
491
492// ===========================================================================
493// Multi-Level Additive Quantization
494// ===========================================================================
495
496/// Multi-level residual codebook container.
497///
498/// Holds all residual codebooks for a multi-level additive quantizer.
499/// Each level's codebook captures the residual left after encoding with
500/// all previous levels.
501#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
502pub struct MultiLevelResidualCodebookV1 {
503    /// Each level's residual codebook (levels[0] = first residual level, etc.).
504    pub levels: Vec<ResidualCodebookV1>,
505    /// Sum of bits per index across all residual levels (main bits not included).
506    pub total_bits: u32,
507}
508
509impl MultiLevelResidualCodebookV1 {
510    /// Validate all levels and check total_bits consistency.
511    pub fn validate(&self) -> Result<()> {
512        for level in &self.levels {
513            level.validate()?;
514        }
515        let computed: u32 = self.levels.iter().map(|l| l.bits_per_index() as u32).sum();
516        if computed != self.total_bits {
517            return Err(FibQuantError::CorruptPayload(format!(
518                "total_bits {} does not match sum of per-level bits {}",
519                self.total_bits, computed
520            )));
521        }
522        Ok(())
523    }
524
525    /// Number of residual levels.
526    pub fn num_levels(&self) -> usize {
527        self.levels.len()
528    }
529}
530
531/// Multi-level encoded artifact: main code + residual indices for all levels.
532///
533/// `residual_indices` is the concatenation of packed index arrays for each
534/// residual level. `residual_bits[i]` gives the bit width for level i.
535/// During decode, each level's byte segment is extracted using
536/// `ceil(block_count * residual_bits[i] / 8)` and unpacked independently.
537#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
538pub struct MultiLevelCode {
539    /// The main FibQuant code (same as single-level).
540    pub main_code: crate::codec::FibCodeV1,
541    /// Concatenated packed residual indices for all levels.
542    pub residual_indices: Vec<u8>,
543    /// Bits per residual index for each level.
544    pub residual_bits: Vec<u8>,
545}
546
547/// Multi-level additive quantizer: main codebook + N trained residual codebooks.
548///
549/// Each residual level is trained with Lloyd-Max on the residual distribution
550/// from the previous levels, producing codebooks adapted to the actual data
551/// rather than random directions.
552///
553/// `num_levels = 1` means main only (no residual codebooks).
554/// `num_levels = 2` means main + 1 residual level (equivalent to 2-level).
555/// `num_levels = 3` means main + 2 residual levels.
556#[derive(Debug, Clone)]
557pub struct FibMultiLevelQuantizer {
558    quantizer: crate::codec::FibQuantizer,
559    residual_codebooks: Vec<ResidualCodebookV1>,
560}
561
562impl FibMultiLevelQuantizer {
563    /// Build a multi-level quantizer with trained residual codebooks.
564    ///
565    /// # Arguments
566    /// * `profile` - The quantization profile.
567    /// * `num_levels` - Total number of levels (1 = main only, 2 = main + 1 residual, etc.).
568    /// * `residual_sizes` - Number of codewords for each residual level
569    ///   (length must be `num_levels - 1`).
570    pub fn new(
571        profile: FibQuantProfileV1,
572        num_levels: usize,
573        residual_sizes: Vec<usize>,
574    ) -> Result<Self> {
575        if num_levels == 0 {
576            return Err(FibQuantError::CorruptPayload(
577                "num_levels must be >= 1".into(),
578            ));
579        }
580        let expected_residual_count = num_levels.saturating_sub(1);
581        if residual_sizes.len() != expected_residual_count {
582            return Err(FibQuantError::CorruptPayload(format!(
583                "residual_sizes length {} does not match num_levels-1 = {}",
584                residual_sizes.len(),
585                expected_residual_count
586            )));
587        }
588        for &sz in &residual_sizes {
589            if sz == 0 {
590                return Err(FibQuantError::CorruptPayload(
591                    "residual_sizes must be > 0".into(),
592                ));
593            }
594        }
595
596        let quantizer = crate::codec::FibQuantizer::new(profile.clone())?;
597
598        if num_levels == 1 {
599            return Ok(Self {
600                quantizer,
601                residual_codebooks: Vec::new(),
602            });
603        }
604
605        // Generate training vectors for residual codebook training
606        let training_vectors = generate_training_vectors(&profile)?;
607
608        // Build residual codebooks level by level
609        let mut residual_codebooks = Vec::with_capacity(expected_residual_count);
610
611        for (level, &num_cw) in residual_sizes
612            .iter()
613            .enumerate()
614            .take(expected_residual_count)
615        {
616            // Compute residual blocks at this level (residuals after all previous levels)
617            let residual_blocks = compute_multi_level_residuals(
618                &profile,
619                &quantizer,
620                &residual_codebooks,
621                &training_vectors,
622            )?;
623
624            if residual_blocks.is_empty() {
625                return Err(FibQuantError::NumericalFailure(format!(
626                    "no residual blocks collected for level {level}"
627                )));
628            }
629
630            // Train a residual codebook on these residuals using Lloyd-Max
631            let cb = train_residual_on_blocks(
632                &profile,
633                &residual_blocks,
634                num_cw,
635                profile
636                    .codebook_seed
637                    .wrapping_add((level as u64).wrapping_mul(0x4c45_5645_4c52)), // "LEVELR"
638            )?;
639
640            residual_codebooks.push(cb);
641        }
642
643        Ok(Self {
644            quantizer,
645            residual_codebooks,
646        })
647    }
648
649    /// Access the main quantizer.
650    pub fn quantizer(&self) -> &crate::codec::FibQuantizer {
651        &self.quantizer
652    }
653
654    /// Access all residual codebooks.
655    pub fn residual_codebooks(&self) -> &[ResidualCodebookV1] {
656        &self.residual_codebooks
657    }
658
659    /// Total number of levels (1 + residual codebooks count).
660    pub fn num_levels(&self) -> usize {
661        1 + self.residual_codebooks.len()
662    }
663
664    /// Get a `MultiLevelResidualCodebookV1` view of the residual codebooks.
665    pub fn multi_level_codebook(&self) -> MultiLevelResidualCodebookV1 {
666        let total_bits: u32 = self
667            .residual_codebooks
668            .iter()
669            .map(|cb| cb.bits_per_index() as u32)
670            .sum();
671        MultiLevelResidualCodebookV1 {
672            levels: self.residual_codebooks.clone(),
673            total_bits,
674        }
675    }
676
677    /// Encode a vector with multi-level additive quantization.
678    ///
679    /// Encodes with the main codebook, then iteratively encodes the residual
680    /// with each residual level: residual[i+1] = residual[i] - cw_i[idx_i].
681    pub fn encode(&self, x: &[f32]) -> Result<MultiLevelCode> {
682        let d = self.quantizer.profile().ambient_dim as usize;
683        let k = self.quantizer.profile().block_dim as usize;
684        if x.len() != d {
685            return Err(FibQuantError::CorruptPayload(format!(
686                "input dimension {}, expected {d}",
687                x.len()
688            )));
689        }
690
691        // Encode with the main codebook
692        let main_code = self.quantizer.encode(x)?;
693
694        if self.residual_codebooks.is_empty() {
695            return Ok(MultiLevelCode {
696                main_code,
697                residual_indices: Vec::new(),
698                residual_bits: Vec::new(),
699            });
700        }
701
702        // Compute rotated blocks for residual encoding
703        let norm: f64 = x
704            .iter()
705            .map(|v| f64::from(*v) * f64::from(*v))
706            .sum::<f64>()
707            .sqrt();
708        if norm == 0.0 {
709            return Err(FibQuantError::ZeroNorm);
710        }
711        let normalized: Vec<f64> = x.iter().map(|v| f64::from(*v) / norm).collect();
712        let rotated = self.quantizer.rotation().apply(&normalized)?;
713        let rotated_f32: Vec<f32> = rotated.iter().map(|&v| v as f32).collect();
714        let block_count = self.quantizer.profile().block_count() as usize;
715
716        // Unpack main indices
717        let main_indices = unpack_indices(
718            &main_code.indices,
719            block_count,
720            self.quantizer.profile().wire_index_bits,
721        )?;
722
723        let codewords = &self.quantizer.codebook().codewords;
724
725        // For each block, encode residuals level by level (additive)
726        let mut all_residual_indices: Vec<Vec<u32>> =
727            Vec::with_capacity(self.residual_codebooks.len());
728        for _ in &self.residual_codebooks {
729            all_residual_indices.push(Vec::with_capacity(block_count));
730        }
731
732        for (block_idx, &main_idx) in main_indices.iter().enumerate() {
733            let main_idx = main_idx as usize;
734            let block = &rotated_f32[block_idx * k..(block_idx + 1) * k];
735            let main_cw = &codewords[main_idx * k..(main_idx + 1) * k];
736
737            // Initial residual = block - main_cw
738            let mut residual: Vec<f32> = block
739                .iter()
740                .zip(main_cw.iter())
741                .map(|(a, b)| a - b)
742                .collect();
743
744            // Encode with each residual level, subtracting the codeword each time
745            for (level, rcb) in self.residual_codebooks.iter().enumerate() {
746                let idx = rcb.nearest(&residual)?;
747                all_residual_indices[level].push(idx);
748                let cw = rcb.codeword(idx)?;
749                for (r, c) in residual.iter_mut().zip(cw.iter()) {
750                    *r -= c;
751                }
752            }
753        }
754
755        // Pack each level's indices and concatenate
756        let mut residual_bits = Vec::with_capacity(self.residual_codebooks.len());
757        let mut residual_indices = Vec::new();
758        for (level, rcb) in self.residual_codebooks.iter().enumerate() {
759            let bits = rcb.bits_per_index();
760            residual_bits.push(bits);
761            if bits > 0 {
762                let packed = pack_indices(&all_residual_indices[level], bits)?;
763                residual_indices.extend_from_slice(&packed);
764            }
765        }
766
767        Ok(MultiLevelCode {
768            main_code,
769            residual_indices,
770            residual_bits,
771        })
772    }
773
774    /// Decode a multi-level code.
775    ///
776    /// Reconstructs: codeword[main] + residual_cw[0] + residual_cw[1] + ...
777    /// then applies inverse rotation and norm scaling.
778    pub fn decode(&self, code: &MultiLevelCode) -> Result<Vec<f32>> {
779        let k = self.quantizer.profile().block_dim as usize;
780        let block_count = self.quantizer.profile().block_count() as usize;
781
782        // Unpack main indices
783        let main_indices = unpack_indices(
784            &code.main_code.indices,
785            block_count,
786            self.quantizer.profile().wire_index_bits,
787        )?;
788
789        // Validate residual_bits length matches our codebooks
790        if code.residual_bits.len() != self.residual_codebooks.len() {
791            return Err(FibQuantError::CorruptPayload(format!(
792                "residual_bits length {} does not match quantizer residual levels {}",
793                code.residual_bits.len(),
794                self.residual_codebooks.len()
795            )));
796        }
797
798        // Unpack residual indices per level from the concatenated byte array
799        let mut all_residual_indices: Vec<Vec<u32>> =
800            Vec::with_capacity(self.residual_codebooks.len());
801        let mut offset = 0;
802        for &bits in &code.residual_bits {
803            if bits > 0 {
804                let level_bytes = (block_count * bits as usize).div_ceil(8);
805                if offset + level_bytes > code.residual_indices.len() {
806                    return Err(FibQuantError::CorruptPayload(format!(
807                        "residual_indices too short: need {} bytes at offset {}, have {}",
808                        level_bytes,
809                        offset,
810                        code.residual_indices.len()
811                    )));
812                }
813                let packed = &code.residual_indices[offset..offset + level_bytes];
814                let unpacked = unpack_indices(packed, block_count, bits)?;
815                all_residual_indices.push(unpacked);
816                offset += level_bytes;
817            } else {
818                // 0 bits means single-codeword level — all indices are 0
819                all_residual_indices.push(vec![0u32; block_count]);
820            }
821        }
822
823        // Reconstruct: main_cw + sum of all residual level codewords per block
824        let codewords = &self.quantizer.codebook().codewords;
825        let mut rotated_f32 = Vec::with_capacity(self.quantizer.profile().ambient_dim as usize);
826
827        for block_idx in 0..block_count {
828            let main_idx = main_indices[block_idx] as usize;
829            let main_cw = &codewords[main_idx * k..(main_idx + 1) * k];
830
831            // Start with main codeword
832            let mut block_recon: Vec<f32> = main_cw.to_vec();
833
834            // Add residual codewords from each level
835            for (level, rcb) in self.residual_codebooks.iter().enumerate() {
836                let res_idx = all_residual_indices[level][block_idx];
837                let cw = rcb.codeword(res_idx)?;
838                for (r, c) in block_recon.iter_mut().zip(cw.iter()) {
839                    *r += c;
840                }
841            }
842
843            rotated_f32.extend(block_recon);
844        }
845
846        // Apply inverse rotation and scale by norm
847        let norm = decode_norm_from_code(&code.main_code)?;
848        let reconstructed = self
849            .quantizer
850            .rotation()
851            .apply_inverse(&rotated_f32.iter().map(|&v| v as f64).collect::<Vec<_>>())?;
852        let out: Vec<f32> = reconstructed
853            .into_iter()
854            .map(|v| (v * norm) as f32)
855            .collect();
856        Ok(out)
857    }
858
859    /// Reconstruction cosine similarity.
860    pub fn cosine_similarity(&self, x: &[f32]) -> Result<f64> {
861        let code = self.encode(x)?;
862        let decoded = self.decode(&code)?;
863        crate::metrics::cosine_similarity(x, &decoded)
864    }
865}
866
867// ===========================================================================
868// Helper functions for multi-level training
869// ===========================================================================
870
871/// Find the nearest codeword in a flat row-major codebook (f32).
872fn nearest_codeword_f32(block: &[f32], codewords: &[f32], k: usize) -> usize {
873    let n = codewords.len() / k;
874    let mut best_idx = 0usize;
875    let mut best_dist = f32::INFINITY;
876    for i in 0..n {
877        let cw = &codewords[i * k..(i + 1) * k];
878        let dist: f32 = block
879            .iter()
880            .zip(cw.iter())
881            .map(|(a, b)| {
882                let d = a - b;
883                d * d
884            })
885            .sum();
886        if dist < best_dist {
887            best_dist = dist;
888            best_idx = i;
889        }
890    }
891    best_idx
892}
893
894/// Run Lloyd-Max on a set of k-dimensional residual blocks.
895///
896/// Initializes centroids from random samples, then iterates
897/// assignment + centroid update until convergence or max_iterations.
898/// Empty cells are reinitialized from random samples.
899fn lloyd_max_train(samples: &[Vec<f32>], k: usize, n: usize, seed: u64) -> Result<Vec<f32>> {
900    if samples.is_empty() {
901        return Err(FibQuantError::NumericalFailure(
902            "no samples for Lloyd-Max training".into(),
903        ));
904    }
905    if n == 0 {
906        return Err(FibQuantError::CorruptPayload(
907            "num_codewords must be > 0".into(),
908        ));
909    }
910
911    use rand::seq::SliceRandom;
912    use rand::SeedableRng;
913    let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(seed);
914
915    // Initialize: pick n random samples as initial centroids
916    let mut indices: Vec<usize> = (0..samples.len()).collect();
917    indices.shuffle(&mut rng);
918
919    let mut centroids: Vec<Vec<f32>> = Vec::with_capacity(n);
920    for i in 0..n.min(samples.len()) {
921        centroids.push(samples[indices[i]].clone());
922    }
923    // If fewer samples than n, fill remaining with perturbed copies
924    use rand_distr::{Distribution, StandardNormal};
925    while centroids.len() < n {
926        let base = &samples[indices[0]];
927        let mut cw = Vec::with_capacity(k);
928        for &v in base {
929            let noise: f64 =
930                <StandardNormal as Distribution<f64>>::sample(&StandardNormal, &mut rng) * 0.001;
931            cw.push((f64::from(v) + noise) as f32);
932        }
933        centroids.push(cw);
934    }
935
936    let max_iterations = 25;
937    for _ in 0..max_iterations {
938        // Assignment step: assign each sample to nearest centroid
939        let mut assignments = Vec::with_capacity(samples.len());
940        for s in samples {
941            let mut best_idx = 0usize;
942            let mut best_dist = f32::INFINITY;
943            for (i, cw) in centroids.iter().enumerate() {
944                let dist: f32 = s
945                    .iter()
946                    .zip(cw.iter())
947                    .map(|(a, b)| {
948                        let d = a - b;
949                        d * d
950                    })
951                    .sum();
952                if dist < best_dist {
953                    best_dist = dist;
954                    best_idx = i;
955                }
956            }
957            assignments.push(best_idx);
958        }
959
960        // Update step: recompute centroids as means of assigned samples
961        let mut sums = vec![0.0f64; n * k];
962        let mut counts = vec![0usize; n];
963        for (s, &a) in samples.iter().zip(&assignments) {
964            counts[a] += 1;
965            for d in 0..k {
966                sums[a * k + d] += f64::from(s[d]);
967            }
968        }
969
970        let mut changed = false;
971        for i in 0..n {
972            if counts[i] > 0 {
973                for d in 0..k {
974                    let new_val = (sums[i * k + d] / counts[i] as f64) as f32;
975                    if (new_val - centroids[i][d]).abs() > 1e-10 {
976                        changed = true;
977                    }
978                    centroids[i][d] = new_val;
979                }
980            } else {
981                // Empty cell: reinitialize from a random sample
982                let idx = indices.choose(&mut rng).copied().unwrap_or(0);
983                centroids[i] = samples[idx].clone();
984                changed = true;
985            }
986        }
987
988        if !changed {
989            break;
990        }
991    }
992
993    // Flatten to row-major
994    let mut codewords = Vec::with_capacity(n * k);
995    for cw in &centroids {
996        codewords.extend_from_slice(cw);
997    }
998    Ok(codewords)
999}
1000
1001/// Generate full d-dimensional training vectors from the spherical-Beta distribution.
1002///
1003/// Each training vector is constructed by sampling `block_count` independent
1004/// k-dimensional blocks from the spherical-Beta source and concatenating them
1005/// into a d-dimensional vector. This matches the structure that the main
1006/// codebook operates on after rotation.
1007fn generate_training_vectors(profile: &FibQuantProfileV1) -> Result<Vec<Vec<f32>>> {
1008    use rand::SeedableRng;
1009    let d = profile.ambient_dim as usize;
1010    let k = profile.block_dim as usize;
1011    let block_count = profile.block_count() as usize;
1012    let count = profile.training_samples.max(256) as usize;
1013    let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(
1014        profile.codebook_seed ^ 0x5452_5641_494e, // "TRAIN"
1015    );
1016    let mut result = Vec::with_capacity(count);
1017    for _ in 0..count {
1018        let mut full_vec = Vec::with_capacity(d);
1019        for _ in 0..block_count {
1020            let block = crate::spherical_beta::sample_spherical_beta(d, k, &mut rng)?;
1021            full_vec.extend(block.into_iter().map(|x| x as f32));
1022        }
1023        // Normalize to unit length so the rotation + norm path works correctly
1024        let norm: f64 = full_vec
1025            .iter()
1026            .map(|v| f64::from(*v) * f64::from(*v))
1027            .sum::<f64>()
1028            .sqrt();
1029        if norm > 0.0 && norm.is_finite() {
1030            for v in &mut full_vec {
1031                *v = (f64::from(*v) / norm) as f32;
1032            }
1033        }
1034        result.push(full_vec);
1035    }
1036    Ok(result)
1037}
1038
1039/// Compute residual blocks at a given level for training.
1040///
1041/// For each training vector: normalize → rotate → for each block, find main
1042/// codeword, compute residual, then subtract all previous residual level
1043/// codewords to get the residual at the current level.
1044fn compute_multi_level_residuals(
1045    profile: &FibQuantProfileV1,
1046    quantizer: &crate::codec::FibQuantizer,
1047    prev_codebooks: &[ResidualCodebookV1],
1048    training_vectors: &[Vec<f32>],
1049) -> Result<Vec<Vec<f32>>> {
1050    let k = profile.block_dim as usize;
1051    let block_count = profile.block_count() as usize;
1052    let rotation = quantizer.rotation();
1053    let codewords = &quantizer.codebook().codewords;
1054
1055    let mut all_residuals: Vec<Vec<f32>> = Vec::with_capacity(training_vectors.len() * block_count);
1056
1057    for x in training_vectors {
1058        let norm: f64 = x
1059            .iter()
1060            .map(|v| f64::from(*v) * f64::from(*v))
1061            .sum::<f64>()
1062            .sqrt();
1063        if norm == 0.0 {
1064            continue;
1065        }
1066        let normalized: Vec<f64> = x.iter().map(|v| f64::from(*v) / norm).collect();
1067        let rotated = rotation.apply(&normalized)?;
1068        let rotated_f32: Vec<f32> = rotated.iter().map(|&v| v as f32).collect();
1069
1070        for block_idx in 0..block_count {
1071            let block = &rotated_f32[block_idx * k..(block_idx + 1) * k];
1072            let main_idx = nearest_codeword_f32(block, codewords, k);
1073            let main_cw = &codewords[main_idx * k..(main_idx + 1) * k];
1074
1075            // Initial residual = block - main_cw
1076            let mut residual: Vec<f32> = block
1077                .iter()
1078                .zip(main_cw.iter())
1079                .map(|(a, b)| a - b)
1080                .collect();
1081
1082            // Subtract all previous residual level codewords
1083            for rcb in prev_codebooks {
1084                let idx = rcb.nearest(&residual)?;
1085                let cw = rcb.codeword(idx)?;
1086                for (r, c) in residual.iter_mut().zip(cw.iter()) {
1087                    *r -= c;
1088                }
1089            }
1090
1091            all_residuals.push(residual);
1092        }
1093    }
1094
1095    Ok(all_residuals)
1096}
1097
1098/// Train a residual codebook on pre-computed residual blocks using Lloyd-Max.
1099fn train_residual_on_blocks(
1100    profile: &FibQuantProfileV1,
1101    residual_blocks: &[Vec<f32>],
1102    num_codewords: usize,
1103    seed: u64,
1104) -> Result<ResidualCodebookV1> {
1105    let k = profile.block_dim as usize;
1106    let codewords = lloyd_max_train(residual_blocks, k, num_codewords, seed)?;
1107
1108    let mut cb = ResidualCodebookV1 {
1109        schema_version: RESIDUAL_SCHEMA.into(),
1110        size: num_codewords as u32,
1111        block_dim: profile.block_dim,
1112        codewords,
1113        codebook_digest: String::new(),
1114    };
1115    cb.codebook_digest = cb.compute_digest()?;
1116    Ok(cb)
1117}
1118
1119fn decode_norm_from_code(code: &crate::codec::FibCodeV1) -> Result<f64> {
1120    use crate::profile::NormFormat;
1121    use half::f16;
1122    match code.norm_format {
1123        NormFormat::Fp16Paper => {
1124            let bytes: [u8; 2] = code
1125                .norm_payload
1126                .as_slice()
1127                .try_into()
1128                .map_err(|_| FibQuantError::CorruptPayload("fp16 norm length".into()))?;
1129            let value = f16::from_le_bytes(bytes).to_f32() as f64;
1130            if value.is_finite() && value > 0.0 {
1131                Ok(value)
1132            } else {
1133                Err(FibQuantError::CorruptPayload("invalid fp16 norm".into()))
1134            }
1135        }
1136        NormFormat::F32Reference => {
1137            let bytes: [u8; 4] = code
1138                .norm_payload
1139                .as_slice()
1140                .try_into()
1141                .map_err(|_| FibQuantError::CorruptPayload("f32 norm length".into()))?;
1142            let value = f32::from_le_bytes(bytes) as f64;
1143            if value.is_finite() && value > 0.0 {
1144                Ok(value)
1145            } else {
1146                Err(FibQuantError::CorruptPayload("invalid f32 norm".into()))
1147            }
1148        }
1149    }
1150}
1151
1152#[cfg(test)]
1153mod tests {
1154    use super::*;
1155
1156    fn build_test_quantizer() -> Result<FibResidualQuantizer> {
1157        let mut profile = FibQuantProfileV1::paper_default(8, 2, 8, 7)?;
1158        profile.training_samples = 128;
1159        profile.lloyd_restarts = 1;
1160        profile.lloyd_iterations = 2;
1161        FibResidualQuantizer::new(profile)
1162    }
1163
1164    fn build_test_multi_level_quantizer(
1165        num_levels: usize,
1166        residual_sizes: Vec<usize>,
1167    ) -> Result<FibMultiLevelQuantizer> {
1168        let mut profile = FibQuantProfileV1::paper_default(8, 2, 8, 7)?;
1169        profile.training_samples = 256;
1170        profile.lloyd_restarts = 2;
1171        profile.lloyd_iterations = 10;
1172        FibMultiLevelQuantizer::new(profile, num_levels, residual_sizes)
1173    }
1174
1175    #[test]
1176    fn residual_codebook_has_correct_size() -> Result<()> {
1177        let rq = build_test_quantizer()?;
1178        assert_eq!(rq.residual_codebook().size, 4);
1179        assert_eq!(rq.residual_codebook().block_dim, 2);
1180        assert_eq!(rq.residual_codebook().codewords.len(), 4 * 2);
1181        Ok(())
1182    }
1183
1184    #[test]
1185    fn residual_codebook_digest_is_valid() -> Result<()> {
1186        let rq = build_test_quantizer()?;
1187        rq.residual_codebook().validate()?;
1188        Ok(())
1189    }
1190
1191    #[test]
1192    fn two_level_encode_decode_roundtrip() -> Result<()> {
1193        let rq = build_test_quantizer()?;
1194        let input = vec![0.25, -0.5, 0.75, 1.0, -1.25, 0.5, 0.125, -0.875];
1195        let code = rq.encode(&input)?;
1196        let decoded = rq.decode(&code)?;
1197        assert_eq!(decoded.len(), input.len());
1198        for (a, b) in input.iter().zip(decoded.iter()) {
1199            assert!(a.is_finite() && b.is_finite());
1200        }
1201        Ok(())
1202    }
1203
1204    #[test]
1205    fn two_level_better_than_single_level() -> Result<()> {
1206        let rq = build_test_quantizer()?;
1207        let input = vec![0.25, -0.5, 0.75, 1.0, -1.25, 0.5, 0.125, -0.875];
1208
1209        // Single-level cosine
1210        let single_cos = rq.quantizer().cosine_similarity(&input)?;
1211
1212        // Two-level cosine
1213        let two_level_cos = rq.cosine_similarity(&input)?;
1214
1215        assert!(
1216            two_level_cos >= single_cos - 1e-6,
1217            "two-level should be >= single-level: {} vs {}",
1218            two_level_cos,
1219            single_cos
1220        );
1221        Ok(())
1222    }
1223
1224    #[test]
1225    fn residual_nearest_returns_valid_index() -> Result<()> {
1226        let rq = build_test_quantizer()?;
1227        let residual = vec![0.1, -0.05];
1228        let idx = rq.residual_codebook().nearest(&residual)?;
1229        assert!(idx < rq.residual_codebook().size);
1230        Ok(())
1231    }
1232
1233    // ----- Multi-level tests -----
1234
1235    #[test]
1236    fn multi_level_roundtrip_produces_finite_values() -> Result<()> {
1237        let rq = build_test_multi_level_quantizer(3, vec![8, 8])?;
1238        let input = vec![0.25, -0.5, 0.75, 1.0, -1.25, 0.5, 0.125, -0.875];
1239        let code = rq.encode(&input)?;
1240        let decoded = rq.decode(&code)?;
1241        assert_eq!(decoded.len(), input.len());
1242        for v in &decoded {
1243            assert!(v.is_finite(), "decoded value is not finite: {v}");
1244        }
1245        Ok(())
1246    }
1247
1248    #[test]
1249    fn multi_level_codebook_validates() -> Result<()> {
1250        let rq = build_test_multi_level_quantizer(3, vec![8, 8])?;
1251        let mlcb = rq.multi_level_codebook();
1252        assert_eq!(mlcb.num_levels(), 2);
1253        assert!(mlcb.total_bits > 0);
1254        mlcb.validate()?;
1255        Ok(())
1256    }
1257
1258    #[test]
1259    fn three_level_better_than_two_better_than_one() -> Result<()> {
1260        let input = vec![0.25, -0.5, 0.75, 1.0, -1.25, 0.5, 0.125, -0.875];
1261
1262        // 1-level (main only)
1263        let q1 = build_test_multi_level_quantizer(1, vec![])?;
1264        let code1 = q1.encode(&input)?;
1265        let decoded1 = q1.decode(&code1)?;
1266        let cos1 = crate::metrics::cosine_similarity(&input, &decoded1)?;
1267
1268        // 2-level (main + 1 trained residual)
1269        let q2 = build_test_multi_level_quantizer(2, vec![8])?;
1270        let code2 = q2.encode(&input)?;
1271        let decoded2 = q2.decode(&code2)?;
1272        let cos2 = crate::metrics::cosine_similarity(&input, &decoded2)?;
1273
1274        // 3-level (main + 2 trained residuals)
1275        let q3 = build_test_multi_level_quantizer(3, vec![8, 8])?;
1276        let code3 = q3.encode(&input)?;
1277        let decoded3 = q3.decode(&code3)?;
1278        let cos3 = crate::metrics::cosine_similarity(&input, &decoded3)?;
1279
1280        assert!(
1281            cos2 >= cos1 - 1e-6,
1282            "2-level ({}) should be >= 1-level ({})",
1283            cos2,
1284            cos1
1285        );
1286        assert!(
1287            cos3 >= cos2 - 1e-6,
1288            "3-level ({}) should be >= 2-level ({})",
1289            cos3,
1290            cos2
1291        );
1292        Ok(())
1293    }
1294
1295    #[test]
1296    fn trained_residual_better_than_random() -> Result<()> {
1297        let mut profile = FibQuantProfileV1::paper_default(8, 2, 8, 7)?;
1298        profile.training_samples = 256;
1299        profile.lloyd_restarts = 2;
1300        profile.lloyd_iterations = 10;
1301
1302        let quantizer = crate::codec::FibQuantizer::new(profile.clone())?;
1303
1304        // Generate training vectors
1305        let training_vectors = generate_training_vectors(&profile)?;
1306
1307        // Build random residual codebook (existing path)
1308        let random_cb = ResidualCodebookV1::build(&profile, quantizer.codebook())?;
1309
1310        // Train a residual codebook with Lloyd-Max
1311        let trained_cb =
1312            ResidualCodebookV1::train(&profile, quantizer.codebook(), &training_vectors, 8)?;
1313
1314        // Build two-level quantizers with each codebook
1315        let rq_random = FibResidualQuantizer::with_residual(
1316            crate::codec::FibQuantizer::new(profile.clone())?,
1317            random_cb,
1318        )?;
1319        let rq_trained = FibResidualQuantizer::with_residual(
1320            crate::codec::FibQuantizer::new(profile.clone())?,
1321            trained_cb,
1322        )?;
1323
1324        // Test on multiple vectors to get a robust comparison
1325        let test_inputs: Vec<Vec<f32>> = vec![
1326            vec![0.25, -0.5, 0.75, 1.0, -1.25, 0.5, 0.125, -0.875],
1327            vec![0.3, 0.7, -0.2, 0.9, 0.4, -0.6, 0.8, -0.1],
1328            vec![-0.5, 0.3, 0.6, -0.8, 0.2, 0.9, -0.4, 0.7],
1329            vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8],
1330            vec![-0.9, 0.1, -0.3, 0.5, -0.7, 0.2, -0.4, 0.6],
1331        ];
1332
1333        let mut random_total = 0.0f64;
1334        let mut trained_total = 0.0f64;
1335        for input in &test_inputs {
1336            let cos_random = rq_random.cosine_similarity(input)?;
1337            let cos_trained = rq_trained.cosine_similarity(input)?;
1338            random_total += cos_random;
1339            trained_total += cos_trained;
1340        }
1341
1342        let random_avg = random_total / test_inputs.len() as f64;
1343        let trained_avg = trained_total / test_inputs.len() as f64;
1344
1345        assert!(
1346            trained_avg >= random_avg - 1e-6,
1347            "trained residual ({}) should be >= random residual ({})",
1348            trained_avg,
1349            random_avg
1350        );
1351        Ok(())
1352    }
1353
1354    #[test]
1355    fn one_level_multi_level_matches_single_level() -> Result<()> {
1356        let profile = FibQuantProfileV1::paper_default(8, 2, 8, 7)?;
1357        let single = crate::codec::FibQuantizer::new(profile.clone())?;
1358        let multi = build_test_multi_level_quantizer(1, vec![])?;
1359
1360        let input = vec![0.25, -0.5, 0.75, 1.0, -1.25, 0.5, 0.125, -0.875];
1361
1362        let single_decoded = single.decode(&single.encode(&input)?)?;
1363        let multi_decoded = multi.decode(&multi.encode(&input)?)?;
1364
1365        // The multi-level decode uses f64 rotation inverse while the single-level
1366        // FibQuantizer::decode uses the f32 fast path, so there can be small
1367        // floating-point differences. Check approximate equality with a
1368        // generous tolerance (f32 vs f64 rotation can differ at ~1e-2 level
1369        // for small dimensions).
1370        let cos_single = crate::metrics::cosine_similarity(&single_decoded, &multi_decoded)?;
1371        assert!(
1372            cos_single > 0.99,
1373            "single vs multi decoded cosine too low: {cos_single}"
1374        );
1375        Ok(())
1376    }
1377}