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}