oxgraph_layout_util/lib.rs
1//! Shared layout primitives for `OxGraph` graph and hypergraph crates.
2//!
3//! Three responsibilities live here:
4//!
5//! - **Index/word vocabulary** ([`LayoutIndex`], [`LayoutWord`], [`LayoutSnapshotWord`],
6//! [`SnapshotWidth`]): the single sealed set of dense index widths, native/little-endian storage
7//! words, and the width-to-LE-word bijection shared by every layout and snapshot crate. CSR and
8//! BCSR add only thin section-kind-bearing sub-traits on top.
9//! - **Build-time** ([`id_to_slot`], [`slot_or_max`], [`index_from_usize`], [`build_offset_index`],
10//! [`slice_to_le`], [`map_offset_overflow`]): validate dense IDs against a known count, convert
11//! `usize` slots back into a typed index width, flatten per-bucket payloads into CSR-style
12//! `(offsets, items)` pairs, and lower native index slices into explicit little-endian words.
13//! - **Read-time** ([`OffsetIntegrityIssue`], [`check_offsets_monotonic`], [`check_value_range`],
14//! [`check_offset_section`], [`index_to_usize_validated`], [`usize_to_index_validated`]): walk
15//! borrowed offset arrays at view-open time and convert already-validated indexes infallibly.
16//!
17//! Helpers return small typed data enums ([`IdOutOfBounds`], [`OffsetOverflow`],
18//! [`OffsetIntegrityIssue`]) instead of crate-specific error types. Callers map
19//! the issue to their own typed error at the boundary.
20//!
21//! [`LocalId`] is the one generic local-handle newtype every layout crate
22//! aliases for its node/edge/vertex/hyperedge/incidence identities, and
23//! [`IdSlice`] is the one slice-to-handle iterator they all reuse.
24//!
25//! `no_std + alloc` (build-time primitives need `Vec`). No public domain
26//! semantics. No dependency on any other `oxgraph` crate.
27// kani-skip: helpers loop over arbitrary slice lengths and allocate
28// variable-sized buffers; proofs exercise the algebraic contract on bounded
29// fixtures.
30#![no_std]
31
32#[cfg(any(feature = "alloc", kani))]
33extern crate alloc;
34
35#[cfg(kani)]
36extern crate kani;
37
38#[cfg(any(feature = "alloc", kani))]
39use alloc::vec::Vec;
40use core::{error::Error, fmt, hash::Hash, iter::FusedIterator, marker::PhantomData};
41
42use zerocopy::{
43 FromBytes, Immutable, IntoBytes, KnownLayout,
44 byteorder::{LE, U16, U32, U64},
45};
46
47/// Sealed module preventing external types from satisfying the in-crate
48/// index/word traits.
49mod sealed {
50 /// Seals [`super::LayoutIndex`] to the supported unsigned index widths.
51 pub trait LayoutIndex {}
52
53 /// Seals [`super::ZerocopyWord`] to in-tree native and little-endian words.
54 pub trait ZerocopyWord {}
55
56 /// Seals [`super::LayoutSnapshotWord`] to little-endian storage words.
57 pub trait LayoutSnapshotWord {}
58
59 /// Seals [`super::SnapshotWidth`] to the persisted unsigned widths.
60 pub trait SnapshotWidth {}
61
62 /// Seals [`super::Axis`] to the in-tree local-handle axis markers.
63 pub trait Axis {}
64}
65
66// ---------------------------------------------------------------------------
67// Index / word vocabulary
68// ---------------------------------------------------------------------------
69
70/// Unsigned dense ID width usable by graph and hypergraph layouts and builders.
71///
72/// This is the single index-width contract for the whole substrate: it merges
73/// what `oxgraph-csr` and `oxgraph-hyper-bcsr` previously duplicated as
74/// `CsrIndex`/`BcsrIndex`. `usize` is included for native build-time indices;
75/// persisted widths additionally implement [`SnapshotWidth`].
76///
77/// # Performance
78///
79/// Implementations perform checked conversions in `O(1)`.
80pub trait LayoutIndex:
81 sealed::LayoutIndex + Copy + Eq + Ord + fmt::Debug + fmt::Display + Hash + Sized
82{
83 /// The additive identity for this width (the zero offset / first slot).
84 const ZERO: Self;
85
86 /// Converts this ID to `usize` when representable on the current target.
87 ///
88 /// # Performance
89 ///
90 /// This function is `O(1)`.
91 fn to_usize(self) -> Option<usize>;
92
93 /// Converts a `usize` into this ID width when representable.
94 ///
95 /// # Performance
96 ///
97 /// This function is `O(1)`.
98 fn from_usize(value: usize) -> Option<Self>;
99}
100
101/// Implements [`LayoutIndex`] for one unsigned width.
102macro_rules! impl_layout_index {
103 ($index:ty) => {
104 impl sealed::LayoutIndex for $index {}
105
106 impl LayoutIndex for $index {
107 const ZERO: Self = 0;
108
109 fn to_usize(self) -> Option<usize> {
110 usize::try_from(self).ok()
111 }
112
113 fn from_usize(value: usize) -> Option<Self> {
114 Self::try_from(value).ok()
115 }
116 }
117 };
118}
119
120impl_layout_index!(u16);
121impl_layout_index!(u32);
122impl_layout_index!(u64);
123
124impl sealed::LayoutIndex for usize {}
125
126impl LayoutIndex for usize {
127 const ZERO: Self = 0;
128
129 fn to_usize(self) -> Option<usize> {
130 Some(self)
131 }
132
133 fn from_usize(value: usize) -> Option<Self> {
134 Some(value)
135 }
136}
137
138/// Borrowed offset or value word usable by offset-integrity primitives.
139///
140/// Sealed: native unsigned integers and little-endian storage words opt in via
141/// the in-tree macros below. External crates cannot satisfy this trait.
142///
143/// # Performance
144///
145/// Reading a word is expected to be `O(1)`.
146pub trait ZerocopyWord: sealed::ZerocopyWord + Copy {
147 /// Reads this word's value as `usize`, or returns `None` when the value
148 /// does not fit in `usize` on the current target.
149 ///
150 /// # Performance
151 ///
152 /// This method is `O(1)`.
153 fn read_as_usize(self) -> Option<usize>;
154}
155
156/// Implements [`ZerocopyWord`] for one native unsigned integer type.
157macro_rules! impl_native_zerocopy_word {
158 ($word:ty) => {
159 impl sealed::ZerocopyWord for $word {}
160
161 impl ZerocopyWord for $word {
162 fn read_as_usize(self) -> Option<usize> {
163 usize::try_from(self).ok()
164 }
165 }
166 };
167}
168
169impl_native_zerocopy_word!(u16);
170impl_native_zerocopy_word!(u32);
171impl_native_zerocopy_word!(u64);
172impl_native_zerocopy_word!(usize);
173
174/// Implements [`ZerocopyWord`] for one little-endian zerocopy storage word.
175macro_rules! impl_le_zerocopy_word {
176 ($word:ty) => {
177 impl sealed::ZerocopyWord for $word {}
178
179 impl ZerocopyWord for $word {
180 fn read_as_usize(self) -> Option<usize> {
181 usize::try_from(<$word>::get(self)).ok()
182 }
183 }
184 };
185}
186
187impl_le_zerocopy_word!(U16<LE>);
188impl_le_zerocopy_word!(U32<LE>);
189impl_le_zerocopy_word!(U64<LE>);
190
191/// A native-host or little-endian word carrying a typed dense [`LayoutIndex`].
192///
193/// This merges what `oxgraph-csr` and `oxgraph-hyper-bcsr` previously
194/// duplicated as `CsrWord`/`BcsrWord`. The associated [`LayoutWord::Index`]
195/// recovers the logical index value from either a native word (identity) or a
196/// little-endian storage word (byte-order conversion), so a single [`IdSlice`]
197/// drives both build-path and view-path iteration.
198///
199/// # Performance
200///
201/// [`LayoutWord::get`] is expected to be `O(1)`.
202pub trait LayoutWord: Copy + ZerocopyWord {
203 /// Logical dense index recovered from this word.
204 type Index: LayoutIndex;
205
206 /// Reads the logical index value out of this word.
207 ///
208 /// # Performance
209 ///
210 /// This method is `O(1)`.
211 fn get(self) -> Self::Index;
212}
213
214/// Implements [`LayoutWord`] for one native unsigned integer (identity index).
215macro_rules! impl_native_layout_word {
216 ($word:ty) => {
217 impl LayoutWord for $word {
218 type Index = $word;
219
220 fn get(self) -> Self::Index {
221 self
222 }
223 }
224 };
225}
226
227impl_native_layout_word!(u16);
228impl_native_layout_word!(u32);
229impl_native_layout_word!(u64);
230impl_native_layout_word!(usize);
231
232/// Implements [`LayoutWord`] for one little-endian word over a native index.
233macro_rules! impl_le_layout_word {
234 ($word:ty, $index:ty) => {
235 impl LayoutWord for $word {
236 type Index = $index;
237
238 fn get(self) -> Self::Index {
239 <$word>::get(self)
240 }
241 }
242 };
243}
244
245impl_le_layout_word!(U16<LE>, u16);
246impl_le_layout_word!(U32<LE>, u32);
247impl_le_layout_word!(U64<LE>, u64);
248
249/// A little-endian storage word usable in persisted snapshot payloads.
250///
251/// Sealed marker over [`LayoutWord`] plus the zerocopy byte-view bounds. Only
252/// the explicit little-endian words implement it — native integers are
253/// excluded so persisted payloads always carry a defined byte order.
254///
255/// # Performance
256///
257/// `perf: unspecified`; this is a marker trait.
258pub trait LayoutSnapshotWord:
259 sealed::LayoutSnapshotWord + LayoutWord + FromBytes + Immutable + IntoBytes + KnownLayout
260{
261}
262
263/// Implements [`LayoutSnapshotWord`] for one little-endian storage word.
264macro_rules! impl_layout_snapshot_word {
265 ($word:ty) => {
266 impl sealed::LayoutSnapshotWord for $word {}
267
268 impl LayoutSnapshotWord for $word {}
269 };
270}
271
272impl_layout_snapshot_word!(U16<LE>);
273impl_layout_snapshot_word!(U32<LE>);
274impl_layout_snapshot_word!(U64<LE>);
275
276/// A persisted unsigned width with its little-endian storage word.
277///
278/// This is the single width-to-LE-word bijection shared by `oxgraph-csr`,
279/// `oxgraph-hyper-bcsr`, and any future layout crate. `usize` deliberately does
280/// not implement it: persisted snapshots are fixed-width.
281///
282/// # Performance
283///
284/// [`SnapshotWidth::to_le_word`] and [`SnapshotWidth::from_le_word`] are `O(1)`.
285pub trait SnapshotWidth: sealed::SnapshotWidth + LayoutIndex {
286 /// Little-endian storage word for this width.
287 type LittleEndianWord: LayoutSnapshotWord<Index = Self>;
288
289 /// Lowers this logical index into its little-endian storage word.
290 ///
291 /// # Performance
292 ///
293 /// This function is `O(1)`.
294 fn to_le_word(self) -> Self::LittleEndianWord;
295
296 /// Recovers this logical index from a little-endian storage word.
297 ///
298 /// # Performance
299 ///
300 /// This function is `O(1)`.
301 fn from_le_word(word: Self::LittleEndianWord) -> Self;
302}
303
304/// Implements [`SnapshotWidth`] for one width and its little-endian word.
305macro_rules! impl_snapshot_width {
306 ($index:ty, $word:ty) => {
307 impl sealed::SnapshotWidth for $index {}
308
309 impl SnapshotWidth for $index {
310 type LittleEndianWord = $word;
311
312 fn to_le_word(self) -> Self::LittleEndianWord {
313 <$word>::new(self)
314 }
315
316 fn from_le_word(word: Self::LittleEndianWord) -> Self {
317 <$word>::get(word)
318 }
319 }
320 };
321}
322
323impl_snapshot_width!(u16, U16<LE>);
324impl_snapshot_width!(u32, U32<LE>);
325impl_snapshot_width!(u64, U64<LE>);
326
327// ---------------------------------------------------------------------------
328// Local-handle newtype + slice iterator
329// ---------------------------------------------------------------------------
330
331/// Marker for a local-handle axis (node, edge, vertex, hyperedge, incidence).
332///
333/// Sealed: only the in-tree axis markers below implement it, so [`LocalId`]
334/// cannot be branded with an arbitrary external type.
335///
336/// # Performance
337///
338/// `perf: unspecified`; this is a marker trait.
339pub trait Axis: sealed::Axis {}
340
341/// Defines one zero-sized local-handle axis marker.
342macro_rules! define_axis {
343 ($(#[$doc:meta])* $name:ident) => {
344 $(#[$doc])*
345 #[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
346 pub struct $name;
347
348 impl sealed::Axis for $name {}
349
350 impl Axis for $name {}
351 };
352}
353
354define_axis!(
355 /// Element axis for binary graphs (a node).
356 NodeAxis
357);
358define_axis!(
359 /// Relation axis for binary graphs (an edge).
360 EdgeAxis
361);
362define_axis!(
363 /// Element axis for hypergraphs (a vertex).
364 VertexAxis
365);
366define_axis!(
367 /// Relation axis for hypergraphs (a hyperedge).
368 HyperedgeAxis
369);
370define_axis!(
371 /// Incidence axis (a participant / endpoint).
372 IncidenceAxis
373);
374
375/// A dense local handle: an axis-branded index value.
376///
377/// Every layout crate aliases this for its node/edge/vertex/hyperedge/incidence
378/// identities so a built graph and its borrowed snapshot view yield the same
379/// handle type. The `Axis` brand is a zero-sized `PhantomData<fn() -> A>` so the
380/// handle stays `Copy`, `Send`, and `Sync` regardless of the marker, and
381/// satisfies the `TopologyId` blanket bound when `Width` does.
382///
383/// # Performance
384///
385/// Copy, compare, order, hash, and debug-format are `O(1)`.
386pub struct LocalId<A, Width> {
387 /// The raw dense index value.
388 value: Width,
389 /// Zero-sized axis brand; `fn() -> A` keeps the handle `Send`/`Sync`.
390 axis: PhantomData<fn() -> A>,
391}
392
393impl<A, Width> LocalId<A, Width> {
394 /// Wraps a raw index value as an axis-branded handle.
395 ///
396 /// # Performance
397 ///
398 /// This function is `O(1)`.
399 #[inline]
400 #[must_use]
401 pub const fn new(value: Width) -> Self {
402 Self {
403 value,
404 axis: PhantomData,
405 }
406 }
407}
408
409impl<A, Width: Copy> LocalId<A, Width> {
410 /// Returns the raw index value of this handle.
411 ///
412 /// # Performance
413 ///
414 /// This function is `O(1)`.
415 #[inline]
416 #[must_use]
417 pub const fn get(self) -> Width {
418 self.value
419 }
420}
421
422impl<A, Width> From<Width> for LocalId<A, Width> {
423 #[inline]
424 fn from(value: Width) -> Self {
425 Self::new(value)
426 }
427}
428
429impl<A, Width: Clone> Clone for LocalId<A, Width> {
430 #[inline]
431 fn clone(&self) -> Self {
432 Self {
433 value: self.value.clone(),
434 axis: PhantomData,
435 }
436 }
437}
438
439impl<A, Width: Copy> Copy for LocalId<A, Width> {}
440
441impl<A, Width: PartialEq> PartialEq for LocalId<A, Width> {
442 #[inline]
443 fn eq(&self, other: &Self) -> bool {
444 self.value == other.value
445 }
446}
447
448impl<A, Width: Eq> Eq for LocalId<A, Width> {}
449
450impl<A, Width: PartialOrd> PartialOrd for LocalId<A, Width> {
451 #[inline]
452 fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
453 self.value.partial_cmp(&other.value)
454 }
455}
456
457impl<A, Width: Ord> Ord for LocalId<A, Width> {
458 #[inline]
459 fn cmp(&self, other: &Self) -> core::cmp::Ordering {
460 self.value.cmp(&other.value)
461 }
462}
463
464impl<A, Width: Hash> Hash for LocalId<A, Width> {
465 #[inline]
466 fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
467 self.value.hash(state);
468 }
469}
470
471impl<A, Width: fmt::Debug> fmt::Debug for LocalId<A, Width> {
472 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
473 formatter.debug_tuple("LocalId").field(&self.value).finish()
474 }
475}
476
477impl<A, Width: fmt::Display> fmt::Display for LocalId<A, Width> {
478 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
479 self.value.fmt(formatter)
480 }
481}
482
483/// Iterator that maps a borrowed slice of [`LayoutWord`]s into axis-branded
484/// handles, recovering each logical index and wrapping it with `Id::from`.
485///
486/// This is the single slice-to-handle iterator shared by every layout crate's
487/// build-path (native words) and view-path (little-endian words).
488///
489/// # Performance
490///
491/// Advancing is `O(1)`; the iterator borrows and never allocates.
492#[derive(Clone, Debug)]
493pub struct IdSlice<'view, W: LayoutWord, Id> {
494 /// Borrowed iterator over the backing word slice.
495 inner: core::slice::Iter<'view, W>,
496 /// Zero-sized brand for the produced handle type.
497 id: PhantomData<fn() -> Id>,
498}
499
500impl<'view, W: LayoutWord, Id> IdSlice<'view, W, Id> {
501 /// Creates an [`IdSlice`] over a borrowed word slice.
502 ///
503 /// # Performance
504 ///
505 /// This function is `O(1)`.
506 #[inline]
507 #[must_use]
508 pub fn new(slice: &'view [W]) -> Self {
509 Self {
510 inner: slice.iter(),
511 id: PhantomData,
512 }
513 }
514}
515
516impl<W: LayoutWord, Id> Iterator for IdSlice<'_, W, Id>
517where
518 Id: From<W::Index>,
519{
520 type Item = Id;
521
522 #[inline]
523 fn next(&mut self) -> Option<Self::Item> {
524 self.inner.next().map(|word| Id::from(word.get()))
525 }
526
527 #[inline]
528 fn size_hint(&self) -> (usize, Option<usize>) {
529 self.inner.size_hint()
530 }
531}
532
533impl<W: LayoutWord, Id> ExactSizeIterator for IdSlice<'_, W, Id>
534where
535 Id: From<W::Index>,
536{
537 #[inline]
538 fn len(&self) -> usize {
539 self.inner.len()
540 }
541}
542
543impl<W: LayoutWord, Id> DoubleEndedIterator for IdSlice<'_, W, Id>
544where
545 Id: From<W::Index>,
546{
547 #[inline]
548 fn next_back(&mut self) -> Option<Self::Item> {
549 self.inner.next_back().map(|word| Id::from(word.get()))
550 }
551}
552
553impl<W: LayoutWord, Id> FusedIterator for IdSlice<'_, W, Id> where Id: From<W::Index> {}
554
555// ---------------------------------------------------------------------------
556// Build-time primitives
557// ---------------------------------------------------------------------------
558
559/// Reasons an ID failed dense bounds validation.
560///
561/// # Performance
562///
563/// Formatting is `O(message length)`.
564#[derive(Clone, Copy, Debug, Eq, PartialEq)]
565#[non_exhaustive]
566pub enum IdOutOfBounds {
567 /// The ID's value did not fit in `usize` on the current target.
568 UsizeOverflow,
569 /// The ID's slot was greater than or equal to the dense count.
570 OutOfRange {
571 /// Slot derived from the ID.
572 slot: usize,
573 /// Exclusive upper bound for the slot.
574 count: usize,
575 },
576}
577
578impl fmt::Display for IdOutOfBounds {
579 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
580 match self {
581 Self::UsizeOverflow => formatter.write_str("ID value did not fit in usize"),
582 Self::OutOfRange { slot, count } => write!(
583 formatter,
584 "ID slot {slot} is not less than the dense bound {count}"
585 ),
586 }
587 }
588}
589
590impl Error for IdOutOfBounds {}
591
592/// Reasons a `usize` could not be represented in a target index width during
593/// builder offset construction.
594///
595/// # Performance
596///
597/// Formatting is `O(message length)`.
598#[derive(Clone, Copy, Debug, Eq, PartialEq)]
599#[non_exhaustive]
600pub enum OffsetOverflow {
601 /// A `usize` value did not fit in the target [`LayoutIndex`] width.
602 IndexOverflow {
603 /// Value that did not fit.
604 value: usize,
605 },
606}
607
608impl fmt::Display for OffsetOverflow {
609 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
610 match self {
611 Self::IndexOverflow { value } => {
612 write!(
613 formatter,
614 "value {value} does not fit in the target index width"
615 )
616 }
617 }
618 }
619}
620
621impl Error for OffsetOverflow {}
622
623/// Maps an [`OffsetOverflow`] into a caller-chosen error via `on_overflow`.
624///
625/// Replaces the per-crate `map_offset_overflow` copies: each builder crate
626/// passes a closure constructing its own typed `IdOverflow { value }` variant.
627///
628/// # Performance
629///
630/// This function is `O(1)`.
631#[inline]
632pub fn map_offset_overflow<E>(error: OffsetOverflow, on_overflow: impl FnOnce(usize) -> E) -> E {
633 match error {
634 OffsetOverflow::IndexOverflow { value } => on_overflow(value),
635 }
636}
637
638/// Validates that `id`'s `usize` representation is less than `count`.
639///
640/// # Errors
641///
642/// Returns [`IdOutOfBounds::UsizeOverflow`] when the ID does not fit in
643/// `usize` on the current target, and [`IdOutOfBounds::OutOfRange`] when the
644/// slot is not less than `count`.
645///
646/// # Performance
647///
648/// This function is `O(1)`.
649#[inline]
650pub fn id_to_slot<I: LayoutIndex>(id: I, count: usize) -> Result<usize, IdOutOfBounds> {
651 let slot = id.to_usize().ok_or(IdOutOfBounds::UsizeOverflow)?;
652 if slot < count {
653 Ok(slot)
654 } else {
655 Err(IdOutOfBounds::OutOfRange { slot, count })
656 }
657}
658
659/// Returns `id`'s `usize` representation, or `usize::MAX` when it does not
660/// fit in `usize` on the current target.
661///
662/// Used for fallback display and for slot lookups guarded elsewhere by
663/// [`id_to_slot`]. The `usize::MAX` sentinel is safe because callers compare
664/// against an upper bound less than `usize::MAX` before indexing.
665///
666/// # Performance
667///
668/// This function is `O(1)`.
669#[inline]
670#[must_use]
671pub fn slot_or_max<I: LayoutIndex>(id: I) -> usize {
672 id.to_usize().unwrap_or(usize::MAX)
673}
674
675/// Converts a `usize` value into the target index width.
676///
677/// # Errors
678///
679/// Returns [`OffsetOverflow::IndexOverflow`] when `value` does not fit.
680///
681/// # Performance
682///
683/// This function is `O(1)`.
684#[inline]
685pub fn index_from_usize<O: LayoutIndex>(value: usize) -> Result<O, OffsetOverflow> {
686 O::from_usize(value).ok_or(OffsetOverflow::IndexOverflow { value })
687}
688
689/// Converts an already-validated index into `usize`.
690///
691/// Returns `None` only when the value does not fit in `usize`. Call sites that
692/// have proven the value fits (because a prior validation pass enforced it)
693/// should `unwrap_or_else(|| unreachable!("validated …"))` with a proof comment;
694/// this helper deliberately does not embed that `unreachable!`, because the
695/// proof obligation belongs to the validated call site, not the shared helper.
696///
697/// # Performance
698///
699/// This function is `O(1)`.
700#[inline]
701#[must_use]
702pub fn index_to_usize_validated<I: LayoutIndex>(value: I) -> Option<usize> {
703 value.to_usize()
704}
705
706/// Converts an already-validated `usize` slot into the target index width.
707///
708/// Returns `None` only when the value does not fit. See
709/// [`index_to_usize_validated`] for the proof-obligation convention.
710///
711/// # Performance
712///
713/// This function is `O(1)`.
714#[inline]
715#[must_use]
716pub fn usize_to_index_validated<I: LayoutIndex>(value: usize) -> Option<I> {
717 I::from_usize(value)
718}
719
720/// Lowers a native index slice into explicit little-endian storage words.
721///
722/// Replaces the per-crate `*_slice_to_le` copies in the CSR and BCSR builders.
723///
724/// # Performance
725///
726/// This function is `O(values.len())`.
727#[cfg(any(feature = "alloc", kani))]
728#[must_use]
729pub fn slice_to_le<W: SnapshotWidth>(values: &[W]) -> Vec<W::LittleEndianWord> {
730 values.iter().copied().map(W::to_le_word).collect()
731}
732
733/// Flattens per-bucket payloads into a `(offsets, items)` pair.
734///
735/// The returned `offsets` vector has length `buckets.len() + 1`. `offsets[0]`
736/// is zero, `offsets[i + 1] - offsets[i]` equals the i-th bucket's length, and
737/// `offsets[buckets.len()]` equals `items.len()`. Items appear in input order
738/// within each bucket and buckets are concatenated in input order.
739///
740/// # Errors
741///
742/// Returns [`OffsetOverflow::IndexOverflow`] when any cumulative offset does
743/// not fit in the target index width.
744///
745/// # Performance
746///
747/// This function is `O(n)` where `n` is the total item count across all
748/// buckets. Allocation matches a single-pass extend-and-grow; no second pass
749/// is performed.
750#[cfg(any(feature = "alloc", kani))]
751pub fn build_offset_index<O, T>(buckets: Vec<Vec<T>>) -> Result<(Vec<O>, Vec<T>), OffsetOverflow>
752where
753 O: LayoutIndex,
754{
755 let mut offsets = Vec::with_capacity(buckets.len() + 1);
756 let mut items: Vec<T> = Vec::new();
757 offsets.push(index_from_usize::<O>(0)?);
758 for bucket in buckets {
759 items.extend(bucket);
760 offsets.push(index_from_usize::<O>(items.len())?);
761 }
762 Ok((offsets, items))
763}
764
765// ---------------------------------------------------------------------------
766// Read-time offset-integrity primitives
767// ---------------------------------------------------------------------------
768
769/// Reasons a borrowed offset or value array failed structural validation.
770///
771/// # Performance
772///
773/// Formatting is `O(message length)`.
774#[derive(Clone, Copy, Debug, Eq, PartialEq)]
775#[non_exhaustive]
776pub enum OffsetIntegrityIssue {
777 /// Length did not match `count + 1`.
778 Length {
779 /// Expected length (`count + 1`).
780 expected: usize,
781 /// Observed length.
782 actual: usize,
783 },
784 /// `expected_count + 1` overflowed `usize`, so no valid length exists.
785 CountOverflow {
786 /// The element count whose successor overflowed.
787 count: usize,
788 },
789 /// `offsets[0]` was not zero.
790 FirstNonZero {
791 /// Observed first offset.
792 actual: usize,
793 },
794 /// `offsets[index] < offsets[index - 1]`.
795 NonMonotonic {
796 /// Index where monotonicity failed.
797 index: usize,
798 /// Offset at `index - 1`.
799 previous: usize,
800 /// Offset at `index`.
801 actual: usize,
802 },
803 /// `offsets[count]` did not match `value_len`.
804 FinalMismatch {
805 /// Observed final offset.
806 final_offset: usize,
807 /// Length of the values array.
808 value_len: usize,
809 },
810 /// A value at `index` was not less than the dense `bound`.
811 ValueOutOfRange {
812 /// Index of the offending value.
813 index: usize,
814 /// Observed value.
815 value: usize,
816 /// Exclusive upper bound.
817 bound: usize,
818 },
819 /// A word's value at `index` did not fit in `usize` on the current target.
820 UsizeOverflow {
821 /// Slice position of the offending word.
822 index: usize,
823 },
824}
825
826impl fmt::Display for OffsetIntegrityIssue {
827 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
828 match self {
829 Self::Length { expected, actual } => write!(
830 formatter,
831 "offsets length {actual} does not match expected {expected}"
832 ),
833 Self::CountOverflow { count } => {
834 write!(formatter, "element count {count} + 1 overflows usize")
835 }
836 Self::FirstNonZero { actual } => {
837 write!(formatter, "first offset {actual} must be zero")
838 }
839 Self::NonMonotonic {
840 index,
841 previous,
842 actual,
843 } => write!(
844 formatter,
845 "offsets[{index}] = {actual} is less than offsets[{}] = {previous}",
846 index - 1
847 ),
848 Self::FinalMismatch {
849 final_offset,
850 value_len,
851 } => write!(
852 formatter,
853 "final offset {final_offset} does not match values length {value_len}"
854 ),
855 Self::ValueOutOfRange {
856 index,
857 value,
858 bound,
859 } => write!(
860 formatter,
861 "values[{index}] = {value} is not less than bound {bound}"
862 ),
863 Self::UsizeOverflow { index } => write!(
864 formatter,
865 "word at slice index {index} did not fit in usize"
866 ),
867 }
868 }
869}
870
871impl Error for OffsetIntegrityIssue {}
872
873/// Verifies `offsets[0] == 0` and that `offsets` is non-decreasing.
874///
875/// # Errors
876///
877/// Returns [`OffsetIntegrityIssue::FirstNonZero`] when the first offset is
878/// non-zero, [`OffsetIntegrityIssue::NonMonotonic`] when an offset decreases
879/// from the previous, and [`OffsetIntegrityIssue::UsizeOverflow`] when a
880/// word's value does not fit in `usize`.
881///
882/// # Performance
883///
884/// This function is `O(offsets.len())`.
885pub fn check_offsets_monotonic<W: ZerocopyWord>(offsets: &[W]) -> Result<(), OffsetIntegrityIssue> {
886 let mut previous: usize = 0;
887 for (index, word) in offsets.iter().copied().enumerate() {
888 let offset = word
889 .read_as_usize()
890 .ok_or(OffsetIntegrityIssue::UsizeOverflow { index })?;
891 if index == 0 {
892 if offset != 0 {
893 return Err(OffsetIntegrityIssue::FirstNonZero { actual: offset });
894 }
895 } else if offset < previous {
896 return Err(OffsetIntegrityIssue::NonMonotonic {
897 index,
898 previous,
899 actual: offset,
900 });
901 }
902 previous = offset;
903 }
904 Ok(())
905}
906
907/// Verifies every value in `values` is less than `bound`.
908///
909/// # Errors
910///
911/// Returns [`OffsetIntegrityIssue::ValueOutOfRange`] when a value is at or
912/// above `bound`, and [`OffsetIntegrityIssue::UsizeOverflow`] when a word's
913/// value does not fit in `usize`.
914///
915/// # Performance
916///
917/// This function is `O(values.len())`.
918pub fn check_value_range<W: ZerocopyWord>(
919 values: &[W],
920 bound: usize,
921) -> Result<(), OffsetIntegrityIssue> {
922 for (index, word) in values.iter().copied().enumerate() {
923 let value = word
924 .read_as_usize()
925 .ok_or(OffsetIntegrityIssue::UsizeOverflow { index })?;
926 if value >= bound {
927 return Err(OffsetIntegrityIssue::ValueOutOfRange {
928 index,
929 value,
930 bound,
931 });
932 }
933 }
934 Ok(())
935}
936
937/// Validates one offset section against `expected_count` rows and a backing
938/// values array of length `value_len`.
939///
940/// Performs four checks: length matches `expected_count + 1`, first offset is
941/// zero, offsets are non-decreasing, and the final offset matches `value_len`.
942/// Because offsets are monotonic and the final offset equals `value_len`, every
943/// interior offset is implied to be `<= value_len`; the borrowed-view slicing in
944/// CSR/BCSR relies on that consequence and the proofs assert it directly.
945///
946/// # Errors
947///
948/// Returns the first [`OffsetIntegrityIssue`] encountered. Reports
949/// [`OffsetIntegrityIssue::CountOverflow`] when `expected_count + 1` overflows.
950///
951/// # Performance
952///
953/// This function is `O(offsets.len())`.
954pub fn check_offset_section<W: ZerocopyWord>(
955 offsets: &[W],
956 expected_count: usize,
957 value_len: usize,
958) -> Result<(), OffsetIntegrityIssue> {
959 let Some(expected) = expected_count.checked_add(1) else {
960 return Err(OffsetIntegrityIssue::CountOverflow {
961 count: expected_count,
962 });
963 };
964 if offsets.len() != expected {
965 return Err(OffsetIntegrityIssue::Length {
966 expected,
967 actual: offsets.len(),
968 });
969 }
970 check_offsets_monotonic(offsets)?;
971 let final_index = offsets.len() - 1;
972 let final_offset = offsets[final_index]
973 .read_as_usize()
974 .ok_or(OffsetIntegrityIssue::UsizeOverflow { index: final_index })?;
975 if final_offset != value_len {
976 return Err(OffsetIntegrityIssue::FinalMismatch {
977 final_offset,
978 value_len,
979 });
980 }
981 Ok(())
982}
983
984#[cfg(kani)]
985mod proofs;