1use serde::{Deserialize, Serialize};
31
32use crate::{
33 bitpack::{pack_indices, unpack_indices},
34 codebook::FibCodebookV1,
35 profile::FibQuantProfileV1,
36 FibQuantError, Result,
37};
38
39pub const RESIDUAL_SCHEMA: &str = "fib_residual_codebook_v1";
41
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub struct ResidualCodebookV1 {
46 pub schema_version: String,
48 pub size: u32,
50 pub block_dim: u32,
52 pub codewords: Vec<f32>,
54 pub codebook_digest: String,
56}
57
58impl ResidualCodebookV1 {
59 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 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), );
75 use rand_distr::{Distribution, StandardNormal};
76 let mut codewords = Vec::with_capacity(residual_n * k);
77 codewords.resize(k, 0.0);
79 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; 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 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 let rotation = crate::rotation::StoredRotation::new(
129 profile.ambient_dim as usize,
130 profile.rotation_seed,
131 )?;
132
133 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), )?;
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 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 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 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 pub fn bits_per_index(&self) -> u8 {
258 let n = self.size as usize;
259 if n <= 1 {
260 return 0;
261 }
262 (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
286fn 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
331pub struct FibResidualCodeV1 {
332 pub main_code: crate::codec::FibCodeV1,
334 pub residual_indices: Vec<u8>,
336 pub residual_bits: u8,
338}
339
340pub struct FibResidualQuantizer {
342 quantizer: crate::codec::FibQuantizer,
343 residual_codebook: ResidualCodebookV1,
344}
345
346impl FibResidualQuantizer {
347 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 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 pub fn quantizer(&self) -> &crate::codec::FibQuantizer {
372 &self.quantizer
373 }
374
375 pub fn residual_codebook(&self) -> &ResidualCodebookV1 {
377 &self.residual_codebook
378 }
379
380 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 let main_code = self.quantizer.encode(x)?;
392
393 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 let main_indices = crate::bitpack::unpack_indices(
410 &main_code.indices,
411 block_count,
412 self.quantizer.profile().wire_index_bits,
413 )?;
414
415 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 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 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 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 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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
502pub struct MultiLevelResidualCodebookV1 {
503 pub levels: Vec<ResidualCodebookV1>,
505 pub total_bits: u32,
507}
508
509impl MultiLevelResidualCodebookV1 {
510 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 pub fn num_levels(&self) -> usize {
527 self.levels.len()
528 }
529}
530
531#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
538pub struct MultiLevelCode {
539 pub main_code: crate::codec::FibCodeV1,
541 pub residual_indices: Vec<u8>,
543 pub residual_bits: Vec<u8>,
545}
546
547#[derive(Debug, Clone)]
557pub struct FibMultiLevelQuantizer {
558 quantizer: crate::codec::FibQuantizer,
559 residual_codebooks: Vec<ResidualCodebookV1>,
560}
561
562impl FibMultiLevelQuantizer {
563 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 let training_vectors = generate_training_vectors(&profile)?;
607
608 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 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 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)), )?;
639
640 residual_codebooks.push(cb);
641 }
642
643 Ok(Self {
644 quantizer,
645 residual_codebooks,
646 })
647 }
648
649 pub fn quantizer(&self) -> &crate::codec::FibQuantizer {
651 &self.quantizer
652 }
653
654 pub fn residual_codebooks(&self) -> &[ResidualCodebookV1] {
656 &self.residual_codebooks
657 }
658
659 pub fn num_levels(&self) -> usize {
661 1 + self.residual_codebooks.len()
662 }
663
664 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 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 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 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 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 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 let mut residual: Vec<f32> = block
739 .iter()
740 .zip(main_cw.iter())
741 .map(|(a, b)| a - b)
742 .collect();
743
744 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 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 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 let main_indices = unpack_indices(
784 &code.main_code.indices,
785 block_count,
786 self.quantizer.profile().wire_index_bits,
787 )?;
788
789 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 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 all_residual_indices.push(vec![0u32; block_count]);
820 }
821 }
822
823 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 let mut block_recon: Vec<f32> = main_cw.to_vec();
833
834 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 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 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
867fn 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
894fn 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 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 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 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 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 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 let mut codewords = Vec::with_capacity(n * k);
995 for cw in ¢roids {
996 codewords.extend_from_slice(cw);
997 }
998 Ok(codewords)
999}
1000
1001fn 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, );
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 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
1039fn 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 let mut residual: Vec<f32> = block
1077 .iter()
1078 .zip(main_cw.iter())
1079 .map(|(a, b)| a - b)
1080 .collect();
1081
1082 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
1098fn 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 let single_cos = rq.quantizer().cosine_similarity(&input)?;
1211
1212 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 #[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 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 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 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 let training_vectors = generate_training_vectors(&profile)?;
1306
1307 let random_cb = ResidualCodebookV1::build(&profile, quantizer.codebook())?;
1309
1310 let trained_cb =
1312 ResidualCodebookV1::train(&profile, quantizer.codebook(), &training_vectors, 8)?;
1313
1314 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 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 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}