Skip to main content

hurray_core/layout/addressing/
block_paged.rs

1//! Block-paged layout element lookup.
2//!
3//! Implements the element-address formula from
4//! `docs/spec/layouts/block-paged.md § Element Lookup`:
5//!
6//! ```text
7//! page_in_seq    = t / page_size
8//! offset_in_page = t mod page_size
9//! phys_page      = block_table[seq_ptr[s] + page_in_seq]
10//! flat           = ((phys_page * page_size + offset_in_page) * num_heads + h) * head_dim + d
11//! ```
12//!
13//! The index buffers are passed by the caller as typed slices to keep this
14//! module free of byte-parsing — parsing belongs at the descriptor layer.
15
16use crate::Error;
17
18/// Computes the flat element offset into `page_pool` (buffer 0) for the
19/// element at token `t` of sequence `s`, head `h`, dimension `d`.
20///
21/// Returns `Err` if any index is out of bounds or if the `seq_ptr` / `block_table`
22/// invariants are violated. Never panics.
23///
24/// # Arguments
25///
26/// - `s` — sequence index (must be `< num_seqs`).
27/// - `t` — token index within the sequence (zero-based).
28/// - `h` — head index (must be `< num_heads`).
29/// - `d` — head-dimension index (must be `< head_dim`).
30/// - `page_size` — tokens per page (from the layout descriptor; `>= 1`).
31/// - `num_pages` — number of physical pages (from the layout descriptor).
32/// - `num_heads` — `shape[1]` of the tensor.
33/// - `head_dim` — `shape[2]` of the tensor.
34/// - `block_table` — slice of physical page IDs (buffer 1). Length must be >= `seq_ptr[num_seqs]`.
35/// - `seq_ptr` — offset array (buffer 2). Length must be `num_seqs + 1`.
36///
37/// # Errors
38///
39/// - [`Error::IndexOutOfRange`] — any index component is out of bounds.
40/// - [`Error::AddressOverflow`] — intermediate arithmetic overflowed `u64`.
41///
42/// # Examples
43///
44/// ```
45/// use hurray_core::layout::addressing::block_paged::element_offset_u32;
46///
47/// // From the spec example: page_size=4, num_pages=5, num_heads=2, head_dim=8.
48/// // Sequence 0 uses block_table[0..2] = [0, 1].
49/// // Token 4 is in page 1 (4/4=1), offset 0 (4%4=0).
50/// // phys_page = block_table[0 + 1] = 1.
51/// // flat = ((1*4 + 0)*2 + 0)*8 + 0 = (4*2)*8 = 64.
52/// let block_table: &[u32] = &[0, 1, 0]; // seq0: [0,1], seq1: [0]
53/// let seq_ptr: &[u32] = &[0, 2, 3];     // seq0 owns bt[0..2], seq1 owns bt[2..3]
54/// let flat = element_offset_u32(
55///     /*s=*/0, /*t=*/4, /*h=*/0, /*d=*/0,
56///     /*page_size=*/4, /*num_pages=*/5,
57///     /*num_heads=*/2, /*head_dim=*/8,
58///     block_table, seq_ptr,
59/// ).unwrap();
60/// assert_eq!(flat, 64);
61/// ```
62#[allow(clippy::too_many_arguments)]
63pub fn element_offset_u32(
64    s: u64,
65    t: u64,
66    h: u64,
67    d: u64,
68    page_size: u32,
69    num_pages: u64,
70    num_heads: u64,
71    head_dim: u64,
72    block_table: &[u32],
73    seq_ptr: &[u32],
74) -> crate::Result<u64> {
75    element_offset_impl(
76        s,
77        t,
78        h,
79        d,
80        page_size,
81        num_pages,
82        num_heads,
83        head_dim,
84        seq_ptr,
85        block_table,
86    )
87}
88
89/// Computes the flat element offset into `page_pool` (buffer 0) for the
90/// element at token `t` of sequence `s`, head `h`, dimension `d`,
91/// using 64-bit index buffers.
92///
93/// See [`element_offset_u32`] for the full documentation. The only difference
94/// is that `block_table` and `seq_ptr` carry `u64` entries rather than `u32`,
95/// enabling pools larger than 4 billion pages.
96///
97/// # Examples
98///
99/// ```
100/// use hurray_core::layout::addressing::block_paged::element_offset_u64;
101///
102/// let block_table: &[u64] = &[0u64, 1, 0];
103/// let seq_ptr: &[u64] = &[0u64, 2, 3];
104/// let flat = element_offset_u64(
105///     0, 4, 0, 0, 4, 5, 2, 8, block_table, seq_ptr,
106/// ).unwrap();
107/// assert_eq!(flat, 64);
108/// ```
109#[allow(clippy::too_many_arguments)]
110pub fn element_offset_u64(
111    s: u64,
112    t: u64,
113    h: u64,
114    d: u64,
115    page_size: u32,
116    num_pages: u64,
117    num_heads: u64,
118    head_dim: u64,
119    block_table: &[u64],
120    seq_ptr: &[u64],
121) -> crate::Result<u64> {
122    element_offset_impl(
123        s,
124        t,
125        h,
126        d,
127        page_size,
128        num_pages,
129        num_heads,
130        head_dim,
131        seq_ptr,
132        block_table,
133    )
134}
135
136/// Shared inner implementation, generic over the index width.
137///
138/// `seq_ptr` has `num_seqs + 1` entries. `block_table` has `seq_ptr[num_seqs]` entries.
139/// Generic over `T: Copy + Into<u64>` so the `u32` and `u64` entry points share this
140/// logic without widening the index buffers into a temporary allocation per call —
141/// this is the KV-cache lookup hot path, so per-call heap traffic is avoided.
142#[allow(clippy::too_many_arguments)]
143fn element_offset_impl<T: Copy + Into<u64>>(
144    s: u64,
145    t: u64,
146    h: u64,
147    d: u64,
148    page_size: u32,
149    num_pages: u64,
150    num_heads: u64,
151    head_dim: u64,
152    seq_ptr: &[T],
153    block_table: &[T],
154) -> crate::Result<u64> {
155    // seq_ptr must have at least 1 element (even for an empty batch: [0]).
156    // The last element is seq_ptr[num_seqs].
157    let num_seqs = seq_ptr.len().saturating_sub(1) as u64;
158
159    // Validate sequence index.
160    if s >= num_seqs {
161        return Err(Error::IndexOutOfRange {
162            dim: 0,
163            index: s,
164            size: num_seqs,
165        });
166    }
167
168    // Validate head index.
169    if h >= num_heads {
170        return Err(Error::IndexOutOfRange {
171            dim: 1,
172            index: h,
173            size: num_heads,
174        });
175    }
176
177    // Validate dimension index.
178    if d >= head_dim {
179        return Err(Error::IndexOutOfRange {
180            dim: 2,
181            index: d,
182            size: head_dim,
183        });
184    }
185
186    let page_size_u64 = page_size as u64;
187
188    // Locate the block-table slice for sequence s.
189    // seq_ptr should be non-decreasing (storage invariant), but this function does
190    // not assume the caller validated it — checked_sub guards against a malformed
191    // (non-monotone) seq_ptr instead of panicking, honouring the "never panics" contract.
192    let bt_start: u64 = seq_ptr[s as usize].into();
193    let bt_end: u64 = seq_ptr[s as usize + 1].into();
194
195    // The sequence's logical page count for this sequence.
196    let seq_page_count = bt_end.checked_sub(bt_start).ok_or_else(|| {
197        Error::InvalidLayout(
198            "block-paged: seq_ptr is not non-decreasing (storage invariant violated)".to_string(),
199        )
200    })?;
201
202    // Token → page index within the sequence.
203    let page_in_seq = t / page_size_u64;
204    let offset_in_page = t % page_size_u64;
205
206    // Validate token: must be within the sequence's allocated page slots.
207    // (The spec allows trailing undefined slots in the last page; we
208    //  allow t up to seq_page_count * page_size - 1 here, matching what a
209    //  reader is permitted to access. The caller is responsible for not
210    //  reading past the sequence's valid token count.)
211    if page_in_seq >= seq_page_count {
212        return Err(Error::IndexOutOfRange {
213            dim: 0,
214            index: t,
215            // saturating_mul: this is only the reported upper bound in an error path;
216            // a saturated value still conveys "out of range" without risking overflow.
217            size: seq_page_count.saturating_mul(page_size_u64),
218        });
219    }
220
221    // Resolve physical page via block table.
222    let bt_idx = bt_start
223        .checked_add(page_in_seq)
224        .ok_or(Error::AddressOverflow)?;
225
226    let bt_idx_usize = bt_idx as usize;
227    if bt_idx_usize >= block_table.len() {
228        // Storage invariant violation: block_table is too short.
229        return Err(Error::IndexOutOfRange {
230            dim: 0,
231            index: bt_idx,
232            size: block_table.len() as u64,
233        });
234    }
235
236    let phys_page: u64 = block_table[bt_idx_usize].into();
237
238    // Storage invariant: all physical page IDs must be < num_pages.
239    if phys_page >= num_pages {
240        return Err(Error::IndexOutOfRange {
241            dim: 0,
242            index: phys_page,
243            size: num_pages,
244        });
245    }
246
247    // Compute flat offset per spec:
248    //   flat = ((phys_page * page_size + offset_in_page) * num_heads + h) * head_dim + d
249    let flat = phys_page
250        .checked_mul(page_size_u64)
251        .and_then(|v| v.checked_add(offset_in_page))
252        .and_then(|v| v.checked_mul(num_heads))
253        .and_then(|v| v.checked_add(h))
254        .and_then(|v| v.checked_mul(head_dim))
255        .and_then(|v| v.checked_add(d))
256        .ok_or(Error::AddressOverflow)?;
257
258    Ok(flat)
259}
260
261/// Validates the storage invariants for a block-paged tensor's index buffers.
262///
263/// Per `docs/spec/layouts/block-paged.md § Storage Invariants`:
264///
265/// 1. `seq_ptr[0] == 0`.
266/// 2. `seq_ptr` is non-decreasing.
267/// 3. `seq_ptr[num_seqs] == total_logical_pages` (equals `block_table.len()`).
268/// 4. Every `block_table[k] < num_pages`.
269///
270/// Both U32 and U64 variants are supported through the typed overloads below.
271/// This function operates on already-widened slices.
272///
273/// # Errors
274///
275/// Returns [`Error::InvalidLayout`] if any invariant is violated.
276///
277/// # Examples
278///
279/// ```
280/// use hurray_core::layout::addressing::block_paged::validate_index_buffers_u64;
281///
282/// let seq_ptr: &[u64] = &[0, 2, 3];
283/// let block_table: &[u64] = &[0, 1, 0];
284/// assert!(validate_index_buffers_u64(5, 2, seq_ptr, block_table).is_ok());
285/// ```
286pub fn validate_index_buffers_u64(
287    num_pages: u64,
288    num_seqs: u32,
289    seq_ptr: &[u64],
290    block_table: &[u64],
291) -> crate::Result<()> {
292    validate_index_buffers_impl(num_pages, num_seqs, seq_ptr, block_table)
293}
294
295/// Validates the storage invariants for block-paged index buffers using `u32` entries.
296///
297/// Widening wrapper over [`validate_index_buffers_u64`]. See that function for
298/// the full invariant list.
299///
300/// # Errors
301///
302/// Returns [`Error::InvalidLayout`] if any invariant is violated.
303///
304/// # Examples
305///
306/// ```
307/// use hurray_core::layout::addressing::block_paged::validate_index_buffers_u32;
308///
309/// let seq_ptr: &[u32] = &[0, 2, 3];
310/// let block_table: &[u32] = &[0, 1, 0];
311/// assert!(validate_index_buffers_u32(5, 2, seq_ptr, block_table).is_ok());
312/// ```
313pub fn validate_index_buffers_u32(
314    num_pages: u64,
315    num_seqs: u32,
316    seq_ptr: &[u32],
317    block_table: &[u32],
318) -> crate::Result<()> {
319    // Widen before delegating — the inner impl uses u64 throughout.
320    let sp: Vec<u64> = seq_ptr.iter().map(|&v| v as u64).collect();
321    let bt: Vec<u64> = block_table.iter().map(|&v| v as u64).collect();
322    validate_index_buffers_impl(num_pages, num_seqs, &sp, &bt)
323}
324
325fn validate_index_buffers_impl(
326    num_pages: u64,
327    num_seqs: u32,
328    seq_ptr: &[u64],
329    block_table: &[u64],
330) -> crate::Result<()> {
331    let expected_sp_len = (num_seqs as usize).saturating_add(1);
332    if seq_ptr.len() != expected_sp_len {
333        return Err(Error::InvalidLayout(format!(
334            "block-paged: seq_ptr length {} does not equal num_seqs + 1 = {}",
335            seq_ptr.len(),
336            expected_sp_len
337        )));
338    }
339
340    // Invariant 1: seq_ptr[0] == 0.
341    if seq_ptr[0] != 0 {
342        return Err(Error::InvalidLayout(format!(
343            "block-paged: seq_ptr[0] must be 0, got {}",
344            seq_ptr[0]
345        )));
346    }
347
348    // Invariant 2: seq_ptr is non-decreasing.
349    for i in 0..(seq_ptr.len() - 1) {
350        if seq_ptr[i] > seq_ptr[i + 1] {
351            return Err(Error::InvalidLayout(format!(
352                "block-paged: seq_ptr is not non-decreasing at index {}: seq_ptr[{}]={} > seq_ptr[{}]={}",
353                i,
354                i,
355                seq_ptr[i],
356                i + 1,
357                seq_ptr[i + 1]
358            )));
359        }
360    }
361
362    // Invariant 3: seq_ptr[num_seqs] == total_logical_pages == block_table.len().
363    let total_logical_pages = seq_ptr[num_seqs as usize];
364    if total_logical_pages != block_table.len() as u64 {
365        return Err(Error::InvalidLayout(format!(
366            "block-paged: seq_ptr[num_seqs]={} does not equal block_table.len()={}",
367            total_logical_pages,
368            block_table.len()
369        )));
370    }
371
372    // Invariant 4: every block_table[k] < num_pages.
373    for (k, &phys_page) in block_table.iter().enumerate() {
374        if phys_page >= num_pages {
375            return Err(Error::InvalidLayout(format!(
376                "block-paged: block_table[{k}]={phys_page} >= num_pages={num_pages}"
377            )));
378        }
379    }
380
381    Ok(())
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use crate::Error;
388
389    // ── Helpers ──────────────────────────────────────────────────────────────
390
391    /// The spec worked example (§ Example):
392    ///   layer 3, key role, 2 seqs, page_size=4, num_pages=5, num_heads=2, head_dim=8.
393    ///   seq_ptr     = [0, 2, 3]
394    ///   block_table = [0, 1, 0]   ← seq 1 aliases page 0 (prefix sharing)
395    ///
396    /// Flat-address formula (spec §Element Lookup):
397    ///   flat = ((phys_page * page_size + offset_in_page) * num_heads + h) * head_dim + d
398    fn spec_example_u32() -> (&'static [u32], &'static [u32]) {
399        let block_table: &[u32] = &[0, 1, 0];
400        let seq_ptr: &[u32] = &[0, 2, 3];
401        (block_table, seq_ptr)
402    }
403
404    fn spec_example_u64() -> (&'static [u64], &'static [u64]) {
405        let block_table: &[u64] = &[0, 1, 0];
406        let seq_ptr: &[u64] = &[0, 2, 3];
407        (block_table, seq_ptr)
408    }
409
410    // ── element_offset_u32: spec worked example ───────────────────────────────
411
412    /// Spec §Example: token 0 of seq 0, head 0, dim 0.
413    /// page_in_seq=0, offset=0, phys_page=block_table[0]=0.
414    /// flat = ((0*4 + 0)*2 + 0)*8 + 0 = 0.
415    #[test]
416    fn element_offset_u32_spec_example_seq0_t0_h0_d0() {
417        let (bt, sp) = spec_example_u32();
418        let flat = element_offset_u32(0, 0, 0, 0, 4, 5, 2, 8, bt, sp).unwrap();
419        assert_eq!(flat, 0);
420    }
421
422    /// Spec §Example: token 4 of seq 0, head 0, dim 0.
423    /// page_in_seq=1, offset=0, phys_page=block_table[1]=1.
424    /// flat = ((1*4 + 0)*2 + 0)*8 + 0 = 64.
425    #[test]
426    fn element_offset_u32_spec_example_seq0_t4_h0_d0() {
427        let (bt, sp) = spec_example_u32();
428        let flat = element_offset_u32(0, 4, 0, 0, 4, 5, 2, 8, bt, sp).unwrap();
429        assert_eq!(flat, 64);
430    }
431
432    /// Spec §Example: token 5 of seq 0, head 1, dim 7 (last valid element).
433    /// page_in_seq=1, offset=1, phys_page=1.
434    /// flat = ((1*4 + 1)*2 + 1)*8 + 7 = ((5)*2 + 1)*8 + 7 = 11*8 + 7 = 95.
435    #[test]
436    fn element_offset_u32_spec_example_seq0_t5_h1_d7() {
437        let (bt, sp) = spec_example_u32();
438        let flat = element_offset_u32(0, 5, 1, 7, 4, 5, 2, 8, bt, sp).unwrap();
439        assert_eq!(flat, 95);
440    }
441
442    /// Spec §Prefix Sharing: seq 1 token 0 uses the same physical page as seq 0 token 0.
443    /// seq 1: block_table[sp[1]..sp[2]] = block_table[2..3] = [0].
444    /// page_in_seq=0, offset=0, phys_page=block_table[2]=0 (same as seq 0 page 0).
445    /// flat = ((0*4 + 0)*2 + 0)*8 + 0 = 0.
446    #[test]
447    fn element_offset_u32_prefix_sharing_seq1_same_physical_page_as_seq0() {
448        let (bt, sp) = spec_example_u32();
449        let flat_seq0 = element_offset_u32(0, 0, 0, 0, 4, 5, 2, 8, bt, sp).unwrap();
450        let flat_seq1 = element_offset_u32(1, 0, 0, 0, 4, 5, 2, 8, bt, sp).unwrap();
451        // Both sequences' token 0 resolve to the same physical page (page 0), same offset.
452        assert_eq!(
453            flat_seq0, flat_seq1,
454            "aliased pages must resolve to identical flat offsets"
455        );
456        assert_eq!(flat_seq1, 0);
457    }
458
459    /// Seq 1 has only 1 logical page (sp[1]=2, sp[2]=3). Token 4 would need a second page.
460    #[test]
461    fn element_offset_u32_token_beyond_sequence_page_count_is_error() {
462        let (bt, sp) = spec_example_u32();
463        let result = element_offset_u32(1, 4, 0, 0, 4, 5, 2, 8, bt, sp);
464        assert!(
465            matches!(result, Err(Error::IndexOutOfRange { .. })),
466            "token 4 in seq 1 (only 1 page) must be IndexOutOfRange"
467        );
468    }
469
470    /// Head index >= num_heads must be rejected.
471    #[test]
472    fn element_offset_u32_head_out_of_range_is_error() {
473        let (bt, sp) = spec_example_u32();
474        let result = element_offset_u32(0, 0, 2, 0, 4, 5, 2, 8, bt, sp);
475        assert!(matches!(result, Err(Error::IndexOutOfRange { dim: 1, .. })));
476    }
477
478    /// Dimension index >= head_dim must be rejected.
479    #[test]
480    fn element_offset_u32_dim_out_of_range_is_error() {
481        let (bt, sp) = spec_example_u32();
482        let result = element_offset_u32(0, 0, 0, 8, 4, 5, 2, 8, bt, sp);
483        assert!(matches!(result, Err(Error::IndexOutOfRange { dim: 2, .. })));
484    }
485
486    /// Sequence index >= num_seqs must be rejected.
487    #[test]
488    fn element_offset_u32_seq_out_of_range_is_error() {
489        let (bt, sp) = spec_example_u32();
490        let result = element_offset_u32(2, 0, 0, 0, 4, 5, 2, 8, bt, sp);
491        assert!(matches!(result, Err(Error::IndexOutOfRange { dim: 0, .. })));
492    }
493
494    /// A non-monotone `seq_ptr` (storage-invariant violation) must return an error,
495    /// not panic — `element_offset` does not assume the caller pre-validated the buffers.
496    #[test]
497    fn element_offset_u32_non_monotone_seq_ptr_is_error_not_panic() {
498        let block_table: &[u32] = &[0];
499        let seq_ptr: &[u32] = &[0, 2, 1]; // seq_ptr[2] < seq_ptr[1]
500        let result = element_offset_u32(1, 0, 0, 0, 4, 5, 2, 8, block_table, seq_ptr);
501        assert!(matches!(
502            result,
503            Err(Error::InvalidLayout(_) | Error::IndexOutOfRange { .. })
504        ));
505    }
506
507    // ── element_offset_u64 ────────────────────────────────────────────────────
508
509    /// Confirm element_offset_u64 produces the same result as u32 on the spec example.
510    #[test]
511    fn element_offset_u64_matches_u32_on_spec_example() {
512        let (bt, sp) = spec_example_u64();
513        let flat = element_offset_u64(0, 4, 0, 0, 4, 5, 2, 8, bt, sp).unwrap();
514        assert_eq!(flat, 64);
515    }
516
517    /// Prefix sharing is observable via u64 variant too.
518    #[test]
519    fn element_offset_u64_prefix_sharing_aliased_offsets_are_equal() {
520        let (bt, sp) = spec_example_u64();
521        let flat_seq0 = element_offset_u64(0, 0, 0, 0, 4, 5, 2, 8, bt, sp).unwrap();
522        let flat_seq1 = element_offset_u64(1, 0, 0, 0, 4, 5, 2, 8, bt, sp).unwrap();
523        assert_eq!(flat_seq0, flat_seq1);
524    }
525
526    // ── element_offset: last valid slot in the page pool ─────────────────────
527
528    /// Absolute last addressable element: page 4 (last page), slot 3, head 1, dim 7.
529    /// flat = ((4*4 + 3)*2 + 1)*8 + 7 = (19*2 + 1)*8 + 7 = 39*8 + 7 = 319.
530    /// page_pool has 5*4*2*8 = 320 elements; index 319 is the last.
531    #[test]
532    fn element_offset_u32_last_element_in_pool() {
533        // Use a seq that owns physical page 4 via its block table entry.
534        let block_table: &[u32] = &[4]; // seq 0 has 1 logical page → physical page 4
535        let seq_ptr: &[u32] = &[0, 1];
536        let flat = element_offset_u32(0, 3, 1, 7, 4, 5, 2, 8, block_table, seq_ptr).unwrap();
537        assert_eq!(flat, 319);
538    }
539
540    // ── validate_index_buffers_u32: valid inputs ──────────────────────────────
541
542    /// Spec §Storage Invariants (1)–(4): the spec example is valid.
543    #[test]
544    fn validate_index_buffers_u32_spec_example_is_valid() {
545        let seq_ptr: &[u32] = &[0, 2, 3];
546        let block_table: &[u32] = &[0, 1, 0];
547        assert!(
548            validate_index_buffers_u32(5, 2, seq_ptr, block_table).is_ok(),
549            "spec example index buffers must be valid"
550        );
551    }
552
553    /// Empty batch: num_seqs=0, seq_ptr=[0], block_table=[].
554    #[test]
555    fn validate_index_buffers_u32_empty_batch_is_valid() {
556        let seq_ptr: &[u32] = &[0];
557        let block_table: &[u32] = &[];
558        assert!(
559            validate_index_buffers_u32(5, 0, seq_ptr, block_table).is_ok(),
560            "empty batch must be valid"
561        );
562    }
563
564    /// Single-sequence batch with multiple pages.
565    #[test]
566    fn validate_index_buffers_u32_single_seq_multiple_pages_is_valid() {
567        let seq_ptr: &[u32] = &[0, 3];
568        let block_table: &[u32] = &[2, 0, 4];
569        assert!(validate_index_buffers_u32(5, 1, seq_ptr, block_table).is_ok());
570    }
571
572    // ── validate_index_buffers_u32: invariant 1 — seq_ptr[0] != 0 ────────────
573
574    /// Spec §Storage Invariant 1: seq_ptr[0] MUST be 0.
575    #[test]
576    fn validate_index_buffers_u32_rejects_seq_ptr_not_starting_at_zero() {
577        let seq_ptr: &[u32] = &[1, 3]; // seq_ptr[0] = 1, not 0
578        let block_table: &[u32] = &[0, 1, 2];
579        let result = validate_index_buffers_u32(5, 1, seq_ptr, block_table);
580        assert!(
581            matches!(result, Err(Error::InvalidLayout(_))),
582            "seq_ptr[0] != 0 must be InvalidLayout"
583        );
584    }
585
586    // ── validate_index_buffers_u32: invariant 2 — non-monotonic seq_ptr ──────
587
588    /// Spec §Storage Invariant 2: seq_ptr must be non-decreasing.
589    #[test]
590    fn validate_index_buffers_u32_rejects_non_monotonic_seq_ptr() {
591        let seq_ptr: &[u32] = &[0, 3, 2]; // seq_ptr[1] > seq_ptr[2]
592        let block_table: &[u32] = &[0, 1, 0];
593        let result = validate_index_buffers_u32(5, 2, seq_ptr, block_table);
594        assert!(
595            matches!(result, Err(Error::InvalidLayout(_))),
596            "non-monotonic seq_ptr must be InvalidLayout"
597        );
598    }
599
600    // ── validate_index_buffers_u32: invariant 3 — seq_ptr[num_seqs] mismatch ─
601
602    /// Spec §Storage Invariant 3: seq_ptr[num_seqs] must equal block_table.len().
603    #[test]
604    fn validate_index_buffers_u32_rejects_seq_ptr_total_mismatch_block_table_len() {
605        // seq_ptr says 4 total pages but block_table has 3 entries.
606        let seq_ptr: &[u32] = &[0, 2, 4];
607        let block_table: &[u32] = &[0, 1, 2]; // only 3 entries, not 4
608        let result = validate_index_buffers_u32(5, 2, seq_ptr, block_table);
609        assert!(
610            matches!(result, Err(Error::InvalidLayout(_))),
611            "seq_ptr[num_seqs] != block_table.len() must be InvalidLayout"
612        );
613    }
614
615    // ── validate_index_buffers_u32: invariant 4 — out-of-bounds page id ──────
616
617    /// Spec §Storage Invariant 4: every block_table[k] < num_pages.
618    #[test]
619    fn validate_index_buffers_u32_rejects_out_of_bounds_page_id() {
620        let seq_ptr: &[u32] = &[0, 2, 3];
621        let block_table: &[u32] = &[0, 5, 0]; // page 5 >= num_pages=5
622        let result = validate_index_buffers_u32(5, 2, seq_ptr, block_table);
623        assert!(
624            matches!(result, Err(Error::InvalidLayout(_))),
625            "block_table[k] >= num_pages must be InvalidLayout"
626        );
627    }
628
629    /// Page id equal to num_pages (exactly at boundary) must be rejected.
630    #[test]
631    fn validate_index_buffers_u32_rejects_page_id_equal_to_num_pages() {
632        let seq_ptr: &[u32] = &[0, 1];
633        let block_table: &[u32] = &[5]; // == num_pages (0-indexed: valid range is 0..4)
634        let result = validate_index_buffers_u32(5, 1, seq_ptr, block_table);
635        assert!(matches!(result, Err(Error::InvalidLayout(_))));
636    }
637
638    // ── validate_index_buffers_u64: basic coverage ────────────────────────────
639
640    #[test]
641    fn validate_index_buffers_u64_spec_example_is_valid() {
642        let seq_ptr: &[u64] = &[0, 2, 3];
643        let block_table: &[u64] = &[0, 1, 0];
644        assert!(validate_index_buffers_u64(5, 2, seq_ptr, block_table).is_ok());
645    }
646
647    #[test]
648    fn validate_index_buffers_u64_empty_batch_is_valid() {
649        let seq_ptr: &[u64] = &[0];
650        let block_table: &[u64] = &[];
651        assert!(validate_index_buffers_u64(5, 0, seq_ptr, block_table).is_ok());
652    }
653
654    #[test]
655    fn validate_index_buffers_u64_rejects_out_of_bounds_page_id() {
656        let seq_ptr: &[u64] = &[0, 1];
657        let block_table: &[u64] = &[5]; // == num_pages
658        let result = validate_index_buffers_u64(5, 1, seq_ptr, block_table);
659        assert!(matches!(result, Err(Error::InvalidLayout(_))));
660    }
661}