hurray_core/quantization/per_channel_affine.rs
1//! Per-channel affine quantization descriptor (scheme tag `0x02`).
2//!
3//! One `scale` and `zero_point` pair per slice along a specified axis. The scale
4//! and zero-point arrays are stored in separate buffers listed in the tensor
5//! descriptor's buffer table.
6//!
7//! See `docs/spec/quantization/per-channel-affine.md` for the normative definition.
8
9use crate::{ElementType, Error, Result};
10
11// ── Wire layout constants ─────────────────────────────────────────────────────
12
13/// Scheme tag byte for per-channel affine quantization.
14#[allow(dead_code)]
15pub(crate) const SCHEME_TAG: u8 = 0x02;
16
17/// Total descriptor length in bytes (header 4 + axis 4 + scale_buf 4 + zp_buf 4
18/// + scale_type_tag 1 + reserved 3).
19pub(crate) const ENCODED_LEN: usize = 20;
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/// `scale_type_tag` is locked to `float32` (`0x03`) for `scheme_version = 0x01`.
28const SCALE_TYPE_TAG_FLOAT32: u8 = 0x03;
29
30/// Bit 0: symmetric flag — zero-point array is implicit (all zeros).
31pub const FLAG_SYMMETRIC: u16 = 0b1;
32/// All bits except bit 0 are reserved and must be zero.
33pub const RESERVED_MASK: u16 = !FLAG_SYMMETRIC;
34
35// Wire field offsets (relative to start of descriptor, including header).
36const OFFSET_AXIS: usize = 4;
37const OFFSET_SCALE_BUF: usize = 8;
38const OFFSET_ZP_BUF: usize = 12;
39const OFFSET_SCALE_TYPE: usize = 16;
40const OFFSET_RESERVED: usize = 17;
41
42// ── PerChannelAffine ──────────────────────────────────────────────────────────
43
44/// Quantization parameters for per-channel affine quantization.
45///
46/// Each slice along `axis` has its own `scale` (and optionally `zero_point`).
47/// The parameter arrays are stored in separate buffers referenced by index in
48/// the tensor descriptor's buffer table.
49///
50/// The dequantization formula for element `q` at logical index
51/// `[i_0, …, i_{rank-1}]` is:
52///
53/// ```text
54/// c = i_axis
55/// x_real = scale[c] * (q - zero_point[c])
56/// ```
57///
58/// When the `SYMMETRIC` flag is set, `zero_point[c]` is treated as `0`.
59///
60/// # Wire format
61///
62/// Total descriptor length: **20 bytes** (including the 4-byte header).
63///
64/// | Offset | Field | Type |
65/// |--------|-------|------|
66/// | 4 | `axis` | `uint32` LE |
67/// | 8 | `scale_buffer_index` | `uint32` LE |
68/// | 12 | `zero_point_buffer_index` | `uint32` LE (`0xFFFFFFFF` if symmetric) |
69/// | 16 | `scale_type_tag` | `uint8` (must be `0x03` in v1) |
70/// | 17 | `_reserved` | `uint8[3]` (must be `0x00`) |
71///
72/// # Design notes
73///
74/// `PartialEq`, `Eq`, and `Hash` are all derived because this struct contains
75/// no floating-point fields — all fields are integers or `Option<u32>`.
76///
77/// `Copy` because the struct is ≤ 24 bytes with no `Drop` glue.
78///
79/// # Examples
80///
81/// ```
82/// use hurray_core::PerChannelAffine;
83///
84/// // Asymmetric: separate scale (buffer 1) and zero-point (buffer 2) arrays.
85/// let q = PerChannelAffine::new_asymmetric(0, 1, 2).unwrap();
86/// assert!(!q.is_symmetric());
87/// assert_eq!(q.zero_point_buffer_index(), Some(2));
88///
89/// // Symmetric: only a scale buffer, zero-point is implicitly zero.
90/// let s = PerChannelAffine::new_symmetric(0, 1).unwrap();
91/// assert!(s.is_symmetric());
92/// assert_eq!(s.zero_point_buffer_index(), None);
93/// ```
94// WHY Eq + Hash: no float fields, so the IEEE 754 NaN problem does not apply.
95// WHY Copy: ≤24 bytes, no Drop glue (design decision #7).
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
97#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
98pub struct PerChannelAffine {
99 axis: u32,
100 scale_buffer_index: u32,
101 /// `None` encodes the symmetric case (wire sentinel `0xFFFFFFFF`).
102 ///
103 /// WHY `Option<u32>`: `None` maps to wire sentinel `0xFFFFFFFF`; makes the
104 /// symmetric/asymmetric distinction unrepresentable-when-wrong at the type
105 /// level (design decision #6).
106 zero_point_buffer_index: Option<u32>,
107 scale_type: ElementType,
108}
109
110impl PerChannelAffine {
111 /// Creates an asymmetric [`PerChannelAffine`] descriptor.
112 ///
113 /// # Errors
114 ///
115 /// - [`Error::InvalidQuantization`] — `scale_buf` equals `0xFFFFFFFF`
116 /// (the zero-point sentinel value, which is not a valid scale buffer index).
117 ///
118 /// # Examples
119 ///
120 /// ```
121 /// use hurray_core::PerChannelAffine;
122 ///
123 /// let q = PerChannelAffine::new_asymmetric(0, 1, 2).unwrap();
124 /// assert!(!q.is_symmetric());
125 /// assert_eq!(q.scale_buffer_index(), 1);
126 /// assert_eq!(q.zero_point_buffer_index(), Some(2));
127 /// ```
128 pub fn new_asymmetric(
129 axis: u32,
130 scale_buffer_index: u32,
131 zero_point_buffer_index: u32,
132 ) -> Result<Self> {
133 if scale_buffer_index == ZP_SENTINEL {
134 return Err(Error::InvalidQuantization(
135 "scale_buffer_index must not be 0xFFFFFFFF (reserved sentinel)".into(),
136 ));
137 }
138 Ok(Self {
139 axis,
140 scale_buffer_index,
141 zero_point_buffer_index: Some(zero_point_buffer_index),
142 scale_type: ElementType::Float32,
143 })
144 }
145
146 /// Creates a symmetric [`PerChannelAffine`] descriptor.
147 ///
148 /// In symmetric mode the zero-point array is implicit (all zeros); no
149 /// zero-point buffer entry is required in the tensor descriptor's buffer table.
150 ///
151 /// # Errors
152 ///
153 /// - [`Error::InvalidQuantization`] — `scale_buf` equals `0xFFFFFFFF`.
154 ///
155 /// # Examples
156 ///
157 /// ```
158 /// use hurray_core::PerChannelAffine;
159 ///
160 /// let q = PerChannelAffine::new_symmetric(1, 2).unwrap();
161 /// assert!(q.is_symmetric());
162 /// assert_eq!(q.zero_point_buffer_index(), None);
163 /// ```
164 pub fn new_symmetric(axis: u32, scale_buffer_index: u32) -> Result<Self> {
165 if scale_buffer_index == ZP_SENTINEL {
166 return Err(Error::InvalidQuantization(
167 "scale_buffer_index must not be 0xFFFFFFFF (reserved sentinel)".into(),
168 ));
169 }
170 Ok(Self {
171 axis,
172 scale_buffer_index,
173 zero_point_buffer_index: None,
174 scale_type: ElementType::Float32,
175 })
176 }
177
178 /// Returns `true` if this descriptor uses symmetric quantization (no zero point).
179 ///
180 /// # Examples
181 ///
182 /// ```
183 /// use hurray_core::PerChannelAffine;
184 ///
185 /// assert!(PerChannelAffine::new_symmetric(0, 1).unwrap().is_symmetric());
186 /// assert!(!PerChannelAffine::new_asymmetric(0, 1, 2).unwrap().is_symmetric());
187 /// ```
188 #[inline]
189 pub fn is_symmetric(&self) -> bool {
190 self.zero_point_buffer_index.is_none()
191 }
192
193 /// Returns the quantization axis index.
194 ///
195 /// # Examples
196 ///
197 /// ```
198 /// use hurray_core::PerChannelAffine;
199 ///
200 /// let q = PerChannelAffine::new_symmetric(3, 1).unwrap();
201 /// assert_eq!(q.axis(), 3);
202 /// ```
203 #[inline]
204 pub fn axis(&self) -> u32 {
205 self.axis
206 }
207
208 /// Returns the buffer table index of the per-channel scale array.
209 ///
210 /// # Examples
211 ///
212 /// ```
213 /// use hurray_core::PerChannelAffine;
214 ///
215 /// let q = PerChannelAffine::new_symmetric(0, 5).unwrap();
216 /// assert_eq!(q.scale_buffer_index(), 5);
217 /// ```
218 #[inline]
219 pub fn scale_buffer_index(&self) -> u32 {
220 self.scale_buffer_index
221 }
222
223 /// Returns the buffer table index of the per-channel zero-point array, or
224 /// `None` if this descriptor is symmetric.
225 ///
226 /// `None` maps to the wire sentinel `0xFFFFFFFF`.
227 ///
228 /// # Examples
229 ///
230 /// ```
231 /// use hurray_core::PerChannelAffine;
232 ///
233 /// let asym = PerChannelAffine::new_asymmetric(0, 1, 2).unwrap();
234 /// assert_eq!(asym.zero_point_buffer_index(), Some(2));
235 ///
236 /// let sym = PerChannelAffine::new_symmetric(0, 1).unwrap();
237 /// assert_eq!(sym.zero_point_buffer_index(), None);
238 /// ```
239 #[inline]
240 pub fn zero_point_buffer_index(&self) -> Option<u32> {
241 self.zero_point_buffer_index
242 }
243
244 /// Returns the element type used for scale values (always `Float32` in v1).
245 ///
246 /// # Examples
247 ///
248 /// ```
249 /// use hurray_core::{ElementType, PerChannelAffine};
250 ///
251 /// let q = PerChannelAffine::new_symmetric(0, 1).unwrap();
252 /// assert_eq!(q.scale_type(), ElementType::Float32);
253 /// ```
254 #[inline]
255 pub fn scale_type(&self) -> ElementType {
256 self.scale_type
257 }
258
259 /// Returns the set of storage [`ElementType`]s that are valid for this scheme.
260 ///
261 /// Per `docs/spec/quantization/per-channel-affine.md § Valid Storage Types`.
262 ///
263 /// WHY `&'static [ElementType]`: no allocation per call; `slice::contains`
264 /// over ≤10 items beats any hash structure (design decision #5).
265 ///
266 /// # Examples
267 ///
268 /// ```
269 /// use hurray_core::{ElementType, PerChannelAffine};
270 ///
271 /// assert!(PerChannelAffine::valid_storage_types().contains(&ElementType::Int8));
272 /// assert!(!PerChannelAffine::valid_storage_types().contains(&ElementType::Float32));
273 /// ```
274 pub fn valid_storage_types() -> &'static [ElementType] {
275 &[
276 ElementType::Int8,
277 ElementType::Uint8,
278 ElementType::Int16,
279 ElementType::Uint16,
280 ElementType::Int32,
281 ElementType::Uint32,
282 ElementType::Int4,
283 ElementType::Uint4,
284 ElementType::Int2,
285 ElementType::Uint2,
286 ]
287 }
288
289 // ── Crate-internal encode/decode ──────────────────────────────────────────
290
291 /// Decodes the scheme-specific payload from `bytes`.
292 ///
293 /// `bytes` is the full descriptor slice (including the 4-byte header).
294 /// `flags` are the header flags already read by the caller.
295 pub(crate) fn decode_payload(flags: u16, bytes: &[u8]) -> Result<Self> {
296 if bytes.len() < ENCODED_LEN {
297 return Err(Error::QuantizationDescriptorTooShort {
298 found: bytes.len(),
299 needed: ENCODED_LEN,
300 });
301 }
302 // Validate that no reserved flag bits are set.
303 if flags & RESERVED_MASK != 0 {
304 return Err(Error::ReservedQuantizationFlagsBits {
305 flags,
306 mask: RESERVED_MASK,
307 });
308 }
309 let symmetric = flags & FLAG_SYMMETRIC != 0;
310
311 let axis = u32::from_le_bytes(
312 bytes[OFFSET_AXIS..OFFSET_AXIS + 4]
313 .try_into()
314 .map_err(|_| Error::InvalidQuantization("axis slice error".into()))?,
315 );
316 let scale_buf = u32::from_le_bytes(
317 bytes[OFFSET_SCALE_BUF..OFFSET_SCALE_BUF + 4]
318 .try_into()
319 .map_err(|_| Error::InvalidQuantization("scale_buffer_index slice error".into()))?,
320 );
321 let zp_buf_raw =
322 u32::from_le_bytes(bytes[OFFSET_ZP_BUF..OFFSET_ZP_BUF + 4].try_into().map_err(
323 |_| Error::InvalidQuantization("zero_point_buffer_index slice error".into()),
324 )?);
325 let scale_type_byte = bytes[OFFSET_SCALE_TYPE];
326
327 // scale_buffer_index must not be the sentinel.
328 if scale_buf == ZP_SENTINEL {
329 return Err(Error::InvalidQuantization(
330 "scale_buffer_index must not be 0xFFFFFFFF".into(),
331 ));
332 }
333
334 // scale_type_tag must be 0x03 (float32) for scheme_version 0x01.
335 if scale_type_byte != SCALE_TYPE_TAG_FLOAT32 {
336 return Err(Error::InvalidQuantization(format!(
337 "per-channel affine scale_type_tag must be 0x03 (float32) in v1, got 0x{scale_type_byte:02X}"
338 )));
339 }
340
341 // Reserved bytes [17..20] must be 0x00.
342 if bytes[OFFSET_RESERVED..OFFSET_RESERVED + 3]
343 .iter()
344 .any(|&b| b != 0)
345 {
346 return Err(Error::InvalidQuantization(
347 "per-channel affine reserved bytes [17..20] must be 0x00".into(),
348 ));
349 }
350
351 // SYMMETRIC flag and zero_point_buffer_index sentinel must be consistent.
352 let zero_point_buffer_index = if symmetric {
353 if zp_buf_raw != ZP_SENTINEL {
354 return Err(Error::InvalidQuantization(
355 "SYMMETRIC flag is set but zero_point_buffer_index is not 0xFFFFFFFF".into(),
356 ));
357 }
358 None
359 } else {
360 if zp_buf_raw == ZP_SENTINEL {
361 return Err(Error::InvalidQuantization(
362 "SYMMETRIC flag is not set but zero_point_buffer_index is 0xFFFFFFFF".into(),
363 ));
364 }
365 Some(zp_buf_raw)
366 };
367
368 Ok(Self {
369 axis,
370 scale_buffer_index: scale_buf,
371 zero_point_buffer_index,
372 scale_type: ElementType::Float32,
373 })
374 }
375
376 /// Encodes the scheme-specific payload into `out`.
377 ///
378 /// `out` must be at least [`ENCODED_LEN`] bytes. The caller writes the 4-byte
379 /// header; this method writes bytes 4–19.
380 pub(crate) fn encode_payload(&self, out: &mut [u8]) {
381 out[OFFSET_AXIS..OFFSET_AXIS + 4].copy_from_slice(&self.axis.to_le_bytes());
382 out[OFFSET_SCALE_BUF..OFFSET_SCALE_BUF + 4]
383 .copy_from_slice(&self.scale_buffer_index.to_le_bytes());
384 let zp_wire = self.zero_point_buffer_index.unwrap_or(ZP_SENTINEL);
385 out[OFFSET_ZP_BUF..OFFSET_ZP_BUF + 4].copy_from_slice(&zp_wire.to_le_bytes());
386 out[OFFSET_SCALE_TYPE] = SCALE_TYPE_TAG_FLOAT32;
387 out[OFFSET_RESERVED..OFFSET_RESERVED + 3].fill(0);
388 }
389
390 /// Returns the flags word that encodes the symmetric/asymmetric state.
391 pub(crate) fn flags(&self) -> u16 {
392 if self.is_symmetric() {
393 FLAG_SYMMETRIC
394 } else {
395 0
396 }
397 }
398}
399
400// ── Tests ─────────────────────────────────────────────────────────────────────
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use crate::{ElementType, Error};
406
407 // ── Constructors ──────────────────────────────────────────────────────────
408
409 #[test]
410 fn new_symmetric_sentinel_scale_buf_is_err() {
411 // scale_buffer_index == 0xFFFFFFFF is the ZP sentinel — must be rejected.
412 assert!(matches!(
413 PerChannelAffine::new_symmetric(0, 0xFFFF_FFFF),
414 Err(Error::InvalidQuantization(_))
415 ));
416 }
417
418 #[test]
419 fn new_asymmetric_sentinel_scale_buf_is_err() {
420 assert!(matches!(
421 PerChannelAffine::new_asymmetric(0, 0xFFFF_FFFF, 1),
422 Err(Error::InvalidQuantization(_))
423 ));
424 }
425
426 #[test]
427 fn new_symmetric_is_symmetric() {
428 let q = PerChannelAffine::new_symmetric(0, 1).unwrap();
429 assert!(q.is_symmetric());
430 assert_eq!(q.zero_point_buffer_index(), None);
431 }
432
433 #[test]
434 fn new_asymmetric_is_not_symmetric() {
435 let q = PerChannelAffine::new_asymmetric(0, 1, 2).unwrap();
436 assert!(!q.is_symmetric());
437 assert_eq!(q.zero_point_buffer_index(), Some(2));
438 }
439
440 #[test]
441 fn scale_type_is_always_float32() {
442 let q = PerChannelAffine::new_symmetric(0, 1).unwrap();
443 assert_eq!(q.scale_type(), ElementType::Float32);
444 }
445
446 // ── Round-trips ───────────────────────────────────────────────────────────
447
448 fn encode_decode(q: &PerChannelAffine) -> PerChannelAffine {
449 let mut buf = vec![0u8; ENCODED_LEN];
450 let flags = q.flags();
451 // Write header: scheme_tag=0x02, version=0x01, flags LE.
452 buf[0] = SCHEME_TAG;
453 buf[1] = SUPPORTED_VERSION;
454 buf[2] = (flags & 0xFF) as u8;
455 buf[3] = (flags >> 8) as u8;
456 q.encode_payload(&mut buf);
457 PerChannelAffine::decode_payload(flags, &buf).unwrap()
458 }
459
460 #[test]
461 fn round_trip_symmetric() {
462 let original = PerChannelAffine::new_symmetric(3, 5).unwrap();
463 let decoded = encode_decode(&original);
464 assert_eq!(decoded, original);
465 }
466
467 #[test]
468 fn round_trip_asymmetric() {
469 let original = PerChannelAffine::new_asymmetric(1, 2, 4).unwrap();
470 let decoded = encode_decode(&original);
471 assert_eq!(decoded, original);
472 }
473
474 // ── decode_payload error cases ────────────────────────────────────────────
475
476 #[test]
477 fn decode_payload_too_short_is_err() {
478 let short = vec![0u8; ENCODED_LEN - 1];
479 assert!(matches!(
480 PerChannelAffine::decode_payload(0, &short),
481 Err(Error::QuantizationDescriptorTooShort { .. })
482 ));
483 }
484
485 #[test]
486 fn decode_payload_reserved_flag_bits_is_err() {
487 let mut buf = vec![0u8; ENCODED_LEN];
488 buf[0] = SCHEME_TAG;
489 buf[1] = SUPPORTED_VERSION;
490 // Write a valid scale_buffer_index.
491 buf[8..12].copy_from_slice(&1u32.to_le_bytes());
492 // Write ZP sentinel (symmetric).
493 buf[12..16].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
494 buf[16] = 0x03; // valid scale_type_tag
495 // flags = 0x0002 — reserved bit.
496 assert!(matches!(
497 PerChannelAffine::decode_payload(0x0002, &buf),
498 Err(Error::ReservedQuantizationFlagsBits { .. })
499 ));
500 }
501
502 #[test]
503 fn decode_payload_sentinel_scale_buf_is_err() {
504 let mut buf = vec![0u8; ENCODED_LEN];
505 buf[0] = SCHEME_TAG;
506 buf[1] = SUPPORTED_VERSION;
507 // scale_buffer_index = 0xFFFFFFFF.
508 buf[8..12].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
509 buf[12..16].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
510 buf[16] = 0x03;
511 assert!(matches!(
512 PerChannelAffine::decode_payload(FLAG_SYMMETRIC, &buf),
513 Err(Error::InvalidQuantization(_))
514 ));
515 }
516
517 #[test]
518 fn decode_payload_wrong_scale_type_tag_is_err() {
519 let mut buf = vec![0u8; ENCODED_LEN];
520 buf[0] = SCHEME_TAG;
521 buf[1] = SUPPORTED_VERSION;
522 buf[8..12].copy_from_slice(&1u32.to_le_bytes());
523 buf[12..16].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
524 // scale_type_tag = 0x01 (float16) — must be 0x03 in v1.
525 buf[16] = 0x01;
526 assert!(matches!(
527 PerChannelAffine::decode_payload(FLAG_SYMMETRIC, &buf),
528 Err(Error::InvalidQuantization(_))
529 ));
530 }
531
532 #[test]
533 fn decode_payload_symmetric_flag_set_but_zp_not_sentinel_is_err() {
534 let mut buf = vec![0u8; ENCODED_LEN];
535 buf[0] = SCHEME_TAG;
536 buf[1] = SUPPORTED_VERSION;
537 buf[8..12].copy_from_slice(&1u32.to_le_bytes());
538 // zp_buf = 2 (not sentinel), but SYMMETRIC flag is set — inconsistent.
539 buf[12..16].copy_from_slice(&2u32.to_le_bytes());
540 buf[16] = 0x03;
541 assert!(matches!(
542 PerChannelAffine::decode_payload(FLAG_SYMMETRIC, &buf),
543 Err(Error::InvalidQuantization(_))
544 ));
545 }
546
547 #[test]
548 fn decode_payload_symmetric_flag_not_set_but_zp_sentinel_is_err() {
549 let mut buf = vec![0u8; ENCODED_LEN];
550 buf[0] = SCHEME_TAG;
551 buf[1] = SUPPORTED_VERSION;
552 buf[8..12].copy_from_slice(&1u32.to_le_bytes());
553 // zp_buf = sentinel, but SYMMETRIC flag is NOT set — inconsistent.
554 buf[12..16].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
555 buf[16] = 0x03;
556 // flags = 0 (no SYMMETRIC bit).
557 assert!(matches!(
558 PerChannelAffine::decode_payload(0, &buf),
559 Err(Error::InvalidQuantization(_))
560 ));
561 }
562
563 #[test]
564 fn decode_payload_nonzero_reserved_bytes_is_err() {
565 let mut buf = vec![0u8; ENCODED_LEN];
566 buf[0] = SCHEME_TAG;
567 buf[1] = SUPPORTED_VERSION;
568 buf[8..12].copy_from_slice(&1u32.to_le_bytes());
569 buf[12..16].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
570 buf[16] = 0x03;
571 // Pollute reserved byte [17].
572 buf[17] = 0xAB;
573 assert!(matches!(
574 PerChannelAffine::decode_payload(FLAG_SYMMETRIC, &buf),
575 Err(Error::InvalidQuantization(_))
576 ));
577 }
578}