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
17pub const COMPACT_MAGIC: [u8; 3] = [b'F', b'B', b'1'];
21pub const COMPACT_VERSION: u8 = 1;
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct FibCodeV1 {
26 pub schema_version: String,
28 pub profile_digest: String,
30 pub codebook_digest: String,
32 pub rotation_digest: String,
34 pub ambient_dim: u32,
36 pub block_dim: u32,
38 pub norm_format: NormFormat,
40 pub norm_payload: Vec<u8>,
42 pub wire_index_bits: u8,
44 pub block_count: u32,
46 pub indices: Vec<u8>,
48}
49
50impl FibCodeV1 {
51 pub fn to_compact_bytes(&self) -> Vec<u8> {
75 let mut out = Vec::with_capacity(11 + self.norm_payload.len() + self.indices.len());
76 out.extend_from_slice(&COMPACT_MAGIC);
77 out.push(COMPACT_VERSION);
78 out.push(self.wire_index_bits);
79 out.extend_from_slice(&self.block_count.to_le_bytes());
80 let norm_len = self.norm_payload.len() as u16;
81 out.extend_from_slice(&norm_len.to_le_bytes());
82 out.extend_from_slice(&self.norm_payload);
83 out.extend_from_slice(&self.indices);
84 out
85 }
86
87 pub fn from_compact_bytes(bytes: &[u8], profile: &FibQuantProfileV1) -> Result<Self> {
95 if bytes.len() < 11 {
96 return Err(FibQuantError::CorruptPayload(format!(
97 "compact FibCodeV1 too short: {} bytes (need >= 11)",
98 bytes.len()
99 )));
100 }
101 if bytes[0..3] != COMPACT_MAGIC {
102 return Err(FibQuantError::CorruptPayload(format!(
103 "compact FibCodeV1 bad magic: {:?} (expected {:?})",
104 &bytes[0..3],
105 COMPACT_MAGIC
106 )));
107 }
108 if bytes[3] != COMPACT_VERSION {
109 return Err(FibQuantError::CorruptPayload(format!(
110 "compact FibCodeV1 version {} not supported (need {})",
111 bytes[3], COMPACT_VERSION
112 )));
113 }
114 let wire_index_bits = bytes[4];
115 let block_count = u32::from_le_bytes([bytes[5], bytes[6], bytes[7], bytes[8]]);
116 let norm_len = u16::from_le_bytes([bytes[9], bytes[10]]) as usize;
117 let header_len = 11;
118 if bytes.len() < header_len + norm_len {
119 return Err(FibQuantError::CorruptPayload(format!(
120 "compact FibCodeV1 truncated: norm_len={} but only {} bytes remain",
121 norm_len,
122 bytes.len() - header_len
123 )));
124 }
125 let norm_payload = bytes[header_len..header_len + norm_len].to_vec();
126 let indices = bytes[header_len + norm_len..].to_vec();
127
128 let expected_packed_len = (block_count as usize)
130 .checked_mul(wire_index_bits as usize)
131 .map(|bits| (bits + 7) / 8)
132 .ok_or_else(|| {
133 FibQuantError::ResourceLimitExceeded("packed index bits overflow".into())
134 })?;
135 if indices.len() != expected_packed_len {
136 return Err(FibQuantError::CorruptPayload(format!(
137 "compact FibCodeV1 indices: got {} bytes, expected {} (block_count={} * wire_index_bits={})",
138 indices.len(),
139 expected_packed_len,
140 block_count,
141 wire_index_bits
142 )));
143 }
144
145 let profile_digest = profile.digest()?;
152 Ok(FibCodeV1 {
153 schema_version: CODE_SCHEMA.into(),
154 profile_digest,
155 codebook_digest: String::new(),
160 rotation_digest: String::new(),
161 ambient_dim: profile.ambient_dim,
162 block_dim: profile.block_dim,
163 norm_format: profile.norm_format.clone(),
164 norm_payload,
165 wire_index_bits,
166 block_count,
167 indices,
168 })
169 }
170
171 pub fn compact_size(&self) -> usize {
173 11 + self.norm_payload.len() + self.indices.len()
174 }
175}
176
177#[derive(Debug, Clone)]
179pub struct FibQuantizer {
180 profile: FibQuantProfileV1,
181 codebook: FibCodebookV1,
182 rotation: StoredRotation,
183}
184
185impl FibQuantizer {
186 pub fn new(profile: FibQuantProfileV1) -> Result<Self> {
188 let codebook = FibCodebookV1::build(profile)?;
189 Self::from_codebook(codebook)
190 }
191
192 pub fn from_codebook(codebook: FibCodebookV1) -> Result<Self> {
194 codebook.validate()?;
195 let profile = codebook.profile.clone();
196 let rotation = StoredRotation::new(profile.ambient_dim as usize, profile.rotation_seed)?;
197 Ok(Self {
198 profile,
199 codebook,
200 rotation,
201 })
202 }
203
204 pub fn profile(&self) -> &FibQuantProfileV1 {
206 &self.profile
207 }
208
209 pub fn codebook(&self) -> &FibCodebookV1 {
211 &self.codebook
212 }
213
214 pub fn encode(&self, x: &[f32]) -> Result<FibCodeV1> {
216 let d = self.profile.ambient_dim as usize;
217 let k = self.profile.block_dim as usize;
218 if x.len() != d {
219 return Err(FibQuantError::CorruptPayload(format!(
220 "input dimension {}, expected {d}",
221 x.len()
222 )));
223 }
224 check_finite(x)?;
225 let norm = l2_norm(x);
226 if norm == 0.0 {
227 return Err(FibQuantError::ZeroNorm);
228 }
229 let normalized: Vec<f64> = x.iter().map(|value| f64::from(*value) / norm).collect();
232 let rotated_f64 = self.rotation.apply(&normalized)?;
233 let rotated_f32: Vec<f32> = rotated_f64.iter().map(|&v| v as f32).collect();
234 let block_count = self.profile.block_count() as usize;
235 let mut indices = Vec::with_capacity(block_count);
236 for block in rotated_f32.chunks_exact(k) {
237 indices.push(gpu_backend::nearest_codeword_f32(block, &self.codebook.codewords, k) as u32);
238 }
239 Ok(FibCodeV1 {
240 schema_version: CODE_SCHEMA.into(),
241 profile_digest: self.profile.digest()?,
242 codebook_digest: self.codebook.codebook_digest.clone(),
243 rotation_digest: self.rotation.digest()?,
244 ambient_dim: self.profile.ambient_dim,
245 block_dim: self.profile.block_dim,
246 norm_format: self.profile.norm_format.clone(),
247 norm_payload: encode_norm(norm, &self.profile.norm_format)?,
248 wire_index_bits: self.profile.wire_index_bits,
249 block_count: self.profile.block_count(),
250 indices: pack_indices(&indices, self.profile.wire_index_bits)?,
251 })
252 }
253
254 pub fn decode(&self, code: &FibCodeV1) -> Result<Vec<f32>> {
256 self.validate_code_header(code)?;
257 let k = self.profile.block_dim as usize;
258 let block_count = self.profile.block_count() as usize;
259 let unpacked = unpack_indices(&code.indices, block_count, self.profile.wire_index_bits)?;
260 let mut rotated = Vec::with_capacity(self.profile.ambient_dim as usize);
261 for index in unpacked {
262 if index >= self.profile.codebook_size {
263 return Err(FibQuantError::IndexOutOfRange {
264 index,
265 codebook_size: self.profile.codebook_size,
266 });
267 }
268 rotated.extend(self.codebook.codeword(index as usize)?);
269 }
270 let expected_rotated_len = block_count.checked_mul(k).ok_or_else(|| {
271 FibQuantError::ResourceLimitExceeded("decoded rotated vector length overflow".into())
272 })?;
273 if rotated.len() != expected_rotated_len {
274 return Err(FibQuantError::CorruptPayload(
275 "decoded rotated vector length mismatch".into(),
276 ));
277 }
278 let norm = decode_norm(&code.norm_payload, &code.norm_format)?;
279 let reconstructed = self.rotation.apply_inverse(&rotated)?;
280 let out: Vec<f32> = reconstructed
281 .into_iter()
282 .map(|value| (value * norm) as f32)
283 .collect();
284 check_finite(&out)?;
285 Ok(out)
286 }
287
288 pub fn encode_with_receipt(
290 &self,
291 x: &[f32],
292 ) -> Result<(FibCodeV1, FibQuantCompressionReceiptV1)> {
293 let code = self.encode(x)?;
294 let source_vector_digest = source_vector_digest(x)?;
295 let mut receipt = FibQuantCompressionReceiptV1::new(
296 &self.profile,
297 code.profile_digest.clone(),
298 code.codebook_digest.clone(),
299 code.rotation_digest.clone(),
300 source_vector_digest,
301 encoded_digest(&code)?,
302 );
303 let decoded = self.decode(&code)?;
304 receipt.mse = Some(metrics::mse(x, &decoded)?);
305 receipt.cosine_similarity = Some(metrics::cosine_similarity(x, &decoded)?);
306 Ok((code, receipt))
307 }
308
309 pub fn reconstruction_mse(&self, x: &[f32]) -> Result<f64> {
311 let code = self.encode(x)?;
312 let decoded = self.decode(&code)?;
313 metrics::mse(x, &decoded)
314 }
315
316 pub fn cosine_similarity(&self, x: &[f32]) -> Result<f64> {
318 let code = self.encode(x)?;
319 let decoded = self.decode(&code)?;
320 metrics::cosine_similarity(x, &decoded)
321 }
322
323 pub fn encode_batch(&self, vectors: &[&[f32]]) -> Result<Vec<FibCodeV1>> {
328 let d = self.profile.ambient_dim as usize;
329 let k = self.profile.block_dim as usize;
330 let n = vectors.len();
331 if n == 0 {
332 return Ok(vec![]);
333 }
334
335 if n < 4 {
337 return vectors.iter().map(|v| self.encode(v)).collect();
338 }
339
340 let mut flat = Vec::with_capacity(n * d);
342 let mut norms_f64 = Vec::with_capacity(n);
343 for v in vectors {
344 if v.len() != d {
345 return Err(FibQuantError::CorruptPayload(format!(
346 "input dimension {}, expected {d}",
347 v.len()
348 )));
349 }
350 check_finite(v)?;
351 let norm = l2_norm(v);
352 if norm == 0.0 {
353 return Err(FibQuantError::ZeroNorm);
354 }
355 norms_f64.push(norm);
356 for &x in *v {
357 flat.push((x as f64 / norm) as f32);
358 }
359 }
360
361 #[cfg(feature = "gpu")]
363 {
364 if let Some(_ctx) = gpu_backend::GpuContext::init() {
365 if n >= gpu_backend::GpuContext::GPU_MIN_BATCH_SIZE
366 && d >= gpu_backend::GpuContext::GPU_MIN_DIM
367 {
368 gpu_backend::hadamard_batch(&mut flat, n, d, self.profile.rotation_seed)
369 .map_err(|e| {
370 FibQuantError::NumericalFailure(format!("gpu hadamard: {}", e))
371 })?;
372
373 #[cfg(feature = "gpu_codebook_lookup")]
384 {
385 let block_count = self.profile.block_count() as usize;
386 if let Ok(indices) = gpu_backend::codebook_lookup_batch(
387 &flat,
388 &self.codebook.codewords,
389 n,
390 d,
391 k,
392 ) {
393 if indices.len() == n * block_count {
394 return self.finish_batch_encode_with_indices(
395 &flat, &norms_f64, &indices, n, d, k,
396 );
397 }
398 }
400 }
401
402 return self.finish_batch_encode(&flat, &norms_f64, n, d, k);
404 }
405 }
406 }
407
408 let mut rotated_flat = Vec::with_capacity(n * d);
410 for chunk in flat.chunks_exact(d) {
411 let f64_chunk: Vec<f64> = chunk.iter().map(|&v| v as f64).collect();
412 let rot = self.rotation.apply(&f64_chunk)?;
413 rotated_flat.extend(rot.iter().map(|&v| v as f32));
414 }
415
416 self.finish_batch_encode(&rotated_flat, &norms_f64, n, d, k)
417 }
418
419 fn finish_batch_encode(
420 &self,
421 rotated: &[f32],
422 norms: &[f64],
423 n: usize,
424 d: usize,
425 k: usize,
426 ) -> Result<Vec<FibCodeV1>> {
427 let profile_digest = self.profile.digest()?;
431 let codebook_digest = self.codebook.codebook_digest.clone();
432 let rotation_digest = self.rotation.digest()?;
433 let profile = &self.profile;
434 let codewords_f32: &[f32] = &self.codebook.codewords;
435
436 let per_vec = |vec_idx: usize| -> Result<FibCodeV1> {
440 let start = vec_idx * d;
441 let chunk = &rotated[start..start + d];
442 let mut indices = Vec::with_capacity(profile.block_count() as usize);
443 for block in chunk.chunks_exact(k) {
444 indices.push(gpu_backend::nearest_codeword_f32(block, codewords_f32, k) as u32);
445 }
446 Ok(FibCodeV1 {
447 schema_version: CODE_SCHEMA.into(),
448 profile_digest: profile_digest.clone(),
449 codebook_digest: codebook_digest.clone(),
450 rotation_digest: rotation_digest.clone(),
451 ambient_dim: profile.ambient_dim,
452 block_dim: profile.block_dim,
453 norm_format: profile.norm_format.clone(),
454 norm_payload: encode_norm(norms[vec_idx], &profile.norm_format)?,
455 wire_index_bits: profile.wire_index_bits,
456 block_count: profile.block_count(),
457 indices: pack_indices(&indices, profile.wire_index_bits)?,
458 })
459 };
460
461 #[cfg(feature = "parallel")]
465 {
466 const RAYON_MIN_N: usize = 16;
467 if n >= RAYON_MIN_N {
468 use rayon::prelude::*;
469 return (0..n).into_par_iter().map(per_vec).collect();
470 }
471 }
472
473 let mut codes = Vec::with_capacity(n);
474 for vec_idx in 0..n {
475 codes.push(per_vec(vec_idx)?);
476 }
477 Ok(codes)
478 }
479
480 #[cfg(all(feature = "gpu", feature = "gpu_codebook_lookup"))]
484 fn finish_batch_encode_with_indices(
485 &self,
486 _rotated: &[f32], norms: &[f64],
488 indices: &[u32],
489 n: usize,
490 _d: usize,
491 _k: usize,
492 ) -> Result<Vec<FibCodeV1>> {
493 let block_count = self.profile.block_count() as usize;
494 if indices.len() != n * block_count {
495 return Err(FibQuantError::CorruptPayload(format!(
496 "indices length {} != n * block_count {}",
497 indices.len(),
498 n * block_count
499 )));
500 }
501
502 let mut codes = Vec::with_capacity(n);
503 for vec_idx in 0..n {
504 let start = vec_idx * block_count;
505 let end = start + block_count;
506 let vec_indices: Vec<u32> = indices[start..end].to_vec();
507
508 codes.push(FibCodeV1 {
509 schema_version: CODE_SCHEMA.into(),
510 profile_digest: self.profile.digest()?,
511 codebook_digest: self.codebook.codebook_digest.clone(),
512 rotation_digest: self.rotation.digest()?,
513 ambient_dim: self.profile.ambient_dim,
514 block_dim: self.profile.block_dim,
515 norm_format: self.profile.norm_format.clone(),
516 norm_payload: encode_norm(norms[vec_idx], &self.profile.norm_format)?,
517 wire_index_bits: self.profile.wire_index_bits,
518 block_count: self.profile.block_count(),
519 indices: pack_indices(&vec_indices, self.profile.wire_index_bits)?,
520 });
521 }
522 Ok(codes)
523 }
524
525 pub fn decode_batch(&self, codes: &[FibCodeV1]) -> Result<Vec<Vec<f32>>> {
527 codes.iter().map(|c| self.decode(c)).collect()
528 }
529
530 pub fn decode_batch_fast(&self, codes: &[FibCodeV1]) -> Result<Vec<Vec<f32>>> {
547 if codes.is_empty() {
548 return Ok(Vec::new());
549 }
550 let d = self.profile.ambient_dim as usize;
551 let k = self.profile.block_dim as usize;
552 let codebook_size = self.profile.codebook_size as usize;
553 let codewords = &self.codebook.codewords;
554 let mut out = Vec::with_capacity(codes.len());
555 for code in codes {
556 self.validate_code_header(code)?;
557 let block_count = self.profile.block_count() as usize;
558 let unpacked = unpack_indices(&code.indices, block_count, self.profile.wire_index_bits)?;
559 let expected_len = block_count.checked_mul(k).ok_or_else(|| {
560 FibQuantError::ResourceLimitExceeded("decoded rotated vector length overflow".into())
561 })?;
562 let mut rotated_f32: Vec<f32> = Vec::with_capacity(expected_len);
564 for &index in &unpacked {
565 let idx = index as usize;
566 if idx >= codebook_size {
567 return Err(FibQuantError::IndexOutOfRange {
568 index,
569 codebook_size: codebook_size as u32,
570 });
571 }
572 let base = idx * k;
573 rotated_f32.extend_from_slice(&codewords[base..base + k]);
575 }
576 debug_assert_eq!(rotated_f32.len(), expected_len);
577 let norm = decode_norm(&code.norm_payload, &code.norm_format)?;
578 let reconstructed = self.rotation.apply_inverse_f32(&rotated_f32)?;
583 let scaled: Vec<f32> = reconstructed
584 .into_iter()
585 .map(|value| (value * norm as f32))
586 .collect();
587 check_finite(&scaled)?;
588 out.push(scaled);
589 }
590 Ok(out)
591 }
592
593 pub fn is_gpu_accelerated(&self) -> bool {
602 #[cfg(feature = "gpu")]
603 {
604 gpu_backend::GpuContext::is_available()
605 }
606 #[cfg(not(feature = "gpu"))]
607 {
608 false
609 }
610 }
611
612 pub fn is_gpu_accelerated_for(&self, n: usize, d: usize) -> bool {
625 #[cfg(feature = "gpu")]
626 {
627 if !gpu_backend::GpuContext::is_available() {
628 return false;
629 }
630 n >= gpu_backend::GpuContext::GPU_MIN_BATCH_SIZE
631 && d >= gpu_backend::GpuContext::GPU_MIN_DIM
632 && (self.profile.codebook_size as usize) <= 32
633 }
634 #[cfg(not(feature = "gpu"))]
635 {
636 let _ = (n, d);
637 false
638 }
639 }
640
641 pub fn gpu_steps_for(&self, n: usize, d: usize) -> GpuStepReport {
649 let device_available = {
650 #[cfg(feature = "gpu")]
651 {
652 gpu_backend::GpuContext::is_available()
653 }
654 #[cfg(not(feature = "gpu"))]
655 {
656 false
657 }
658 };
659 const MIN_BATCH: usize = 16;
662 const MIN_DIM: usize = 64;
663 let clears_thresholds = n >= MIN_BATCH && d >= MIN_DIM;
664 let codebook_fits = (self.profile.codebook_size as usize) <= 32;
665 GpuStepReport {
666 hadamard: device_available && clears_thresholds,
667 codebook_lookup: device_available && clears_thresholds && codebook_fits,
668 }
669 }
670
671 fn validate_code_header(&self, code: &FibCodeV1) -> Result<()> {
674 if code.schema_version != CODE_SCHEMA {
675 return Err(FibQuantError::CorruptPayload(format!(
676 "code schema_version {}, expected {CODE_SCHEMA}",
677 code.schema_version
678 )));
679 }
680 let expected_profile = self.profile.digest()?;
681 if code.profile_digest != expected_profile {
682 return Err(FibQuantError::ProfileDigestMismatch {
683 expected: expected_profile,
684 actual: code.profile_digest.clone(),
685 });
686 }
687 if !code.codebook_digest.is_empty()
694 && code.codebook_digest != self.codebook.codebook_digest
695 {
696 return Err(FibQuantError::CodebookDigestMismatch {
697 expected: self.codebook.codebook_digest.clone(),
698 actual: code.codebook_digest.clone(),
699 });
700 }
701 let expected_rotation = self.rotation.digest()?;
702 if !code.rotation_digest.is_empty()
703 && (code.rotation_digest != expected_rotation
704 || code.rotation_digest != self.codebook.rotation_digest)
705 {
706 return Err(FibQuantError::RotationDigestMismatch {
707 expected: expected_rotation,
708 actual: code.rotation_digest.clone(),
709 });
710 }
711 if code.ambient_dim != self.profile.ambient_dim
712 || code.block_dim != self.profile.block_dim
713 || code.block_count != self.profile.block_count()
714 || code.wire_index_bits != self.profile.wire_index_bits
715 || code.norm_format != self.profile.norm_format
716 {
717 return Err(FibQuantError::CorruptPayload(
718 "encoded header does not match profile".into(),
719 ));
720 }
721 Ok(())
722 }
723}
724
725pub fn encoded_digest(code: &FibCodeV1) -> Result<String> {
727 json_digest(CODE_SCHEMA, code)
728}
729
730fn source_vector_digest(x: &[f32]) -> Result<String> {
731 check_finite(x)?;
732 let mut bytes = Vec::with_capacity(32 + std::mem::size_of_val(x));
733 bytes.extend_from_slice(b"fib_quant_source_vector_v1");
734 bytes.push(0);
735 bytes.extend_from_slice(&(x.len() as u64).to_le_bytes());
736 for value in x {
737 bytes.extend_from_slice(&value.to_le_bytes());
738 }
739 Ok(bytes_digest(&bytes))
740}
741
742fn encode_norm(norm: f64, format: &NormFormat) -> Result<Vec<u8>> {
743 if !norm.is_finite() || norm <= 0.0 {
744 return Err(FibQuantError::CorruptPayload(
745 "norm must be finite and positive".into(),
746 ));
747 }
748 match format {
749 NormFormat::Fp16Paper => {
750 let narrowed = f16::from_f32(norm as f32);
751 if !narrowed.is_finite() || narrowed <= f16::ZERO {
752 return Err(FibQuantError::CorruptPayload(
753 "norm cannot be represented as finite positive fp16".into(),
754 ));
755 }
756 Ok(narrowed.to_le_bytes().to_vec())
757 }
758 NormFormat::F32Reference => {
759 let narrowed = norm as f32;
760 if !narrowed.is_finite() || narrowed <= 0.0 {
761 return Err(FibQuantError::CorruptPayload(
762 "norm cannot be represented as finite positive f32".into(),
763 ));
764 }
765 Ok(narrowed.to_le_bytes().to_vec())
766 }
767 }
768}
769
770fn decode_norm(bytes: &[u8], format: &NormFormat) -> Result<f64> {
771 match format {
772 NormFormat::Fp16Paper => {
773 let bytes: [u8; 2] = bytes
774 .try_into()
775 .map_err(|_| FibQuantError::CorruptPayload("fp16 norm length".into()))?;
776 let value = f16::from_le_bytes(bytes).to_f32() as f64;
777 if value.is_finite() && value > 0.0 {
778 Ok(value)
779 } else {
780 Err(FibQuantError::CorruptPayload("invalid fp16 norm".into()))
781 }
782 }
783 NormFormat::F32Reference => {
784 let bytes: [u8; 4] = bytes
785 .try_into()
786 .map_err(|_| FibQuantError::CorruptPayload("f32 norm length".into()))?;
787 let value = f32::from_le_bytes(bytes) as f64;
788 if value.is_finite() && value > 0.0 {
789 Ok(value)
790 } else {
791 Err(FibQuantError::CorruptPayload("invalid f32 norm".into()))
792 }
793 }
794 }
795}
796
797fn l2_norm(x: &[f32]) -> f64 {
798 x.iter()
799 .map(|value| {
800 let value = f64::from(*value);
801 value * value
802 })
803 .sum::<f64>()
804 .sqrt()
805}
806
807fn check_finite(x: &[f32]) -> Result<()> {
808 if let Some((idx, _)) = x.iter().enumerate().find(|(_, value)| !value.is_finite()) {
809 return Err(FibQuantError::NonFiniteInput(idx));
810 }
811 Ok(())
812}
813
814#[cfg(test)]
815mod tests {
816 use super::*;
817
818 #[test]
819 fn f32_norm_overflow_rejects_before_payload_emit() {
820 let err = encode_norm(f64::MAX, &NormFormat::F32Reference).unwrap_err();
821 assert!(matches!(err, FibQuantError::CorruptPayload(message) if message.contains("f32")));
822 }
823
824 #[test]
825 fn f32_norm_underflow_rejects_before_payload_emit() {
826 let err = encode_norm(
827 f64::from(f32::from_bits(1)) / 2.0,
828 &NormFormat::F32Reference,
829 )
830 .unwrap_err();
831 assert!(matches!(err, FibQuantError::CorruptPayload(message) if message.contains("f32")));
832 }
833}
834
835#[derive(Debug, Clone, Copy, PartialEq, Eq)]
837pub struct GpuStepReport {
838 pub hadamard: bool,
840 pub codebook_lookup: bool,
842}