Skip to main content

hurray_core/quantization/
mod.rs

1//! Quantization descriptor types for the Hurray tensor format.
2//!
3//! Every quantized tensor carries a **quantization descriptor** immediately
4//! following the tensor descriptor header. The descriptor starts with a 4-byte
5//! header (scheme tag, scheme version, flags) followed by a scheme-specific
6//! payload.
7//!
8//! ## Supported schemes
9//!
10//! | Scheme tag | Tier | Type |
11//! |------------|------|------|
12//! | `0x01` | 1 | [`PerTensorAffine`] |
13//! | `0x02` | 1 | [`PerChannelAffine`] |
14//! | `0x03` | 1 | [`PerBlockAffine`] |
15//! | `0x04` | 1 | [`Nf4`] |
16//! | `0x05` | 1 | [`Mxfp`] |
17//!
18//! ## Encoding overview
19//!
20//! The canonical encoding path is:
21//!
22//! 1. Call [`QuantizationDescriptor::encode_into`] to write into a caller-owned
23//!    buffer (zero-alloc, required by Layer 5 streaming writers).
24//! 2. Or call [`QuantizationDescriptor::encode_to_vec`] for a convenience
25//!    `Vec<u8>`.
26//!
27//! The canonical decoding path is:
28//!
29//! - Call [`QuantizationDescriptor::decode`] which returns the descriptor and
30//!   the number of bytes consumed, allowing streaming readers to advance their
31//!   cursor without re-scanning.
32//!
33//! See `docs/spec/quantization.md` for the normative definition.
34
35pub mod mxfp;
36pub mod nf4;
37pub mod per_block_affine;
38pub mod per_channel_affine;
39pub mod per_tensor_affine;
40
41// Scheme constants carry their scheme prefix: three schemes each define a minimum block
42// size with a different value, so an unprefixed re-export would silently privilege one.
43pub use mxfp::{Mxfp, MXFP_CANONICAL_BLOCK_SIZE, MXFP_MAX_BLOCK_SIZE, MXFP_MIN_BLOCK_SIZE};
44pub use nf4::{Nf4, NF4_LUT, NF4_MIN_BLOCK_SIZE};
45pub use per_block_affine::{PerBlockAffine, PER_BLOCK_AFFINE_MIN_BLOCK_SIZE};
46pub use per_channel_affine::PerChannelAffine;
47pub use per_tensor_affine::PerTensorAffine;
48
49use crate::{BufferHandle, ElementType, Error, Result};
50
51// ── Scheme tag ranges ─────────────────────────────────────────────────────────
52
53// Tier 2 assigned range: 0x40–0x5F
54const TIER2_MIN: u8 = 0x40;
55const TIER2_MAX: u8 = 0x5F;
56
57// Reserved range: 0x60–0xEF
58const RESERVED_MIN: u8 = 0x60;
59const RESERVED_MAX: u8 = 0xEF;
60
61// Private range: 0xF0–0xFE
62const PRIVATE_MIN: u8 = 0xF0;
63const PRIVATE_MAX: u8 = 0xFE;
64
65// ── QuantizationSchemeTag ─────────────────────────────────────────────────────
66
67/// The wire scheme tag that identifies which quantization scheme a descriptor uses.
68///
69/// The tag byte occupies offset `0` of the quantization descriptor. It determines
70/// both the payload layout and the set of valid storage types for the tensor.
71///
72/// # Tag ranges
73///
74/// | Range | Meaning |
75/// |-------|---------|
76/// | `0x01`–`0x05` | Tier 1 — assigned by this spec version |
77/// | `0x06`–`0x3F` | Unallocated Tier 1 — reader MUST treat as unknown |
78/// | `0x40`–`0x5F` | Tier 2 — reserved for future assignment |
79/// | `0x60`–`0xEF` | Reserved — reader MUST reject |
80/// | `0xF0`–`0xFE` | Private — reader MUST reject (unconstrained payload) |
81/// | `0x00`, `0xFF` | Permanently invalid — reader MUST reject |
82///
83/// # Examples
84///
85/// ```
86/// use hurray_core::QuantizationSchemeTag;
87///
88/// assert_eq!(QuantizationSchemeTag::PerTensorAffine.tag(), 0x01);
89/// assert_eq!(QuantizationSchemeTag::Mxfp.tag(), 0x05);
90/// assert_eq!(QuantizationSchemeTag::PerTensorAffine.tier(), 1);
91/// assert_eq!(QuantizationSchemeTag::Mxfp.tier(), 2);
92/// ```
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
94#[repr(u8)]
95pub enum QuantizationSchemeTag {
96    /// Per-tensor affine quantization. Tag `0x01`, Tier 1.
97    PerTensorAffine = 0x01,
98    /// Per-channel affine quantization. Tag `0x02`, Tier 1.
99    PerChannelAffine = 0x02,
100    /// Per-block affine quantization. Tag `0x03`, Tier 1.
101    PerBlockAffine = 0x03,
102    /// NF4 (NormalFloat4) block quantization. Tag `0x04`, Tier 1.
103    Nf4 = 0x04,
104    /// MXFP (OCP Microscaling) block quantization. Tag `0x05`, Tier 1.
105    Mxfp = 0x05,
106}
107
108impl QuantizationSchemeTag {
109    /// Returns the one-byte wire representation of this scheme tag.
110    ///
111    /// # Examples
112    ///
113    /// ```
114    /// use hurray_core::QuantizationSchemeTag;
115    ///
116    /// assert_eq!(QuantizationSchemeTag::PerTensorAffine.tag(), 0x01);
117    /// assert_eq!(QuantizationSchemeTag::Mxfp.tag(), 0x05);
118    /// ```
119    #[inline]
120    pub fn tag(self) -> u8 {
121        self as u8
122    }
123
124    /// Returns the tier of this scheme: `1` for mandatory schemes, `2` for optional schemes.
125    ///
126    /// Per `docs/spec/quantization.md` § Tag Assignment Table:
127    /// - Tier 1 (`MUST` support): `PerTensorAffine`, `PerChannelAffine`, `PerBlockAffine`.
128    /// - Tier 2 (OPTIONAL): `Nf4`, `Mxfp`.
129    ///
130    /// Note: NF4 (`0x04`) and MXFP (`0x05`) have wire tags in the `0x01`–`0x3F` numeric
131    /// range but are designated Tier 2 by the spec scheme table. Tier is a support-obligation
132    /// property, not a simple function of the tag byte.
133    ///
134    /// # Examples
135    ///
136    /// ```
137    /// use hurray_core::QuantizationSchemeTag;
138    ///
139    /// assert_eq!(QuantizationSchemeTag::PerTensorAffine.tier(), 1);
140    /// assert_eq!(QuantizationSchemeTag::PerBlockAffine.tier(), 1);
141    /// assert_eq!(QuantizationSchemeTag::Nf4.tier(), 2);
142    /// assert_eq!(QuantizationSchemeTag::Mxfp.tier(), 2);
143    /// ```
144    #[inline]
145    pub fn tier(self) -> u8 {
146        // WHY explicit match: NF4/MXFP are Tier 2 per spec scheme table despite having
147        // tags in the 0x01–0x3F numeric range; a simple byte threshold would be wrong.
148        match self {
149            Self::PerTensorAffine | Self::PerChannelAffine | Self::PerBlockAffine => 1,
150            Self::Nf4 | Self::Mxfp => 2,
151        }
152    }
153
154    /// Parses a [`QuantizationSchemeTag`] from its one-byte wire representation.
155    ///
156    /// # Errors
157    ///
158    /// - [`Error::InvalidQuantizationSchemeTag`] — byte is `0x00` or `0xFF`
159    ///   (permanently reserved).
160    /// - [`Error::ReservedQuantizationSchemeTag`] — byte is in `0x60`–`0xEF`
161    ///   (reserved for future specification versions).
162    /// - [`Error::PrivateQuantizationSchemeTag`] — byte is in `0xF0`–`0xFE`.
163    ///   Private tags have unconstrained payloads beyond the 4-byte header;
164    ///   callers that need private scheme support must handle raw bytes at a
165    ///   higher layer (design decision #1).
166    /// - [`Error::UnknownQuantizationSchemeTag`] — byte is in `0x06`–`0x3F`
167    ///   (unallocated Tier 1 range) or in `0x40`–`0x5F` but not yet assigned.
168    ///
169    /// # Examples
170    ///
171    /// ```
172    /// use hurray_core::{QuantizationSchemeTag, Error};
173    ///
174    /// assert_eq!(
175    ///     QuantizationSchemeTag::from_byte(0x01).unwrap(),
176    ///     QuantizationSchemeTag::PerTensorAffine
177    /// );
178    /// assert!(matches!(
179    ///     QuantizationSchemeTag::from_byte(0x00),
180    ///     Err(Error::InvalidQuantizationSchemeTag(0x00))
181    /// ));
182    /// assert!(matches!(
183    ///     QuantizationSchemeTag::from_byte(0x60),
184    ///     Err(Error::ReservedQuantizationSchemeTag(0x60))
185    /// ));
186    /// assert!(matches!(
187    ///     QuantizationSchemeTag::from_byte(0xF0),
188    ///     Err(Error::PrivateQuantizationSchemeTag(0xF0))
189    /// ));
190    /// assert!(matches!(
191    ///     QuantizationSchemeTag::from_byte(0x06),
192    ///     Err(Error::UnknownQuantizationSchemeTag(0x06))
193    /// ));
194    /// ```
195    pub fn from_byte(b: u8) -> Result<Self> {
196        match b {
197            // Permanently invalid sentinels.
198            0x00 | 0xFF => Err(Error::InvalidQuantizationSchemeTag(b)),
199
200            // Tier 1 assigned range.
201            0x01 => Ok(Self::PerTensorAffine),
202            0x02 => Ok(Self::PerChannelAffine),
203            0x03 => Ok(Self::PerBlockAffine),
204            0x04 => Ok(Self::Nf4),
205            0x05 => Ok(Self::Mxfp),
206
207            // Unallocated range: 0x06–0x3F (arms above exhausted all assigned tags 0x01–0x05).
208            b if (0x06..=0x3F).contains(&b) => Err(Error::UnknownQuantizationSchemeTag(b)),
209
210            // Future Tier 2 numeric range: 0x40–0x5F.
211            // No tags in this range are currently assigned.
212            b if (TIER2_MIN..=TIER2_MAX).contains(&b) => {
213                Err(Error::UnknownQuantizationSchemeTag(b))
214            }
215
216            // Reserved range: 0x60–0xEF.
217            b if (RESERVED_MIN..=RESERVED_MAX).contains(&b) => {
218                Err(Error::ReservedQuantizationSchemeTag(b))
219            }
220
221            // Private range: 0xF0–0xFE (reject — unconstrained wire format beyond header
222            // gives callers nothing useful; design decision #1).
223            b if (PRIVATE_MIN..=PRIVATE_MAX).contains(&b) => {
224                Err(Error::PrivateQuantizationSchemeTag(b))
225            }
226
227            _ => Err(Error::UnknownQuantizationSchemeTag(b)),
228        }
229    }
230}
231
232// ── QuantizationHeader ────────────────────────────────────────────────────────
233
234/// The 4-byte common header present at the start of every quantization descriptor.
235///
236/// | Offset | Field | Type |
237/// |--------|-------|------|
238/// | 0 | `scheme_tag` | `uint8` |
239/// | 1 | `scheme_version` | `uint8` |
240/// | 2 | `flags` | `uint16` LE |
241///
242/// All multi-byte fields are little-endian.
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub(crate) struct QuantizationHeader {
245    pub scheme_tag: QuantizationSchemeTag,
246    pub scheme_version: u8,
247    pub flags: u16,
248}
249
250impl QuantizationHeader {
251    /// Minimum number of bytes required to read a header.
252    pub(crate) const LEN: usize = 4;
253
254    /// Parses a [`QuantizationHeader`] from the first 4 bytes of `bytes`.
255    ///
256    /// Validates that `scheme_version` does not exceed `supported_version`.
257    ///
258    /// # Errors
259    ///
260    /// - [`Error::QuantizationDescriptorTooShort`] — fewer than 4 bytes available.
261    /// - Scheme tag errors forwarded from [`QuantizationSchemeTag::from_byte`].
262    /// - [`Error::UnsupportedSchemeVersion`] — `scheme_version` exceeds
263    ///   `supported_version`.
264    pub(crate) fn parse(bytes: &[u8], supported_version: u8) -> Result<Self> {
265        if bytes.len() < Self::LEN {
266            return Err(Error::QuantizationDescriptorTooShort {
267                found: bytes.len(),
268                needed: Self::LEN,
269            });
270        }
271        let scheme_tag_byte = bytes[0];
272        let scheme_version = bytes[1];
273        let flags = u16::from_le_bytes([bytes[2], bytes[3]]);
274
275        let scheme_tag = QuantizationSchemeTag::from_byte(scheme_tag_byte)?;
276
277        if scheme_version > supported_version {
278            return Err(Error::UnsupportedSchemeVersion {
279                tag: scheme_tag_byte,
280                version: scheme_version,
281                supported: supported_version,
282            });
283        }
284
285        Ok(Self {
286            scheme_tag,
287            scheme_version,
288            flags,
289        })
290    }
291
292    /// Writes this header into the first 4 bytes of `out`.
293    ///
294    /// `out` must be at least 4 bytes long.
295    pub(crate) fn write(&self, out: &mut [u8]) {
296        out[0] = self.scheme_tag.tag();
297        out[1] = self.scheme_version;
298        let flags_le = self.flags.to_le_bytes();
299        out[2] = flags_le[0];
300        out[3] = flags_le[1];
301    }
302}
303
304// ── QuantizationDescriptor ────────────────────────────────────────────────────
305
306/// A typed quantization descriptor carrying the parameters for one of the
307/// supported quantization schemes.
308///
309/// # Equality and hashing
310///
311/// Only [`PartialEq`] is derived — **not** `Eq` or `Hash` — because the
312/// [`PerTensorAffine`] variant contains `f32` fields. IEEE 754 NaN != NaN, which
313/// would make `Eq` semantically unsound (design decision #2).
314///
315/// # Examples
316///
317/// ```
318/// use hurray_core::{PerTensorAffine, QuantizationDescriptor, QuantizationSchemeTag};
319///
320/// let desc = QuantizationDescriptor::PerTensorAffine(
321///     PerTensorAffine::new(0.5, 128).unwrap(),
322/// );
323/// assert_eq!(desc.scheme_tag(), QuantizationSchemeTag::PerTensorAffine);
324/// assert_eq!(desc.encoded_len(), 16);
325/// ```
326// WHY PartialEq only: f32 fields make Eq/Hash unsound due to NaN semantics
327// (design decision #2).
328#[derive(Debug, Clone, PartialEq)]
329#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
330pub enum QuantizationDescriptor {
331    /// Per-tensor affine quantization (scheme tag `0x01`).
332    PerTensorAffine(PerTensorAffine),
333    /// Per-channel affine quantization (scheme tag `0x02`).
334    PerChannelAffine(PerChannelAffine),
335    /// Per-block affine quantization (scheme tag `0x03`).
336    PerBlockAffine(PerBlockAffine),
337    /// NF4 (NormalFloat4) block quantization (scheme tag `0x04`).
338    Nf4(Nf4),
339    /// MXFP (OCP Microscaling) block quantization (scheme tag `0x05`).
340    Mxfp(Mxfp),
341}
342
343impl QuantizationDescriptor {
344    /// Returns the [`QuantizationSchemeTag`] for this descriptor.
345    ///
346    /// # Examples
347    ///
348    /// ```
349    /// use hurray_core::{Nf4, QuantizationDescriptor, QuantizationSchemeTag};
350    ///
351    /// let desc = QuantizationDescriptor::Nf4(Nf4::new(0, 64, 1).unwrap());
352    /// assert_eq!(desc.scheme_tag(), QuantizationSchemeTag::Nf4);
353    /// ```
354    pub fn scheme_tag(&self) -> QuantizationSchemeTag {
355        match self {
356            Self::PerTensorAffine(_) => QuantizationSchemeTag::PerTensorAffine,
357            Self::PerChannelAffine(_) => QuantizationSchemeTag::PerChannelAffine,
358            Self::PerBlockAffine(_) => QuantizationSchemeTag::PerBlockAffine,
359            Self::Nf4(_) => QuantizationSchemeTag::Nf4,
360            Self::Mxfp(_) => QuantizationSchemeTag::Mxfp,
361        }
362    }
363
364    /// Returns the total encoded length of this descriptor in bytes, including
365    /// the 4-byte header.
366    ///
367    /// # Examples
368    ///
369    /// ```
370    /// use hurray_core::{PerTensorAffine, PerBlockAffine, ElementType, QuantizationDescriptor};
371    ///
372    /// let pt = QuantizationDescriptor::PerTensorAffine(
373    ///     PerTensorAffine::new(1.0, 0).unwrap()
374    /// );
375    /// assert_eq!(pt.encoded_len(), 16);
376    ///
377    /// let pb = QuantizationDescriptor::PerBlockAffine(
378    ///     PerBlockAffine::new_symmetric(0, 32, 1, ElementType::Float32).unwrap()
379    /// );
380    /// assert_eq!(pb.encoded_len(), 24);
381    /// ```
382    pub fn encoded_len(&self) -> usize {
383        match self {
384            Self::PerTensorAffine(_) => per_tensor_affine::ENCODED_LEN,
385            Self::PerChannelAffine(_) => per_channel_affine::ENCODED_LEN,
386            Self::PerBlockAffine(_) => per_block_affine::ENCODED_LEN,
387            Self::Nf4(_) => nf4::ENCODED_LEN,
388            Self::Mxfp(_) => mxfp::ENCODED_LEN,
389        }
390    }
391
392    /// Encodes this descriptor into `out` without any allocation.
393    ///
394    /// This is the zero-alloc canonical encoding path required by Layer 5
395    /// streaming writers. `out` must be at least [`Self::encoded_len()`] bytes.
396    ///
397    /// Returns the number of bytes written.
398    ///
399    /// # Errors
400    ///
401    /// - [`Error::InvalidQuantization`] — `out` is shorter than `encoded_len()`.
402    ///
403    /// # Examples
404    ///
405    /// ```
406    /// use hurray_core::{PerTensorAffine, QuantizationDescriptor};
407    ///
408    /// let desc = QuantizationDescriptor::PerTensorAffine(
409    ///     PerTensorAffine::new(0.5, 0).unwrap()
410    /// );
411    /// let mut buf = vec![0u8; desc.encoded_len()];
412    /// let written = desc.encode_into(&mut buf).unwrap();
413    /// assert_eq!(written, 16);
414    ///
415    /// // Round-trip.
416    /// let (decoded, consumed) = QuantizationDescriptor::decode(&buf).unwrap();
417    /// assert_eq!(consumed, 16);
418    /// assert_eq!(decoded, desc);
419    /// ```
420    pub fn encode_into(&self, out: &mut [u8]) -> Result<usize> {
421        let len = self.encoded_len();
422        if out.len() < len {
423            return Err(Error::InvalidQuantization(format!(
424                "output buffer too short: need {len} bytes, have {}",
425                out.len()
426            )));
427        }
428
429        // Build and write the 4-byte header.
430        let (scheme_version, flags) = self.header_version_and_flags();
431        let header = QuantizationHeader {
432            scheme_tag: self.scheme_tag(),
433            scheme_version,
434            flags,
435        };
436        header.write(out);
437
438        // Write the scheme-specific payload (bytes 4 onward).
439        match self {
440            Self::PerTensorAffine(inner) => inner.encode_payload(out),
441            Self::PerChannelAffine(inner) => inner.encode_payload(out),
442            Self::PerBlockAffine(inner) => inner.encode_payload(out),
443            Self::Nf4(inner) => inner.encode_payload(out),
444            Self::Mxfp(inner) => inner.encode_payload(out),
445        }
446
447        Ok(len)
448    }
449
450    /// Encodes this descriptor into a freshly allocated `Vec<u8>`.
451    ///
452    /// This is a convenience wrapper around [`Self::encode_into`]. Prefer
453    /// [`Self::encode_into`] in hot paths to avoid allocation.
454    ///
455    /// # Examples
456    ///
457    /// ```
458    /// use hurray_core::{Nf4, QuantizationDescriptor};
459    ///
460    /// let desc = QuantizationDescriptor::Nf4(Nf4::new(0, 64, 1).unwrap());
461    /// let bytes = desc.encode_to_vec();
462    /// assert_eq!(bytes.len(), 16);
463    ///
464    /// let (decoded, _) = QuantizationDescriptor::decode(&bytes).unwrap();
465    /// assert_eq!(decoded, desc);
466    /// ```
467    pub fn encode_to_vec(&self) -> Vec<u8> {
468        let len = self.encoded_len();
469        let mut buf = vec![0u8; len];
470        // SAFETY (logic): encode_into only fails if buf is shorter than encoded_len(),
471        // and we allocate exactly encoded_len() bytes above, so this never errors.
472        self.encode_into(&mut buf)
473            .expect("encode_into into exact-sized buffer is infallible");
474        buf
475    }
476
477    /// Decodes a [`QuantizationDescriptor`] from the beginning of `bytes`.
478    ///
479    /// Returns the decoded descriptor and the number of bytes consumed.
480    /// This lets streaming readers advance their cursor without re-scanning
481    /// (design decision: returning consumed count avoids a second length query).
482    ///
483    /// # Errors
484    ///
485    /// - [`Error::QuantizationDescriptorTooShort`] — not enough bytes.
486    /// - [`Error::InvalidQuantizationSchemeTag`] — tag is `0x00` or `0xFF`.
487    /// - [`Error::ReservedQuantizationSchemeTag`] — tag is in `0x60`–`0xEF`.
488    /// - [`Error::PrivateQuantizationSchemeTag`] — tag is in `0xF0`–`0xFE`.
489    /// - [`Error::UnknownQuantizationSchemeTag`] — tag is unallocated.
490    /// - [`Error::UnsupportedSchemeVersion`] — version exceeds supported maximum.
491    /// - [`Error::ReservedQuantizationFlagsBits`] — reserved flags bits are set.
492    /// - [`Error::InvalidBlockSize`] — block_size is out of range or not a power of two.
493    /// - [`Error::InvalidQuantization`] — other payload validation failure.
494    ///
495    /// # Examples
496    ///
497    /// ```
498    /// use hurray_core::{PerTensorAffine, QuantizationDescriptor};
499    ///
500    /// let desc = QuantizationDescriptor::PerTensorAffine(
501    ///     PerTensorAffine::new(1.0, 0).unwrap()
502    /// );
503    /// let bytes = desc.encode_to_vec();
504    /// let (decoded, consumed) = QuantizationDescriptor::decode(&bytes).unwrap();
505    /// assert_eq!(consumed, bytes.len());
506    /// assert_eq!(decoded, desc);
507    /// ```
508    pub fn decode(bytes: &[u8]) -> Result<(Self, usize)> {
509        // Peek at the scheme tag first so we know the supported version to pass.
510        if bytes.is_empty() {
511            return Err(Error::QuantizationDescriptorTooShort {
512                found: 0,
513                needed: QuantizationHeader::LEN,
514            });
515        }
516        let tag_byte = bytes[0];
517        let tag = QuantizationSchemeTag::from_byte(tag_byte)?;
518        let supported_version = supported_version_for(tag);
519
520        let header = QuantizationHeader::parse(bytes, supported_version)?;
521        let descriptor = match tag {
522            QuantizationSchemeTag::PerTensorAffine => Self::PerTensorAffine(
523                per_tensor_affine::PerTensorAffine::decode_payload(header.flags, bytes)?,
524            ),
525            QuantizationSchemeTag::PerChannelAffine => Self::PerChannelAffine(
526                per_channel_affine::PerChannelAffine::decode_payload(header.flags, bytes)?,
527            ),
528            QuantizationSchemeTag::PerBlockAffine => Self::PerBlockAffine(
529                per_block_affine::PerBlockAffine::decode_payload(header.flags, bytes)?,
530            ),
531            QuantizationSchemeTag::Nf4 => Self::Nf4(nf4::Nf4::decode_payload(header.flags, bytes)?),
532            QuantizationSchemeTag::Mxfp => {
533                Self::Mxfp(mxfp::Mxfp::decode_payload(header.flags, bytes)?)
534            }
535        };
536
537        let consumed = descriptor.encoded_len();
538        Ok((descriptor, consumed))
539    }
540
541    /// Returns the set of storage [`ElementType`]s that are valid for this descriptor.
542    ///
543    /// # Examples
544    ///
545    /// ```
546    /// use hurray_core::{ElementType, Mxfp, QuantizationDescriptor};
547    ///
548    /// let desc = QuantizationDescriptor::Mxfp(Mxfp::new(0, 32, 1).unwrap());
549    /// assert!(desc.valid_storage_types().contains(&ElementType::Float8E4M3));
550    /// assert!(!desc.valid_storage_types().contains(&ElementType::Float32));
551    /// ```
552    pub fn valid_storage_types(&self) -> &'static [ElementType] {
553        match self {
554            Self::PerTensorAffine(_) => PerTensorAffine::valid_storage_types(),
555            Self::PerChannelAffine(_) => PerChannelAffine::valid_storage_types(),
556            Self::PerBlockAffine(_) => PerBlockAffine::valid_storage_types(),
557            Self::Nf4(_) => Nf4::valid_storage_types(),
558            Self::Mxfp(_) => Mxfp::valid_storage_types(),
559        }
560    }
561
562    /// Returns `true` if `ty` is a valid storage type for this descriptor.
563    ///
564    /// # Examples
565    ///
566    /// ```
567    /// use hurray_core::{ElementType, PerTensorAffine, QuantizationDescriptor};
568    ///
569    /// let desc = QuantizationDescriptor::PerTensorAffine(
570    ///     PerTensorAffine::new(1.0, 0).unwrap()
571    /// );
572    /// assert!(desc.is_valid_storage_type(ElementType::Int8));
573    /// assert!(!desc.is_valid_storage_type(ElementType::Float32));
574    /// ```
575    pub fn is_valid_storage_type(&self, ty: ElementType) -> bool {
576        self.valid_storage_types().contains(&ty)
577    }
578
579    // ── Private helpers ───────────────────────────────────────────────────────
580
581    /// Returns the `(scheme_version, flags)` pair to write into the header.
582    fn header_version_and_flags(&self) -> (u8, u16) {
583        match self {
584            Self::PerTensorAffine(_) => (per_tensor_affine::SUPPORTED_VERSION, 0),
585            Self::PerChannelAffine(inner) => (per_channel_affine::SUPPORTED_VERSION, inner.flags()),
586            Self::PerBlockAffine(inner) => (per_block_affine::SUPPORTED_VERSION, inner.flags()),
587            Self::Nf4(_) => (nf4::SUPPORTED_VERSION, 0),
588            Self::Mxfp(_) => (mxfp::SUPPORTED_VERSION, 0),
589        }
590    }
591}
592
593// ── Module-level helpers ──────────────────────────────────────────────────────
594
595/// Returns the highest `scheme_version` this implementation supports for `tag`.
596fn supported_version_for(tag: QuantizationSchemeTag) -> u8 {
597    match tag {
598        QuantizationSchemeTag::PerTensorAffine => per_tensor_affine::SUPPORTED_VERSION,
599        QuantizationSchemeTag::PerChannelAffine => per_channel_affine::SUPPORTED_VERSION,
600        QuantizationSchemeTag::PerBlockAffine => per_block_affine::SUPPORTED_VERSION,
601        QuantizationSchemeTag::Nf4 => nf4::SUPPORTED_VERSION,
602        QuantizationSchemeTag::Mxfp => mxfp::SUPPORTED_VERSION,
603    }
604}
605
606/// Validates that `axis` is a valid axis index for a tensor of the given `rank`.
607///
608/// Per the spec, `axis` MUST satisfy `axis < rank`.
609///
610/// # Errors
611///
612/// - [`Error::QuantizationAxisOutOfBounds`] — `axis >= rank`.
613///
614/// # Examples
615///
616/// ```
617/// use hurray_core::validate_axis;
618///
619/// assert!(validate_axis(0, 3).is_ok());
620/// assert!(validate_axis(2, 3).is_ok());
621/// assert!(validate_axis(3, 3).is_err()); // axis == rank is out of bounds
622/// assert!(validate_axis(0, 0).is_err()); // rank 0 means no valid axis
623/// ```
624pub fn validate_axis(axis: u32, rank: u32) -> Result<()> {
625    if axis >= rank {
626        return Err(Error::QuantizationAxisOutOfBounds { axis, rank });
627    }
628    Ok(())
629}
630
631/// Validates that all quantization-parameter buffer indices in `desc` are valid
632/// within the `buffers` table and do not alias the tensor data buffer.
633///
634/// For each parameter buffer index referenced by `desc`:
635///
636/// 1. The index MUST be less than `buffers.len()`.
637/// 2. The index MUST NOT equal `data_buffer_index`.
638/// 3. The referenced buffer's `device_tag` MUST match that of
639///    `buffers[data_buffer_index]`.
640///
641/// # Errors
642///
643/// - [`Error::QuantizationBufferIndexOutOfRange`] — a parameter buffer index is
644///   out of range.
645/// - [`Error::QuantizationBufferAliasesData`] — a parameter buffer index equals
646///   `data_buffer_index`.
647/// - [`Error::DeviceTagMismatch`] — a parameter buffer is on a different device
648///   than the data buffer.
649///
650/// # Examples
651///
652/// ```
653/// use hurray_core::{
654///     BufferHandle, DeviceTag, Nf4, QuantizationDescriptor, SyncMode,
655///     validate_buffer_placement, MIN_BUFFER_ALIGNMENT,
656/// };
657///
658/// let data = BufferHandle::new(1024, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced).unwrap();
659/// let scale = BufferHandle::new(64, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced).unwrap();
660/// let buffers = [data, scale];
661///
662/// let desc = QuantizationDescriptor::Nf4(Nf4::new(0, 64, 1).unwrap());
663/// // scale buffer index 1 is valid: in range, != data index 0, same device.
664/// assert!(validate_buffer_placement(&desc, &buffers, 0).is_ok());
665/// ```
666pub fn validate_buffer_placement(
667    desc: &QuantizationDescriptor,
668    buffers: &[BufferHandle],
669    data_buffer_index: u32,
670) -> Result<()> {
671    let data_device = buffers
672        .get(data_buffer_index as usize)
673        .ok_or(Error::QuantizationBufferIndexOutOfRange {
674            index: data_buffer_index,
675            buffer_count: buffers.len() as u32,
676        })?
677        .device_tag();
678
679    for param_index in param_buffer_indices(desc) {
680        // Check index is in range.
681        if param_index as usize >= buffers.len() {
682            return Err(Error::QuantizationBufferIndexOutOfRange {
683                index: param_index,
684                buffer_count: buffers.len() as u32,
685            });
686        }
687        // Check it does not alias the data buffer.
688        if param_index == data_buffer_index {
689            return Err(Error::QuantizationBufferAliasesData { index: param_index });
690        }
691        // Check same device as data buffer.
692        let param_device = buffers[param_index as usize].device_tag();
693        if param_device != data_device {
694            return Err(Error::DeviceTagMismatch {
695                expected: data_device.to_byte(),
696                found: param_device.to_byte(),
697            });
698        }
699    }
700    Ok(())
701}
702
703/// Returns the list of parameter buffer indices referenced by `desc`.
704///
705/// This is an internal helper that collects the indices in a small fixed-size
706/// array to avoid allocating per call.
707fn param_buffer_indices(desc: &QuantizationDescriptor) -> impl Iterator<Item = u32> {
708    // Max 2 parameter buffers (scale + optional zero_point for per-channel/per-block).
709    let mut indices = [u32::MAX; 2];
710    let mut count = 0usize;
711
712    match desc {
713        QuantizationDescriptor::PerTensorAffine(_) => {
714            // All parameters are inline — no separate buffer references.
715        }
716        QuantizationDescriptor::PerChannelAffine(inner) => {
717            indices[count] = inner.scale_buffer_index();
718            count += 1;
719            if let Some(zp) = inner.zero_point_buffer_index() {
720                indices[count] = zp;
721                count += 1;
722            }
723        }
724        QuantizationDescriptor::PerBlockAffine(inner) => {
725            indices[count] = inner.scale_buffer_index();
726            count += 1;
727            if let Some(zp) = inner.zero_point_buffer_index() {
728                indices[count] = zp;
729                count += 1;
730            }
731        }
732        QuantizationDescriptor::Nf4(inner) => {
733            indices[count] = inner.scale_buffer_index();
734            count += 1;
735        }
736        QuantizationDescriptor::Mxfp(inner) => {
737            indices[count] = inner.scale_buffer_index();
738            count += 1;
739        }
740    }
741
742    indices.into_iter().take(count)
743}
744
745// ── Tests ─────────────────────────────────────────────────────────────────────
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750    use crate::{BufferHandle, DeviceTag, ElementType, Error, SyncMode, MIN_BUFFER_ALIGNMENT};
751
752    // ── QuantizationSchemeTag::from_byte ─────────────────────────────────────
753
754    #[test]
755    fn scheme_tag_from_byte_tier1_variants() {
756        // Spec §: tag range 0x01–0x05 are Tier 1 assigned.
757        assert_eq!(
758            QuantizationSchemeTag::from_byte(0x01).unwrap(),
759            QuantizationSchemeTag::PerTensorAffine
760        );
761        assert_eq!(
762            QuantizationSchemeTag::from_byte(0x02).unwrap(),
763            QuantizationSchemeTag::PerChannelAffine
764        );
765        assert_eq!(
766            QuantizationSchemeTag::from_byte(0x03).unwrap(),
767            QuantizationSchemeTag::PerBlockAffine
768        );
769        assert_eq!(
770            QuantizationSchemeTag::from_byte(0x04).unwrap(),
771            QuantizationSchemeTag::Nf4
772        );
773        assert_eq!(
774            QuantizationSchemeTag::from_byte(0x05).unwrap(),
775            QuantizationSchemeTag::Mxfp
776        );
777    }
778
779    #[test]
780    fn scheme_tag_from_byte_permanently_invalid_sentinels() {
781        // Spec §: 0x00 and 0xFF are permanently invalid.
782        assert!(matches!(
783            QuantizationSchemeTag::from_byte(0x00),
784            Err(Error::InvalidQuantizationSchemeTag(0x00))
785        ));
786        assert!(matches!(
787            QuantizationSchemeTag::from_byte(0xFF),
788            Err(Error::InvalidQuantizationSchemeTag(0xFF))
789        ));
790    }
791
792    #[test]
793    fn scheme_tag_from_byte_reserved_range() {
794        // Spec §: 0x60–0xEF are reserved.
795        assert!(matches!(
796            QuantizationSchemeTag::from_byte(0x60),
797            Err(Error::ReservedQuantizationSchemeTag(0x60))
798        ));
799        assert!(matches!(
800            QuantizationSchemeTag::from_byte(0xEF),
801            Err(Error::ReservedQuantizationSchemeTag(0xEF))
802        ));
803    }
804
805    #[test]
806    fn scheme_tag_from_byte_private_range() {
807        // Spec §: 0xF0–0xFE are private — unconstrained payload, rejected by this crate.
808        assert!(matches!(
809            QuantizationSchemeTag::from_byte(0xF0),
810            Err(Error::PrivateQuantizationSchemeTag(0xF0))
811        ));
812        assert!(matches!(
813            QuantizationSchemeTag::from_byte(0xFE),
814            Err(Error::PrivateQuantizationSchemeTag(0xFE))
815        ));
816    }
817
818    #[test]
819    fn scheme_tag_from_byte_unknown_unallocated_tier1() {
820        // Spec §: 0x06–0x3F are unallocated Tier 1 — must treat as unknown.
821        assert!(matches!(
822            QuantizationSchemeTag::from_byte(0x06),
823            Err(Error::UnknownQuantizationSchemeTag(0x06))
824        ));
825    }
826
827    #[test]
828    fn scheme_tag_tag_returns_raw_byte() {
829        assert_eq!(QuantizationSchemeTag::PerTensorAffine.tag(), 0x01);
830        assert_eq!(QuantizationSchemeTag::PerChannelAffine.tag(), 0x02);
831        assert_eq!(QuantizationSchemeTag::PerBlockAffine.tag(), 0x03);
832        assert_eq!(QuantizationSchemeTag::Nf4.tag(), 0x04);
833        assert_eq!(QuantizationSchemeTag::Mxfp.tag(), 0x05);
834    }
835
836    #[test]
837    fn scheme_tag_tier_correct_per_spec_table() {
838        // Tier 1 (mandatory): per-tensor, per-channel, per-block.
839        assert_eq!(QuantizationSchemeTag::PerTensorAffine.tier(), 1);
840        assert_eq!(QuantizationSchemeTag::PerChannelAffine.tier(), 1);
841        assert_eq!(QuantizationSchemeTag::PerBlockAffine.tier(), 1);
842        // Tier 2 (optional): NF4 and MXFP — despite having tags in the 0x01–0x3F
843        // numeric range, the spec scheme table designates them as Tier 2.
844        assert_eq!(QuantizationSchemeTag::Nf4.tier(), 2);
845        assert_eq!(QuantizationSchemeTag::Mxfp.tier(), 2);
846    }
847
848    // ── QuantizationDescriptor::encoded_len ──────────────────────────────────
849
850    #[test]
851    fn encoded_len_per_tensor_affine_is_16() {
852        let desc = QuantizationDescriptor::PerTensorAffine(PerTensorAffine::new(1.0, 0).unwrap());
853        assert_eq!(desc.encoded_len(), 16);
854    }
855
856    #[test]
857    fn encoded_len_per_channel_affine_is_20() {
858        let desc = QuantizationDescriptor::PerChannelAffine(
859            PerChannelAffine::new_symmetric(0, 1).unwrap(),
860        );
861        assert_eq!(desc.encoded_len(), 20);
862    }
863
864    #[test]
865    fn encoded_len_per_block_affine_is_24() {
866        let desc = QuantizationDescriptor::PerBlockAffine(
867            PerBlockAffine::new_symmetric(0, 32, 1, ElementType::Float32).unwrap(),
868        );
869        assert_eq!(desc.encoded_len(), 24);
870    }
871
872    #[test]
873    fn encoded_len_nf4_is_16() {
874        let desc = QuantizationDescriptor::Nf4(Nf4::new(0, 64, 1).unwrap());
875        assert_eq!(desc.encoded_len(), 16);
876    }
877
878    #[test]
879    fn encoded_len_mxfp_is_16() {
880        let desc = QuantizationDescriptor::Mxfp(Mxfp::new(0, 32, 1).unwrap());
881        assert_eq!(desc.encoded_len(), 16);
882    }
883
884    // ── Round-trips (encode_into + decode) ───────────────────────────────────
885
886    fn round_trip(desc: &QuantizationDescriptor) {
887        let len = desc.encoded_len();
888        let mut buf = vec![0u8; len];
889        let written = desc.encode_into(&mut buf).unwrap();
890        assert_eq!(written, len, "encode_into must return encoded_len");
891        let (decoded, consumed) = QuantizationDescriptor::decode(&buf).unwrap();
892        assert_eq!(
893            consumed, len,
894            "decode must consume exactly encoded_len bytes"
895        );
896        assert_eq!(&decoded, desc, "decoded descriptor must equal original");
897    }
898
899    #[test]
900    fn round_trip_per_tensor_affine() {
901        round_trip(&QuantizationDescriptor::PerTensorAffine(
902            PerTensorAffine::new(0.5, 128).unwrap(),
903        ));
904    }
905
906    #[test]
907    fn round_trip_per_channel_affine_symmetric() {
908        round_trip(&QuantizationDescriptor::PerChannelAffine(
909            PerChannelAffine::new_symmetric(0, 1).unwrap(),
910        ));
911    }
912
913    #[test]
914    fn round_trip_per_channel_affine_asymmetric() {
915        round_trip(&QuantizationDescriptor::PerChannelAffine(
916            PerChannelAffine::new_asymmetric(2, 1, 3).unwrap(),
917        ));
918    }
919
920    #[test]
921    fn round_trip_per_block_affine_symmetric() {
922        round_trip(&QuantizationDescriptor::PerBlockAffine(
923            PerBlockAffine::new_symmetric(0, 32, 1, ElementType::Float32).unwrap(),
924        ));
925    }
926
927    #[test]
928    fn round_trip_per_block_affine_asymmetric() {
929        round_trip(&QuantizationDescriptor::PerBlockAffine(
930            PerBlockAffine::new_asymmetric(1, 64, 2, 3, ElementType::Float16).unwrap(),
931        ));
932    }
933
934    #[test]
935    fn round_trip_nf4() {
936        round_trip(&QuantizationDescriptor::Nf4(Nf4::new(0, 64, 1).unwrap()));
937    }
938
939    #[test]
940    fn round_trip_mxfp() {
941        round_trip(&QuantizationDescriptor::Mxfp(Mxfp::new(0, 32, 1).unwrap()));
942    }
943
944    #[test]
945    fn encode_to_vec_matches_encode_into() {
946        let desc = QuantizationDescriptor::PerTensorAffine(PerTensorAffine::new(0.25, -7).unwrap());
947        let vec_bytes = desc.encode_to_vec();
948        let mut into_bytes = vec![0u8; desc.encoded_len()];
949        desc.encode_into(&mut into_bytes).unwrap();
950        assert_eq!(vec_bytes, into_bytes);
951    }
952
953    // ── validate_axis ─────────────────────────────────────────────────────────
954
955    #[test]
956    fn validate_axis_within_rank_is_ok() {
957        assert!(validate_axis(0, 3).is_ok());
958        assert!(validate_axis(2, 3).is_ok());
959    }
960
961    #[test]
962    fn validate_axis_equal_to_rank_is_out_of_bounds() {
963        assert!(matches!(
964            validate_axis(3, 3),
965            Err(Error::QuantizationAxisOutOfBounds { axis: 3, rank: 3 })
966        ));
967    }
968
969    #[test]
970    fn validate_axis_greater_than_rank_is_out_of_bounds() {
971        assert!(matches!(
972            validate_axis(10, 3),
973            Err(Error::QuantizationAxisOutOfBounds { axis: 10, rank: 3 })
974        ));
975    }
976
977    #[test]
978    fn validate_axis_rank_zero_always_errs() {
979        // rank == 0: no valid axis exists.
980        assert!(matches!(
981            validate_axis(0, 0),
982            Err(Error::QuantizationAxisOutOfBounds { axis: 0, rank: 0 })
983        ));
984    }
985
986    // ── validate_buffer_placement ─────────────────────────────────────────────
987
988    fn make_cpu_buffer() -> BufferHandle {
989        BufferHandle::new(
990            1024,
991            MIN_BUFFER_ALIGNMENT,
992            DeviceTag::Cpu,
993            SyncMode::ProducerSynced,
994        )
995        .unwrap()
996    }
997
998    #[test]
999    fn validate_buffer_placement_nf4_scale_only_valid() {
1000        // NF4 has one scale buffer.  data=index 0, scale=index 1 — valid.
1001        let buffers = [make_cpu_buffer(), make_cpu_buffer()];
1002        let desc = QuantizationDescriptor::Nf4(Nf4::new(0, 64, 1).unwrap());
1003        assert!(validate_buffer_placement(&desc, &buffers, 0).is_ok());
1004    }
1005
1006    #[test]
1007    fn validate_buffer_placement_mxfp_scale_only_valid() {
1008        let buffers = [make_cpu_buffer(), make_cpu_buffer()];
1009        let desc = QuantizationDescriptor::Mxfp(Mxfp::new(0, 32, 1).unwrap());
1010        assert!(validate_buffer_placement(&desc, &buffers, 0).is_ok());
1011    }
1012
1013    #[test]
1014    fn validate_buffer_placement_per_block_asymmetric_scale_and_zp_valid() {
1015        // PerBlockAffine asymmetric: data=0, scale=1, zp=2 — all valid, same device.
1016        let buffers = [make_cpu_buffer(), make_cpu_buffer(), make_cpu_buffer()];
1017        let desc = QuantizationDescriptor::PerBlockAffine(
1018            PerBlockAffine::new_asymmetric(0, 32, 1, 2, ElementType::Float32).unwrap(),
1019        );
1020        assert!(validate_buffer_placement(&desc, &buffers, 0).is_ok());
1021    }
1022
1023    #[test]
1024    fn validate_buffer_placement_aliases_data_is_err() {
1025        // Param buffer index == data buffer index → QuantizationBufferAliasesData.
1026        let buffers = [make_cpu_buffer(), make_cpu_buffer()];
1027        // scale_buffer_index = 0, which is the data buffer index.
1028        let desc = QuantizationDescriptor::Nf4(Nf4::new(0, 64, 0).unwrap());
1029        assert!(matches!(
1030            validate_buffer_placement(&desc, &buffers, 0),
1031            Err(Error::QuantizationBufferAliasesData { index: 0 })
1032        ));
1033    }
1034
1035    #[test]
1036    fn validate_buffer_placement_index_out_of_range_is_err() {
1037        // Only 1 buffer; scale_buffer_index = 5 is out of range.
1038        let buffers = [make_cpu_buffer()];
1039        let desc = QuantizationDescriptor::Nf4(Nf4::new(0, 64, 5).unwrap());
1040        assert!(matches!(
1041            validate_buffer_placement(&desc, &buffers, 0),
1042            Err(Error::QuantizationBufferIndexOutOfRange { .. })
1043        ));
1044    }
1045
1046    #[test]
1047    fn validate_buffer_placement_device_tag_mismatch_is_err() {
1048        // data=Cpu (index 0), scale=Cuda (index 1) → DeviceTagMismatch.
1049        let cpu_buf = BufferHandle::new(
1050            1024,
1051            MIN_BUFFER_ALIGNMENT,
1052            DeviceTag::Cpu,
1053            SyncMode::ProducerSynced,
1054        )
1055        .unwrap();
1056        let cuda_buf = BufferHandle::new(
1057            1024,
1058            MIN_BUFFER_ALIGNMENT,
1059            DeviceTag::Cuda,
1060            SyncMode::ProducerSynced,
1061        )
1062        .unwrap();
1063        let buffers = [cpu_buf, cuda_buf];
1064        let desc = QuantizationDescriptor::Nf4(Nf4::new(0, 64, 1).unwrap());
1065        assert!(matches!(
1066            validate_buffer_placement(&desc, &buffers, 0),
1067            Err(Error::DeviceTagMismatch { .. })
1068        ));
1069    }
1070}