Skip to main content

hurray_core/quantization/
per_block_affine.rs

1//! Per-block affine quantization descriptor (scheme tag `0x03`).
2//!
3//! The tensor is divided into fixed-size contiguous blocks along a specified axis.
4//! Each block carries its own `scale` (and optionally `zero_point`). Partial
5//! trailing blocks are permitted; see `docs/spec/quantization/per-block-affine.md`
6//! § Padding for the normative padding rules.
7//!
8//! See `docs/spec/quantization/per-block-affine.md` for the normative definition.
9
10use crate::{ElementType, Error, Result};
11
12// ── Wire layout constants ─────────────────────────────────────────────────────
13
14/// Scheme tag byte for per-block affine quantization.
15pub(crate) const SCHEME_TAG: u8 = 0x03;
16
17/// Total descriptor length in bytes (header 4 + axis 4 + block_size 4 +
18/// scale_buf 4 + zp_buf 4 + scale_type_tag 1 + reserved 3).
19pub(crate) const ENCODED_LEN: usize = 24;
20
21/// Version this implementation supports.
22pub(crate) const SUPPORTED_VERSION: u8 = 0x01;
23
24/// Wire sentinel meaning "no zero-point buffer" (symmetric mode).
25const ZP_SENTINEL: u32 = 0xFFFF_FFFF;
26
27/// Minimum block size for per-block affine (must be a power of two ≥ 2).
28pub const PER_BLOCK_AFFINE_MIN_BLOCK_SIZE: u32 = 2;
29
30/// No upper bound on block_size is imposed by the spec for per-block affine.
31/// We use `u32::MAX` as a stand-in for "unbounded" in error messages.
32const MAX_BLOCK_SIZE: u32 = u32::MAX;
33
34/// Bit 0: symmetric flag — zero-point array is implicit (all zeros).
35pub const FLAG_SYMMETRIC: u16 = 0b1;
36/// All bits except bit 0 are reserved and must be zero.
37pub const RESERVED_MASK: u16 = !FLAG_SYMMETRIC;
38
39/// Valid scale type tags: float16 (0x01), bfloat16 (0x02), float32 (0x03).
40const VALID_SCALE_TYPE_TAGS: [u8; 3] = [0x01, 0x02, 0x03];
41
42// Wire field offsets (relative to start of descriptor, including header).
43const OFFSET_AXIS: usize = 4;
44const OFFSET_BLOCK_SIZE: usize = 8;
45const OFFSET_SCALE_BUF: usize = 12;
46const OFFSET_ZP_BUF: usize = 16;
47const OFFSET_SCALE_TYPE: usize = 20;
48const OFFSET_RESERVED: usize = 21;
49
50// ── PerBlockAffine ────────────────────────────────────────────────────────────
51
52/// Quantization parameters for per-block affine quantization.
53///
54/// The tensor is divided into fixed-size contiguous blocks along `axis`. Each
55/// block carries its own `scale` (and optionally `zero_point`). Partial
56/// trailing blocks are permitted; see the spec for padding rules.
57///
58/// The dequantization formula for element `q` with block index `b` is:
59///
60/// ```text
61/// s      = scale[b]           (widened to float32 if float16/bfloat16)
62/// z      = zero_point[b]      (0 if symmetric)
63/// x_real = s * (q - z)
64/// ```
65///
66/// # Wire format
67///
68/// Total descriptor length: **24 bytes** (including the 4-byte header).
69///
70/// | Offset | Field | Type |
71/// |--------|-------|------|
72/// | 4 | `axis` | `uint32` LE |
73/// | 8 | `block_size` | `uint32` LE |
74/// | 12 | `scale_buffer_index` | `uint32` LE |
75/// | 16 | `zero_point_buffer_index` | `uint32` LE (`0xFFFFFFFF` if symmetric) |
76/// | 20 | `scale_type_tag` | `uint8` (`0x01`, `0x02`, or `0x03`) |
77/// | 21 | `_reserved` | `uint8[3]` (must be `0x00`) |
78///
79/// # Design notes
80///
81/// `PartialEq`, `Eq`, and `Hash` are all derived because this struct contains
82/// no floating-point fields.
83///
84/// `Copy` because the struct is ≤ 24 bytes with no `Drop` glue.
85///
86/// # Examples
87///
88/// ```
89/// use hurray_core::{ElementType, PerBlockAffine};
90///
91/// let q = PerBlockAffine::new_symmetric(0, 32, 1, ElementType::Float32).unwrap();
92/// assert!(q.is_symmetric());
93/// assert_eq!(q.block_size(), 32);
94/// assert_eq!(q.scale_type(), ElementType::Float32);
95/// ```
96// WHY Eq + Hash: no float fields (design decision #2 applies only to PerTensorAffine).
97// WHY Copy: ≤24 bytes, no Drop glue (design decision #7).
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
99#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
100pub struct PerBlockAffine {
101    axis: u32,
102    block_size: u32,
103    scale_buffer_index: u32,
104    /// `None` encodes the symmetric case (wire sentinel `0xFFFFFFFF`).
105    ///
106    /// WHY `Option<u32>`: makes the symmetric/asymmetric distinction
107    /// unrepresentable-when-wrong at the type level (design decision #6).
108    zero_point_buffer_index: Option<u32>,
109    scale_type: ElementType,
110}
111
112impl PerBlockAffine {
113    /// Creates an asymmetric [`PerBlockAffine`] descriptor.
114    ///
115    /// # Errors
116    ///
117    /// - [`Error::InvalidBlockSize`] — `block_size` is not a power of two or is
118    ///   less than `2`.
119    /// - [`Error::InvalidQuantization`] — `scale_type` is not one of
120    ///   `Float16`, `BFloat16`, or `Float32`.
121    ///
122    /// # Examples
123    ///
124    /// ```
125    /// use hurray_core::{ElementType, PerBlockAffine};
126    ///
127    /// let q = PerBlockAffine::new_asymmetric(0, 64, 1, 2, ElementType::Float16).unwrap();
128    /// assert!(!q.is_symmetric());
129    /// assert_eq!(q.zero_point_buffer_index(), Some(2));
130    /// ```
131    pub fn new_asymmetric(
132        axis: u32,
133        block_size: u32,
134        scale_buffer_index: u32,
135        zero_point_buffer_index: u32,
136        scale_type: ElementType,
137    ) -> Result<Self> {
138        validate_block_size(block_size)?;
139        validate_scale_type(scale_type)?;
140        Ok(Self {
141            axis,
142            block_size,
143            scale_buffer_index,
144            zero_point_buffer_index: Some(zero_point_buffer_index),
145            scale_type,
146        })
147    }
148
149    /// Creates a symmetric [`PerBlockAffine`] descriptor.
150    ///
151    /// In symmetric mode the zero-point array is implicit (all zeros); no
152    /// zero-point buffer entry is required.
153    ///
154    /// # Errors
155    ///
156    /// - [`Error::InvalidBlockSize`] — `block_size` is not a power of two or is
157    ///   less than `2`.
158    /// - [`Error::InvalidQuantization`] — `scale_type` is not one of
159    ///   `Float16`, `BFloat16`, or `Float32`.
160    ///
161    /// # Examples
162    ///
163    /// ```
164    /// use hurray_core::{ElementType, PerBlockAffine};
165    ///
166    /// let q = PerBlockAffine::new_symmetric(0, 128, 1, ElementType::BFloat16).unwrap();
167    /// assert!(q.is_symmetric());
168    /// assert_eq!(q.zero_point_buffer_index(), None);
169    /// ```
170    pub fn new_symmetric(
171        axis: u32,
172        block_size: u32,
173        scale_buffer_index: u32,
174        scale_type: ElementType,
175    ) -> Result<Self> {
176        validate_block_size(block_size)?;
177        validate_scale_type(scale_type)?;
178        Ok(Self {
179            axis,
180            block_size,
181            scale_buffer_index,
182            zero_point_buffer_index: None,
183            scale_type,
184        })
185    }
186
187    /// Returns `true` if this descriptor uses symmetric quantization (no zero point).
188    ///
189    /// # Examples
190    ///
191    /// ```
192    /// use hurray_core::{ElementType, PerBlockAffine};
193    ///
194    /// assert!(PerBlockAffine::new_symmetric(0, 32, 1, ElementType::Float32)
195    ///     .unwrap()
196    ///     .is_symmetric());
197    /// ```
198    #[inline]
199    pub fn is_symmetric(&self) -> bool {
200        self.zero_point_buffer_index.is_none()
201    }
202
203    /// Returns the quantization axis index.
204    ///
205    /// # Examples
206    ///
207    /// ```
208    /// use hurray_core::{ElementType, PerBlockAffine};
209    ///
210    /// let q = PerBlockAffine::new_symmetric(2, 32, 1, ElementType::Float32).unwrap();
211    /// assert_eq!(q.axis(), 2);
212    /// ```
213    #[inline]
214    pub fn axis(&self) -> u32 {
215        self.axis
216    }
217
218    /// Returns the number of logical elements per block along `axis`.
219    ///
220    /// # Examples
221    ///
222    /// ```
223    /// use hurray_core::{ElementType, PerBlockAffine};
224    ///
225    /// let q = PerBlockAffine::new_symmetric(0, 64, 1, ElementType::Float32).unwrap();
226    /// assert_eq!(q.block_size(), 64);
227    /// ```
228    #[inline]
229    pub fn block_size(&self) -> u32 {
230        self.block_size
231    }
232
233    /// Returns the buffer table index of the per-block scale array.
234    ///
235    /// # Examples
236    ///
237    /// ```
238    /// use hurray_core::{ElementType, PerBlockAffine};
239    ///
240    /// let q = PerBlockAffine::new_symmetric(0, 32, 3, ElementType::Float32).unwrap();
241    /// assert_eq!(q.scale_buffer_index(), 3);
242    /// ```
243    #[inline]
244    pub fn scale_buffer_index(&self) -> u32 {
245        self.scale_buffer_index
246    }
247
248    /// Returns the buffer table index of the per-block zero-point array, or
249    /// `None` if this descriptor is symmetric.
250    ///
251    /// `None` maps to the wire sentinel `0xFFFFFFFF`.
252    ///
253    /// # Examples
254    ///
255    /// ```
256    /// use hurray_core::{ElementType, PerBlockAffine};
257    ///
258    /// let asym = PerBlockAffine::new_asymmetric(0, 32, 1, 2, ElementType::Float32).unwrap();
259    /// assert_eq!(asym.zero_point_buffer_index(), Some(2));
260    ///
261    /// let sym = PerBlockAffine::new_symmetric(0, 32, 1, ElementType::Float32).unwrap();
262    /// assert_eq!(sym.zero_point_buffer_index(), None);
263    /// ```
264    #[inline]
265    pub fn zero_point_buffer_index(&self) -> Option<u32> {
266        self.zero_point_buffer_index
267    }
268
269    /// Returns the element type used for scale values.
270    ///
271    /// # Examples
272    ///
273    /// ```
274    /// use hurray_core::{ElementType, PerBlockAffine};
275    ///
276    /// let q = PerBlockAffine::new_symmetric(0, 32, 1, ElementType::Float16).unwrap();
277    /// assert_eq!(q.scale_type(), ElementType::Float16);
278    /// ```
279    #[inline]
280    pub fn scale_type(&self) -> ElementType {
281        self.scale_type
282    }
283
284    /// Computes the number of blocks along `axis` for a given `shape_axis` size.
285    ///
286    /// Uses `ceil(shape_axis / block_size)`.
287    ///
288    /// Returns `0` when `shape_axis == 0` per the ADR-007 empty-axis carve-out:
289    /// an empty quantization axis produces zero blocks and zero-byte parameter buffers.
290    ///
291    /// # Examples
292    ///
293    /// ```
294    /// use hurray_core::{ElementType, PerBlockAffine};
295    ///
296    /// let q = PerBlockAffine::new_symmetric(0, 32, 1, ElementType::Float32).unwrap();
297    /// assert_eq!(q.num_blocks_per_axis(64), 2);
298    /// assert_eq!(q.num_blocks_per_axis(65), 3); // partial trailing block
299    /// assert_eq!(q.num_blocks_per_axis(0), 0);  // ADR-007 empty-axis carve-out
300    /// ```
301    pub fn num_blocks_per_axis(&self, shape_axis: u64) -> u64 {
302        if shape_axis == 0 {
303            // ADR-007 empty-axis carve-out: an empty quantization axis yields zero
304            // blocks. The block_size field retains its declared value for round-trip
305            // fidelity; no blocks are materialized and scale/zp buffers have size 0.
306            return 0;
307        }
308        shape_axis.div_ceil(self.block_size as u64)
309    }
310
311    /// Validates this descriptor against the resolved `shape_axis` size.
312    ///
313    /// Rejects if `shape_axis` is the DYNAMIC sentinel (`u64::MAX`), or if
314    /// `shape_axis > 0` and `block_size > shape_axis`.
315    ///
316    /// Does not check `shape_axis == 0` (ADR-007 carve-out: the upper-bound
317    /// check is waived for empty axes).
318    ///
319    /// # Errors
320    ///
321    /// - [`Error::QuantizationShapeMismatch`] — `shape_axis` is the DYNAMIC sentinel,
322    ///   or `shape_axis > 0` and `block_size > shape_axis`.
323    ///
324    /// # Examples
325    ///
326    /// ```
327    /// use hurray_core::{ElementType, PerBlockAffine, DYNAMIC};
328    ///
329    /// let q = PerBlockAffine::new_symmetric(0, 32, 1, ElementType::Float32).unwrap();
330    /// assert!(q.validate_against_shape_axis(64).is_ok());
331    /// assert!(q.validate_against_shape_axis(32).is_ok());
332    /// assert!(q.validate_against_shape_axis(0).is_ok()); // ADR-007: waived
333    /// assert!(q.validate_against_shape_axis(16).is_err()); // block_size > shape_axis
334    /// assert!(q.validate_against_shape_axis(DYNAMIC).is_err()); // dynamic dimension
335    /// ```
336    pub fn validate_against_shape_axis(&self, shape_axis: u64) -> Result<()> {
337        // Reject the DYNAMIC sentinel (u64::MAX = 0xFFFF…FFFF): a dynamic dimension
338        // cannot be validated against a block size constraint.
339        if shape_axis == crate::shape::DYNAMIC {
340            return Err(Error::QuantizationShapeMismatch {
341                axis: self.axis,
342                shape_axis,
343                block_size: self.block_size,
344                reason: "shape[axis] must not be the DYNAMIC sentinel (0xFFFFFFFFFFFFFFFF)",
345            });
346        }
347        if shape_axis > 0 && self.block_size as u64 > shape_axis {
348            return Err(Error::QuantizationShapeMismatch {
349                axis: self.axis,
350                shape_axis,
351                block_size: self.block_size,
352                reason: "block_size must not exceed shape[axis] when shape[axis] > 0",
353            });
354        }
355        Ok(())
356    }
357
358    /// Returns the set of storage [`ElementType`]s that are valid for this scheme.
359    ///
360    /// Per `docs/spec/quantization/per-block-affine.md § Valid Storage Types`.
361    ///
362    /// WHY `&'static [ElementType]`: no allocation per call (design decision #5).
363    ///
364    /// # Examples
365    ///
366    /// ```
367    /// use hurray_core::{ElementType, PerBlockAffine};
368    ///
369    /// assert!(PerBlockAffine::valid_storage_types().contains(&ElementType::Int8));
370    /// assert!(PerBlockAffine::valid_storage_types().contains(&ElementType::Int4));
371    /// assert!(!PerBlockAffine::valid_storage_types().contains(&ElementType::Int16));
372    /// ```
373    pub fn valid_storage_types() -> &'static [ElementType] {
374        &[
375            ElementType::Int8,
376            ElementType::Uint8,
377            ElementType::Int4,
378            ElementType::Uint4,
379            ElementType::Int2,
380            ElementType::Uint2,
381        ]
382    }
383
384    // ── Crate-internal encode/decode ──────────────────────────────────────────
385
386    /// Decodes the scheme-specific payload from `bytes`.
387    ///
388    /// `bytes` is the full descriptor slice (including the 4-byte header).
389    /// `flags` are the header flags already read by the caller.
390    pub(crate) fn decode_payload(flags: u16, bytes: &[u8]) -> Result<Self> {
391        if bytes.len() < ENCODED_LEN {
392            return Err(Error::QuantizationDescriptorTooShort {
393                found: bytes.len(),
394                needed: ENCODED_LEN,
395            });
396        }
397        if flags & RESERVED_MASK != 0 {
398            return Err(Error::ReservedQuantizationFlagsBits {
399                flags,
400                mask: RESERVED_MASK,
401            });
402        }
403        let symmetric = flags & FLAG_SYMMETRIC != 0;
404
405        let axis = u32::from_le_bytes(
406            bytes[OFFSET_AXIS..OFFSET_AXIS + 4]
407                .try_into()
408                .map_err(|_| Error::InvalidQuantization("axis slice error".into()))?,
409        );
410        let block_size = u32::from_le_bytes(
411            bytes[OFFSET_BLOCK_SIZE..OFFSET_BLOCK_SIZE + 4]
412                .try_into()
413                .map_err(|_| Error::InvalidQuantization("block_size slice error".into()))?,
414        );
415        let scale_buf = u32::from_le_bytes(
416            bytes[OFFSET_SCALE_BUF..OFFSET_SCALE_BUF + 4]
417                .try_into()
418                .map_err(|_| Error::InvalidQuantization("scale_buffer_index slice error".into()))?,
419        );
420        let zp_buf_raw =
421            u32::from_le_bytes(bytes[OFFSET_ZP_BUF..OFFSET_ZP_BUF + 4].try_into().map_err(
422                |_| Error::InvalidQuantization("zero_point_buffer_index slice error".into()),
423            )?);
424        let scale_type_byte = bytes[OFFSET_SCALE_TYPE];
425
426        // block_size must be a power of two and >= 2.
427        if !block_size.is_power_of_two() || block_size < PER_BLOCK_AFFINE_MIN_BLOCK_SIZE {
428            return Err(Error::InvalidBlockSize {
429                scheme_tag: SCHEME_TAG,
430                block_size,
431                min: PER_BLOCK_AFFINE_MIN_BLOCK_SIZE,
432                max: MAX_BLOCK_SIZE,
433            });
434        }
435
436        // scale_type_tag must be one of {0x01, 0x02, 0x03}.
437        if !VALID_SCALE_TYPE_TAGS.contains(&scale_type_byte) {
438            return Err(Error::InvalidQuantization(format!(
439                "per-block affine scale_type_tag must be 0x01, 0x02, or 0x03, got 0x{scale_type_byte:02X}"
440            )));
441        }
442        let scale_type = ElementType::from_tag(scale_type_byte)?;
443
444        // Reserved bytes [21..24] must be 0x00.
445        if bytes[OFFSET_RESERVED..OFFSET_RESERVED + 3]
446            .iter()
447            .any(|&b| b != 0)
448        {
449            return Err(Error::InvalidQuantization(
450                "per-block affine reserved bytes [21..24] must be 0x00".into(),
451            ));
452        }
453
454        // SYMMETRIC flag and zero_point_buffer_index sentinel must be consistent.
455        let zero_point_buffer_index = if symmetric {
456            if zp_buf_raw != ZP_SENTINEL {
457                return Err(Error::InvalidQuantization(
458                    "SYMMETRIC flag is set but zero_point_buffer_index is not 0xFFFFFFFF".into(),
459                ));
460            }
461            None
462        } else {
463            Some(zp_buf_raw)
464        };
465
466        Ok(Self {
467            axis,
468            block_size,
469            scale_buffer_index: scale_buf,
470            zero_point_buffer_index,
471            scale_type,
472        })
473    }
474
475    /// Encodes the scheme-specific payload into `out`.
476    ///
477    /// `out` must be at least [`ENCODED_LEN`] bytes. The caller writes the 4-byte
478    /// header; this method writes bytes 4–23.
479    pub(crate) fn encode_payload(&self, out: &mut [u8]) {
480        out[OFFSET_AXIS..OFFSET_AXIS + 4].copy_from_slice(&self.axis.to_le_bytes());
481        out[OFFSET_BLOCK_SIZE..OFFSET_BLOCK_SIZE + 4]
482            .copy_from_slice(&self.block_size.to_le_bytes());
483        out[OFFSET_SCALE_BUF..OFFSET_SCALE_BUF + 4]
484            .copy_from_slice(&self.scale_buffer_index.to_le_bytes());
485        let zp_wire = self.zero_point_buffer_index.unwrap_or(ZP_SENTINEL);
486        out[OFFSET_ZP_BUF..OFFSET_ZP_BUF + 4].copy_from_slice(&zp_wire.to_le_bytes());
487        out[OFFSET_SCALE_TYPE] = self.scale_type.tag();
488        out[OFFSET_RESERVED..OFFSET_RESERVED + 3].fill(0);
489    }
490
491    /// Returns the flags word that encodes the symmetric/asymmetric state.
492    pub(crate) fn flags(&self) -> u16 {
493        if self.is_symmetric() {
494            FLAG_SYMMETRIC
495        } else {
496            0
497        }
498    }
499}
500
501// ── Helpers ───────────────────────────────────────────────────────────────────
502
503fn validate_block_size(block_size: u32) -> Result<()> {
504    if !block_size.is_power_of_two() || block_size < PER_BLOCK_AFFINE_MIN_BLOCK_SIZE {
505        return Err(Error::InvalidBlockSize {
506            scheme_tag: SCHEME_TAG,
507            block_size,
508            min: PER_BLOCK_AFFINE_MIN_BLOCK_SIZE,
509            max: MAX_BLOCK_SIZE,
510        });
511    }
512    Ok(())
513}
514
515fn validate_scale_type(scale_type: ElementType) -> Result<()> {
516    if !VALID_SCALE_TYPE_TAGS.contains(&scale_type.tag()) {
517        return Err(Error::InvalidQuantization(format!(
518            "per-block affine scale_type must be Float16, BFloat16, or Float32, got {:?}",
519            scale_type
520        )));
521    }
522    Ok(())
523}
524
525// ── Tests ─────────────────────────────────────────────────────────────────────
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530    use crate::{ElementType, Error};
531
532    // ── Constructors ──────────────────────────────────────────────────────────
533
534    #[test]
535    fn new_symmetric_block_size_not_power_of_two_is_err() {
536        assert!(matches!(
537            PerBlockAffine::new_symmetric(0, 3, 1, ElementType::Float32),
538            Err(Error::InvalidBlockSize { .. })
539        ));
540    }
541
542    #[test]
543    fn new_symmetric_block_size_zero_is_err() {
544        assert!(matches!(
545            PerBlockAffine::new_symmetric(0, 0, 1, ElementType::Float32),
546            Err(Error::InvalidBlockSize { .. })
547        ));
548    }
549
550    #[test]
551    fn new_symmetric_block_size_one_is_err() {
552        // PER_BLOCK_AFFINE_MIN_BLOCK_SIZE is 2; block_size=1 is below minimum.
553        assert!(matches!(
554            PerBlockAffine::new_symmetric(0, 1, 1, ElementType::Float32),
555            Err(Error::InvalidBlockSize { .. })
556        ));
557    }
558
559    #[test]
560    fn new_symmetric_sentinel_scale_buf_is_accepted() {
561        // PerBlockAffine does NOT reject 0xFFFFFFFF as scale_buffer_index
562        // (only PerChannelAffine and NF4 do — per_block_affine has no sentinel check).
563        // Confirm new_symmetric with valid params works.
564        assert!(PerBlockAffine::new_symmetric(0, 32, 1, ElementType::Float32).is_ok());
565    }
566
567    #[test]
568    fn new_asymmetric_block_size_not_power_of_two_is_err() {
569        assert!(matches!(
570            PerBlockAffine::new_asymmetric(0, 6, 1, 2, ElementType::Float32),
571            Err(Error::InvalidBlockSize { .. })
572        ));
573    }
574
575    #[test]
576    fn new_asymmetric_block_size_zero_is_err() {
577        assert!(matches!(
578            PerBlockAffine::new_asymmetric(0, 0, 1, 2, ElementType::Float32),
579            Err(Error::InvalidBlockSize { .. })
580        ));
581    }
582
583    #[test]
584    fn new_symmetric_invalid_scale_type_is_err() {
585        // Float32 is valid; Int8 is not a valid scale type.
586        assert!(matches!(
587            PerBlockAffine::new_symmetric(0, 32, 1, ElementType::Int8),
588            Err(Error::InvalidQuantization(_))
589        ));
590    }
591
592    #[test]
593    fn new_asymmetric_invalid_scale_type_is_err() {
594        assert!(matches!(
595            PerBlockAffine::new_asymmetric(0, 32, 1, 2, ElementType::Uint8),
596            Err(Error::InvalidQuantization(_))
597        ));
598    }
599
600    #[test]
601    fn valid_scale_types_are_float16_bfloat16_float32() {
602        // All three valid scale types must succeed.
603        assert!(PerBlockAffine::new_symmetric(0, 32, 1, ElementType::Float16).is_ok());
604        assert!(PerBlockAffine::new_symmetric(0, 32, 1, ElementType::BFloat16).is_ok());
605        assert!(PerBlockAffine::new_symmetric(0, 32, 1, ElementType::Float32).is_ok());
606    }
607
608    // ── num_blocks_per_axis ───────────────────────────────────────────────────
609
610    #[test]
611    fn num_blocks_per_axis_zero_shape_returns_zero() {
612        // ADR-007 empty-axis carve-out.
613        let q = PerBlockAffine::new_symmetric(0, 32, 1, ElementType::Float32).unwrap();
614        assert_eq!(q.num_blocks_per_axis(0), 0);
615    }
616
617    #[test]
618    fn num_blocks_per_axis_exact_divisibility() {
619        let q = PerBlockAffine::new_symmetric(0, 32, 1, ElementType::Float32).unwrap();
620        assert_eq!(q.num_blocks_per_axis(64), 2);
621    }
622
623    #[test]
624    fn num_blocks_per_axis_partial_trailing_block_uses_div_ceil() {
625        let q = PerBlockAffine::new_symmetric(0, 32, 1, ElementType::Float32).unwrap();
626        // 65 / 32 = 2.03125 → ceil = 3.
627        assert_eq!(q.num_blocks_per_axis(65), 3);
628    }
629
630    // ── valid_storage_types ───────────────────────────────────────────────────
631
632    #[test]
633    fn valid_storage_types_contains_integer_sub_byte_and_byte_types() {
634        let types = PerBlockAffine::valid_storage_types();
635        assert!(types.contains(&ElementType::Int8));
636        assert!(types.contains(&ElementType::Uint8));
637        assert!(types.contains(&ElementType::Int4));
638        assert!(types.contains(&ElementType::Uint4));
639        assert!(types.contains(&ElementType::Int2));
640        assert!(types.contains(&ElementType::Uint2));
641    }
642
643    #[test]
644    fn valid_storage_types_does_not_contain_float32() {
645        assert!(!PerBlockAffine::valid_storage_types().contains(&ElementType::Float32));
646    }
647
648    // ── Round-trips ───────────────────────────────────────────────────────────
649
650    fn encode_decode(q: &PerBlockAffine) -> PerBlockAffine {
651        let mut buf = vec![0u8; ENCODED_LEN];
652        let flags = q.flags();
653        buf[0] = SCHEME_TAG;
654        buf[1] = SUPPORTED_VERSION;
655        buf[2] = (flags & 0xFF) as u8;
656        buf[3] = (flags >> 8) as u8;
657        q.encode_payload(&mut buf);
658        PerBlockAffine::decode_payload(flags, &buf).unwrap()
659    }
660
661    #[test]
662    fn round_trip_symmetric() {
663        let original = PerBlockAffine::new_symmetric(1, 64, 2, ElementType::BFloat16).unwrap();
664        let decoded = encode_decode(&original);
665        assert_eq!(decoded, original);
666    }
667
668    #[test]
669    fn round_trip_asymmetric() {
670        let original = PerBlockAffine::new_asymmetric(0, 32, 1, 3, ElementType::Float16).unwrap();
671        let decoded = encode_decode(&original);
672        assert_eq!(decoded, original);
673    }
674
675    // ── validate_against_shape_axis ───────────────────────────────────────────
676
677    #[test]
678    fn validate_against_shape_axis_ok_when_block_size_le_shape() {
679        let q = PerBlockAffine::new_symmetric(0, 32, 1, ElementType::Float32).unwrap();
680        assert!(q.validate_against_shape_axis(32).is_ok());
681        assert!(q.validate_against_shape_axis(64).is_ok());
682    }
683
684    #[test]
685    fn validate_against_shape_axis_ok_when_shape_is_zero_adr007() {
686        // ADR-007 carve-out: empty axis is always accepted.
687        let q = PerBlockAffine::new_symmetric(0, 32, 1, ElementType::Float32).unwrap();
688        assert!(q.validate_against_shape_axis(0).is_ok());
689    }
690
691    #[test]
692    fn validate_against_shape_axis_err_when_block_size_exceeds_shape() {
693        let q = PerBlockAffine::new_symmetric(0, 32, 1, ElementType::Float32).unwrap();
694        assert!(matches!(
695            q.validate_against_shape_axis(16),
696            Err(Error::QuantizationShapeMismatch { .. })
697        ));
698    }
699
700    #[test]
701    fn validate_against_shape_axis_err_for_dynamic_sentinel() {
702        let q = PerBlockAffine::new_symmetric(0, 32, 1, ElementType::Float32).unwrap();
703        assert!(matches!(
704            q.validate_against_shape_axis(u64::MAX),
705            Err(Error::QuantizationShapeMismatch { .. })
706        ));
707    }
708
709    // ── decode_payload error paths ────────────────────────────────────────────
710
711    #[test]
712    fn decode_payload_too_short_is_err() {
713        let short = vec![0u8; ENCODED_LEN - 1];
714        assert!(matches!(
715            PerBlockAffine::decode_payload(0, &short),
716            Err(Error::QuantizationDescriptorTooShort { .. })
717        ));
718    }
719
720    #[test]
721    fn decode_payload_nonzero_reserved_flags_is_err() {
722        let q = PerBlockAffine::new_symmetric(0, 32, 1, ElementType::Float32).unwrap();
723        let mut buf = vec![0u8; ENCODED_LEN];
724        buf[0] = SCHEME_TAG;
725        buf[1] = SUPPORTED_VERSION;
726        let nonzero_flags: u16 = 0xFF00;
727        buf[2] = (nonzero_flags & 0xFF) as u8;
728        buf[3] = (nonzero_flags >> 8) as u8;
729        q.encode_payload(&mut buf);
730        assert!(matches!(
731            PerBlockAffine::decode_payload(nonzero_flags, &buf),
732            Err(Error::ReservedQuantizationFlagsBits { .. })
733        ));
734    }
735
736    #[test]
737    fn decode_payload_invalid_block_size_is_err() {
738        // Manually craft a descriptor with a non-power-of-two block_size.
739        let mut buf = vec![0u8; ENCODED_LEN];
740        buf[0] = SCHEME_TAG;
741        buf[1] = SUPPORTED_VERSION;
742        buf[2] = 0;
743        buf[3] = 0;
744        // axis = 0
745        buf[4..8].copy_from_slice(&0u32.to_le_bytes());
746        // block_size = 33 (not power of two)
747        buf[8..12].copy_from_slice(&33u32.to_le_bytes());
748        // scale_buf = 1, zp_buf sentinel (symmetric)
749        buf[12..16].copy_from_slice(&1u32.to_le_bytes());
750        buf[16..20].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
751        // scale_type = float32 (0x03)
752        buf[20] = 0x03;
753        assert!(matches!(
754            PerBlockAffine::decode_payload(0b1, &buf),
755            Err(Error::InvalidBlockSize { .. })
756        ));
757    }
758
759    #[test]
760    fn decode_payload_invalid_scale_type_tag_is_err() {
761        let mut buf = vec![0u8; ENCODED_LEN];
762        buf[0] = SCHEME_TAG;
763        buf[1] = SUPPORTED_VERSION;
764        // symmetric flag
765        let flags: u16 = 0b1;
766        buf[2] = (flags & 0xFF) as u8;
767        buf[3] = (flags >> 8) as u8;
768        buf[4..8].copy_from_slice(&0u32.to_le_bytes()); // axis
769        buf[8..12].copy_from_slice(&32u32.to_le_bytes()); // block_size
770        buf[12..16].copy_from_slice(&1u32.to_le_bytes()); // scale_buf
771        buf[16..20].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); // zp sentinel
772        buf[20] = 0x10; // invalid scale_type_tag
773        assert!(matches!(
774            PerBlockAffine::decode_payload(flags, &buf),
775            Err(Error::InvalidQuantization(_))
776        ));
777    }
778}