Skip to main content

hurray_core/layout/
block_paged.rs

1//! Block-paged layout descriptor.
2//!
3//! Tag `0x0A`. Rank MUST be 3. Buffer count = 3 (page_pool + block_table + seq_ptr).
4//! See `docs/spec/layouts/block-paged.md`.
5
6/// KV-cache role for a block-paged tensor.
7///
8/// The wire value is a `uint8` in the additional descriptor fields of a
9/// block-paged layout (see `docs/spec/layouts/block-paged.md § Additional Descriptor Fields`).
10///
11/// # Examples
12///
13/// ```
14/// use hurray_core::layout::KvRole;
15///
16/// assert_eq!(KvRole::Key.wire_byte(), 0x00);
17/// assert_eq!(KvRole::Value.wire_byte(), 0x01);
18/// assert_eq!(KvRole::Fused.wire_byte(), 0x02);
19/// ```
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21#[non_exhaustive]
22pub enum KvRole {
23    /// Key tensor (`kv_role = 0x00`).
24    Key,
25    /// Value tensor (`kv_role = 0x01`).
26    Value,
27    /// Fused or non-KV generic tensor (`kv_role = 0x02`).
28    Fused,
29}
30
31impl KvRole {
32    /// Returns the wire byte for this role.
33    ///
34    /// # Examples
35    ///
36    /// ```
37    /// use hurray_core::layout::KvRole;
38    ///
39    /// assert_eq!(KvRole::Key.wire_byte(), 0x00);
40    /// assert_eq!(KvRole::Value.wire_byte(), 0x01);
41    /// assert_eq!(KvRole::Fused.wire_byte(), 0x02);
42    /// ```
43    #[inline]
44    pub fn wire_byte(self) -> u8 {
45        match self {
46            Self::Key => 0x00,
47            Self::Value => 0x01,
48            Self::Fused => 0x02,
49        }
50    }
51
52    /// Constructs a [`KvRole`] from its wire byte.
53    ///
54    /// Returns `None` for unrecognized values (future spec extensions).
55    ///
56    /// # Examples
57    ///
58    /// ```
59    /// use hurray_core::layout::KvRole;
60    ///
61    /// assert_eq!(KvRole::from_wire(0x00), Some(KvRole::Key));
62    /// assert_eq!(KvRole::from_wire(0x03), None);
63    /// ```
64    #[inline]
65    pub fn from_wire(byte: u8) -> Option<Self> {
66        match byte {
67            0x00 => Some(Self::Key),
68            0x01 => Some(Self::Value),
69            0x02 => Some(Self::Fused),
70            _ => None,
71        }
72    }
73}
74
75/// Element type for the `block_table` and `seq_ptr` index buffers.
76///
77/// Both buffers share the same index type, selected by `block_table_index_type`
78/// in the block-paged descriptor fields. `U32` addresses up to ~4 billion pages;
79/// `U64` is required for larger pools.
80///
81/// See `docs/spec/layouts/block-paged.md § Additional Descriptor Fields`.
82///
83/// # Examples
84///
85/// ```
86/// use hurray_core::layout::BlockTableIndexType;
87///
88/// assert_eq!(BlockTableIndexType::U32.wire_byte(), 0x00);
89/// assert_eq!(BlockTableIndexType::U64.wire_byte(), 0x01);
90/// ```
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
92#[non_exhaustive]
93pub enum BlockTableIndexType {
94    /// 32-bit unsigned index (`block_table_index_type = 0x00`). Default.
95    U32,
96    /// 64-bit unsigned index (`block_table_index_type = 0x01`).
97    U64,
98}
99
100impl BlockTableIndexType {
101    /// Returns the wire byte for this index type.
102    ///
103    /// # Examples
104    ///
105    /// ```
106    /// use hurray_core::layout::BlockTableIndexType;
107    ///
108    /// assert_eq!(BlockTableIndexType::U32.wire_byte(), 0x00);
109    /// assert_eq!(BlockTableIndexType::U64.wire_byte(), 0x01);
110    /// ```
111    #[inline]
112    pub fn wire_byte(self) -> u8 {
113        match self {
114            Self::U32 => 0x00,
115            Self::U64 => 0x01,
116        }
117    }
118
119    /// Constructs a [`BlockTableIndexType`] from its wire byte.
120    ///
121    /// Returns `None` for unrecognized values (future spec extensions).
122    ///
123    /// # Examples
124    ///
125    /// ```
126    /// use hurray_core::layout::BlockTableIndexType;
127    ///
128    /// assert_eq!(BlockTableIndexType::from_wire(0x00), Some(BlockTableIndexType::U32));
129    /// assert_eq!(BlockTableIndexType::from_wire(0x02), None);
130    /// ```
131    #[inline]
132    pub fn from_wire(byte: u8) -> Option<Self> {
133        match byte {
134            0x00 => Some(Self::U32),
135            0x01 => Some(Self::U64),
136            _ => None,
137        }
138    }
139
140    /// Returns the size in bytes of a single index element for this type.
141    ///
142    /// # Examples
143    ///
144    /// ```
145    /// use hurray_core::layout::BlockTableIndexType;
146    ///
147    /// assert_eq!(BlockTableIndexType::U32.element_bytes(), 4);
148    /// assert_eq!(BlockTableIndexType::U64.element_bytes(), 8);
149    /// ```
150    #[inline]
151    pub fn element_bytes(self) -> usize {
152        match self {
153            Self::U32 => 4,
154            Self::U64 => 8,
155        }
156    }
157}
158
159/// The wire sentinel for `layer_index` meaning "not layer-scoped".
160///
161/// When `layer_index` is `0xFFFFFFFF` on the wire the field encodes
162/// [`None`] in Rust; any other value encodes `Some(n)`.
163pub(crate) const LAYER_INDEX_NONE: u32 = 0xFFFF_FFFF;
164
165/// Descriptor for the block-paged indirect layout.
166///
167/// Block-paged stores a KV-cache tensor whose **paged axis** is divided into
168/// fixed-size pages drawn from a shared **page pool**, with a **block table**
169/// mapping each logical page position to a physical page ID. It is the
170/// interchange form of a PagedAttention KV cache.
171///
172/// This layout is **defined only for rank-3 tensors** in this version of the
173/// specification: `[total_tokens, num_heads, head_dim]`.
174///
175/// Three buffers are always required:
176///
177/// | Buffer | Name | Description |
178/// |--------|------|-------------|
179/// | 0 | `page_pool` | Flat pool of fixed-size pages. |
180/// | 1 | `block_table` | Concatenated per-sequence page-ID lists. |
181/// | 2 | `seq_ptr` | CSR-style offset array into `block_table`. |
182///
183/// When the tensor carries quantization parameters, additional buffers appear
184/// at indices 3 and above per `docs/spec/quantization.md § Buffer Table Placement Rules`.
185///
186/// See `docs/spec/layouts/block-paged.md` for the full normative definition.
187///
188/// # Examples
189///
190/// ```
191/// use hurray_core::layout::{BlockPagedLayout, BlockTableIndexType, KvRole, LayoutDescriptor};
192///
193/// let layout = BlockPagedLayout::new(16, 128, 0, 4, KvRole::Key, Some(3), BlockTableIndexType::U32);
194/// assert_eq!(layout.page_size, 16);
195/// assert_eq!(layout.num_pages, 128);
196/// assert_eq!(layout.num_seqs, 4);
197/// assert_eq!(layout.layer_index, Some(3));
198///
199/// let desc = LayoutDescriptor::BlockPaged(layout);
200/// assert_eq!(desc.tag(), 0x0A);
201/// assert_eq!(desc.buffer_count().map(|n| n.get()), Some(3));
202/// ```
203#[derive(Debug, Clone, PartialEq, Eq, Hash)]
204#[non_exhaustive]
205pub struct BlockPagedLayout {
206    /// Tokens per page. MUST be >= 1.
207    ///
208    /// Common values are 16 and 32 (matching vLLM's default `block_size`).
209    pub page_size: u32,
210
211    /// Number of physical pages in `page_pool`.
212    pub num_pages: u64,
213
214    /// The axis subdivided into pages. MUST be 0 in this spec version.
215    ///
216    /// Kept as a field rather than a constant so that the wire format remains
217    /// forward-compatible if a future spec version adds non-zero paged axes.
218    pub paged_axis: u32,
219
220    /// Number of sequences in the batch. MAY be 0 for an empty batch.
221    pub num_seqs: u32,
222
223    /// KV-cache role for this tensor (key, value, or fused/generic).
224    pub kv_role: KvRole,
225
226    /// Transformer layer index, or `None` if the tensor is not layer-scoped.
227    ///
228    /// Wire value `0xFFFFFFFF` maps to `None`; all other values map to `Some(n)`.
229    pub layer_index: Option<u32>,
230
231    /// Element type for both the `block_table` (buffer 1) and `seq_ptr` (buffer 2).
232    ///
233    /// `U32` is the default and addresses up to 4 billion pages.
234    /// `U64` is required for larger pools.
235    pub block_table_index_type: BlockTableIndexType,
236}
237
238impl BlockPagedLayout {
239    /// Creates a new [`BlockPagedLayout`].
240    ///
241    /// # Arguments
242    ///
243    /// - `page_size` — tokens per page (MUST be validated >= 1 later via `validate_against_shape`).
244    /// - `num_pages` — number of physical pages in the pool.
245    /// - `paged_axis` — the axis subdivided into pages (MUST be 0 in this version).
246    /// - `num_seqs` — number of sequences in the batch.
247    /// - `kv_role` — KV-cache role for this tensor.
248    /// - `layer_index` — transformer layer index (`None` = not layer-scoped).
249    /// - `block_table_index_type` — element type for `block_table` and `seq_ptr` buffers.
250    ///
251    /// This constructor does not validate `page_size >= 1` or `paged_axis == 0` because
252    /// the tensor shape is not available at construction time.  Call
253    /// [`LayoutDescriptor::validate_against_shape`] to perform all invariant checks
254    /// once the shape is known.
255    ///
256    /// # Examples
257    ///
258    /// ```
259    /// use hurray_core::layout::{BlockPagedLayout, BlockTableIndexType, KvRole};
260    ///
261    /// // Layer-3 key cache: page_size=16, 64 pages, 2 sequences, uint32 indices.
262    /// let layout = BlockPagedLayout::new(
263    ///     16, 64, 0, 2,
264    ///     KvRole::Key,
265    ///     Some(3),
266    ///     BlockTableIndexType::U32,
267    /// );
268    /// assert_eq!(layout.page_size, 16);
269    /// assert_eq!(layout.layer_index, Some(3));
270    /// ```
271    #[allow(clippy::too_many_arguments)]
272    pub fn new(
273        page_size: u32,
274        num_pages: u64,
275        paged_axis: u32,
276        num_seqs: u32,
277        kv_role: KvRole,
278        layer_index: Option<u32>,
279        block_table_index_type: BlockTableIndexType,
280    ) -> Self {
281        Self {
282            page_size,
283            num_pages,
284            paged_axis,
285            num_seqs,
286            kv_role,
287            layer_index,
288            block_table_index_type,
289        }
290    }
291
292    /// Validates that a quantization scheme is compatible with this block-paged layout.
293    ///
294    /// Per `docs/spec/layouts/block-paged.md § Quantization Compatibility`:
295    ///
296    /// - **Per-tensor** (`scheme_tag = 0x01`): always compatible.
297    /// - **Per-channel** (`scheme_tag = 0x02`): MUST NOT be applied on axis 0 (the
298    ///   paged / token axis). `quant_axis` must be 1 (`num_heads`) or 2 (`head_dim`).
299    /// - **Per-block-affine** (`scheme_tag = 0x03`): `quant_axis` MUST be 0 and
300    ///   `quant_block_size` MUST equal `page_size`.
301    /// - All other schemes: not validated here; returns `Ok(())`.
302    ///
303    /// Call this at the typed-quantization layer once you have a decoded
304    /// [`crate::QuantizationDescriptor`]. The raw-bytes descriptor layer cannot
305    /// perform this check.
306    ///
307    /// # Arguments
308    ///
309    /// - `scheme_tag` — the `scheme_tag` byte from the quantization descriptor header.
310    /// - `quant_axis` — the `axis` field from the quantization descriptor (relevant for
311    ///   per-channel and per-block-affine).
312    /// - `quant_block_size` — the `block_size` field from the per-block-affine descriptor
313    ///   (ignored for all other schemes).
314    ///
315    /// # Errors
316    ///
317    /// Returns [`crate::Error::InvalidQuantization`] if the scheme/layout combination
318    /// violates the spec rules above.
319    ///
320    /// # Examples
321    ///
322    /// ```
323    /// use hurray_core::layout::{BlockPagedLayout, BlockTableIndexType, KvRole};
324    ///
325    /// let layout = BlockPagedLayout::new(16, 64, 0, 2, KvRole::Key, Some(0), BlockTableIndexType::U32);
326    ///
327    /// // Per-tensor (0x01) is always valid.
328    /// assert!(layout.validate_quantization_compatibility(0x01, 0, 0).is_ok());
329    ///
330    /// // Per-channel on axis 1 (num_heads) is valid.
331    /// assert!(layout.validate_quantization_compatibility(0x02, 1, 0).is_ok());
332    ///
333    /// // Per-channel on axis 0 (paged axis) is forbidden.
334    /// assert!(layout.validate_quantization_compatibility(0x02, 0, 0).is_err());
335    ///
336    /// // Per-block-affine with axis=0 and block_size==page_size is valid.
337    /// assert!(layout.validate_quantization_compatibility(0x03, 0, 16).is_ok());
338    ///
339    /// // Per-block-affine with block_size != page_size is forbidden.
340    /// assert!(layout.validate_quantization_compatibility(0x03, 0, 32).is_err());
341    /// ```
342    pub fn validate_quantization_compatibility(
343        &self,
344        scheme_tag: u8,
345        quant_axis: u32,
346        quant_block_size: u32,
347    ) -> crate::Result<()> {
348        const SCHEME_PER_CHANNEL: u8 = 0x02;
349        const SCHEME_PER_BLOCK_AFFINE: u8 = 0x03;
350
351        match scheme_tag {
352            SCHEME_PER_CHANNEL => {
353                // Per-channel MUST NOT be applied to the paged axis (axis 0).
354                // axis 1 (num_heads) and axis 2 (head_dim) are permitted.
355                if quant_axis == 0 {
356                    return Err(crate::Error::InvalidQuantization(
357                        "block-paged: per-channel quantization (scheme 0x02) MUST NOT be applied \
358                         to the paged axis (axis 0); use axis 1 (num_heads) or axis 2 (head_dim)"
359                            .to_string(),
360                    ));
361                }
362            }
363            SCHEME_PER_BLOCK_AFFINE => {
364                // axis MUST be 0 (the paged / token axis).
365                if quant_axis != 0 {
366                    return Err(crate::Error::InvalidQuantization(format!(
367                        "block-paged: per-block-affine quantization (scheme 0x03) requires \
368                         axis = 0, got axis = {quant_axis}"
369                    )));
370                }
371                // block_size MUST equal page_size so scales align with page slots.
372                if quant_block_size != self.page_size {
373                    return Err(crate::Error::InvalidQuantization(format!(
374                        "block-paged: per-block-affine quantization (scheme 0x03) requires \
375                         block_size == page_size ({}), got block_size = {}",
376                        self.page_size, quant_block_size
377                    )));
378                }
379            }
380            // Per-tensor (0x01) and all other schemes compose without paged-layout constraints.
381            _ => {}
382        }
383        Ok(())
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::layout::LayoutDescriptor;
391    use std::num::NonZeroU8;
392
393    // ── KvRole wire mapping ───────────────────────────────────────────────────
394
395    /// Spec §block-paged.md §Additional Descriptor Fields: kv_role wire bytes.
396    #[test]
397    fn kv_role_wire_byte_key_is_0x00() {
398        assert_eq!(KvRole::Key.wire_byte(), 0x00);
399    }
400
401    #[test]
402    fn kv_role_wire_byte_value_is_0x01() {
403        assert_eq!(KvRole::Value.wire_byte(), 0x01);
404    }
405
406    #[test]
407    fn kv_role_wire_byte_fused_is_0x02() {
408        assert_eq!(KvRole::Fused.wire_byte(), 0x02);
409    }
410
411    #[test]
412    fn kv_role_from_wire_round_trips_all_variants() {
413        for variant in [KvRole::Key, KvRole::Value, KvRole::Fused] {
414            let byte = variant.wire_byte();
415            let decoded = KvRole::from_wire(byte);
416            assert_eq!(
417                decoded,
418                Some(variant),
419                "KvRole::from_wire(0x{byte:02X}) should return {variant:?}"
420            );
421        }
422    }
423
424    #[test]
425    fn kv_role_from_wire_rejects_0x03() {
426        assert_eq!(KvRole::from_wire(0x03), None);
427    }
428
429    #[test]
430    fn kv_role_from_wire_rejects_0xff() {
431        assert_eq!(KvRole::from_wire(0xFF), None);
432    }
433
434    // ── BlockTableIndexType wire mapping ──────────────────────────────────────
435
436    /// Spec §block-paged.md §Additional Descriptor Fields: block_table_index_type wire bytes.
437    #[test]
438    fn block_table_index_type_wire_byte_u32_is_0x00() {
439        assert_eq!(BlockTableIndexType::U32.wire_byte(), 0x00);
440    }
441
442    #[test]
443    fn block_table_index_type_wire_byte_u64_is_0x01() {
444        assert_eq!(BlockTableIndexType::U64.wire_byte(), 0x01);
445    }
446
447    #[test]
448    fn block_table_index_type_from_wire_round_trips_all_variants() {
449        for variant in [BlockTableIndexType::U32, BlockTableIndexType::U64] {
450            let byte = variant.wire_byte();
451            let decoded = BlockTableIndexType::from_wire(byte);
452            assert_eq!(
453                decoded,
454                Some(variant),
455                "BlockTableIndexType::from_wire(0x{byte:02X}) should return {variant:?}"
456            );
457        }
458    }
459
460    #[test]
461    fn block_table_index_type_from_wire_rejects_0x02() {
462        assert_eq!(BlockTableIndexType::from_wire(0x02), None);
463    }
464
465    #[test]
466    fn block_table_index_type_from_wire_rejects_0xff() {
467        assert_eq!(BlockTableIndexType::from_wire(0xFF), None);
468    }
469
470    #[test]
471    fn block_table_index_type_element_bytes_u32_is_4() {
472        assert_eq!(BlockTableIndexType::U32.element_bytes(), 4);
473    }
474
475    #[test]
476    fn block_table_index_type_element_bytes_u64_is_8() {
477        assert_eq!(BlockTableIndexType::U64.element_bytes(), 8);
478    }
479
480    // ── layer_index sentinel ─────────────────────────────────────────────────
481
482    /// Spec §block-paged.md §Additional Descriptor Fields:
483    /// wire value 0xFFFFFFFF encodes None; any other value encodes Some(n).
484    #[test]
485    fn layer_index_none_sentinel_value_is_0xffffffff() {
486        assert_eq!(LAYER_INDEX_NONE, 0xFFFF_FFFF);
487    }
488
489    #[test]
490    fn layer_index_some_zero_is_distinct_from_none() {
491        // Some(0) must differ from None (0xFFFFFFFF sentinel).
492        let layout_none =
493            BlockPagedLayout::new(16, 64, 0, 2, KvRole::Key, None, BlockTableIndexType::U32);
494        let layout_some_zero =
495            BlockPagedLayout::new(16, 64, 0, 2, KvRole::Key, Some(0), BlockTableIndexType::U32);
496        assert_ne!(layout_none, layout_some_zero);
497        assert_eq!(layout_none.layer_index, None);
498        assert_eq!(layout_some_zero.layer_index, Some(0));
499    }
500
501    #[test]
502    fn layer_index_some_large_value_stored_correctly() {
503        let n = 0xFFFF_FFFE_u32; // one below sentinel
504        let layout =
505            BlockPagedLayout::new(16, 64, 0, 2, KvRole::Key, Some(n), BlockTableIndexType::U32);
506        assert_eq!(layout.layer_index, Some(n));
507    }
508
509    // ── BlockPagedLayout tag and buffer count ─────────────────────────────────
510
511    /// Spec §block-paged.md: layout tag MUST be 0x0A.
512    #[test]
513    fn block_paged_layout_tag_is_0x0a() {
514        let layout =
515            BlockPagedLayout::new(16, 64, 0, 2, KvRole::Key, Some(0), BlockTableIndexType::U32);
516        let desc = LayoutDescriptor::BlockPaged(layout);
517        assert_eq!(desc.tag(), 0x0A);
518    }
519
520    /// Spec §block-paged.md §Buffer Table: buffer_count MUST be 3.
521    #[test]
522    fn block_paged_buffer_count_is_3() {
523        let layout =
524            BlockPagedLayout::new(16, 64, 0, 2, KvRole::Key, Some(0), BlockTableIndexType::U32);
525        let desc = LayoutDescriptor::BlockPaged(layout);
526        assert_eq!(desc.buffer_count(), NonZeroU8::new(3));
527    }
528
529    // ── Quantization compatibility ────────────────────────────────────────────
530
531    /// Spec §block-paged.md §Quantization Compatibility: per-tensor (0x01) is always valid.
532    #[test]
533    fn quant_compat_per_tensor_always_accepted() {
534        let layout =
535            BlockPagedLayout::new(16, 64, 0, 2, KvRole::Key, Some(0), BlockTableIndexType::U32);
536        // axis and block_size are irrelevant for per-tensor; any values pass.
537        assert!(layout
538            .validate_quantization_compatibility(0x01, 0, 0)
539            .is_ok());
540        assert!(layout
541            .validate_quantization_compatibility(0x01, 99, 999)
542            .is_ok());
543    }
544
545    /// Spec §block-paged.md §Quantization Compatibility: per-channel on axis 0 (paged axis) is forbidden.
546    #[test]
547    fn quant_compat_per_channel_on_axis_0_rejected() {
548        let layout =
549            BlockPagedLayout::new(16, 64, 0, 2, KvRole::Key, Some(0), BlockTableIndexType::U32);
550        let result = layout.validate_quantization_compatibility(0x02, 0, 0);
551        assert!(result.is_err(), "per-channel on axis 0 must be rejected");
552        assert!(matches!(result, Err(crate::Error::InvalidQuantization(_))));
553    }
554
555    /// Spec §block-paged.md §Quantization Compatibility: per-channel on axis 1 (num_heads) is valid.
556    #[test]
557    fn quant_compat_per_channel_on_axis_1_accepted() {
558        let layout =
559            BlockPagedLayout::new(16, 64, 0, 2, KvRole::Key, Some(0), BlockTableIndexType::U32);
560        assert!(layout
561            .validate_quantization_compatibility(0x02, 1, 0)
562            .is_ok());
563    }
564
565    /// Spec §block-paged.md §Quantization Compatibility: per-channel on axis 2 (head_dim) is valid.
566    #[test]
567    fn quant_compat_per_channel_on_axis_2_accepted() {
568        let layout =
569            BlockPagedLayout::new(16, 64, 0, 2, KvRole::Key, Some(0), BlockTableIndexType::U32);
570        assert!(layout
571            .validate_quantization_compatibility(0x02, 2, 0)
572            .is_ok());
573    }
574
575    /// Spec §block-paged.md §Quantization Compatibility: per-block-affine with axis=0 and
576    /// block_size == page_size is valid.
577    #[test]
578    fn quant_compat_per_block_affine_axis_0_block_size_eq_page_size_accepted() {
579        let layout =
580            BlockPagedLayout::new(16, 64, 0, 2, KvRole::Key, Some(0), BlockTableIndexType::U32);
581        assert!(layout
582            .validate_quantization_compatibility(0x03, 0, 16)
583            .is_ok());
584    }
585
586    /// Spec §block-paged.md §Quantization Compatibility: per-block-affine with axis != 0 is rejected.
587    #[test]
588    fn quant_compat_per_block_affine_axis_nonzero_rejected() {
589        let layout =
590            BlockPagedLayout::new(16, 64, 0, 2, KvRole::Key, Some(0), BlockTableIndexType::U32);
591        let result = layout.validate_quantization_compatibility(0x03, 1, 16);
592        assert!(
593            result.is_err(),
594            "per-block-affine with axis=1 must be rejected"
595        );
596        assert!(matches!(result, Err(crate::Error::InvalidQuantization(_))));
597    }
598
599    /// Spec §block-paged.md §Quantization Compatibility: per-block-affine with
600    /// block_size != page_size is rejected.
601    #[test]
602    fn quant_compat_per_block_affine_block_size_mismatch_rejected() {
603        let layout =
604            BlockPagedLayout::new(16, 64, 0, 2, KvRole::Key, Some(0), BlockTableIndexType::U32);
605        let result = layout.validate_quantization_compatibility(0x03, 0, 32);
606        assert!(
607            result.is_err(),
608            "per-block-affine with block_size=32 != page_size=16 must be rejected"
609        );
610        assert!(matches!(result, Err(crate::Error::InvalidQuantization(_))));
611    }
612
613    /// Unknown quantization scheme tag (>= 0x04) passes without error.
614    #[test]
615    fn quant_compat_unknown_scheme_tag_passes() {
616        let layout =
617            BlockPagedLayout::new(16, 64, 0, 2, KvRole::Key, Some(0), BlockTableIndexType::U32);
618        assert!(layout
619            .validate_quantization_compatibility(0x04, 0, 0)
620            .is_ok());
621        assert!(layout
622            .validate_quantization_compatibility(0xFF, 0, 0)
623            .is_ok());
624    }
625}