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}