Skip to main content

oxgraph_hyper_bcsr/internal/
validation.rs

1//! Layout- and Strict-tier validation for bipartite-CSR section payloads.
2//!
3//! Validation walks the eight section payloads at open time and rejects any
4//! shape that would let a later traversal step out of bounds, observe stale
5//! data, or yield an inconsistent view of the bipartite incidence relation.
6//! The depth of the walk is selected by [`BcsrValidation`].
7
8use oxgraph_layout_util::{OffsetIntegrityIssue, check_offset_section, check_value_range};
9
10use crate::{
11    error::{BcsrError, BcsrRoleSide, BcsrSection},
12    internal::view::BcsrSections,
13    word::{LayoutIndex, LayoutWord},
14};
15
16/// Maps an [`OffsetIntegrityIssue`] from `oxgraph-csr-util` into a typed
17/// [`BcsrError`], stamping the originating [`BcsrSection`] discriminator.
18fn map_offsets_issue<W: LayoutWord>(
19    section: BcsrSection,
20    offsets: &[W],
21    issue: OffsetIntegrityIssue,
22) -> BcsrError {
23    match issue {
24        OffsetIntegrityIssue::Length { expected, actual } => BcsrError::OffsetLength {
25            section,
26            expected,
27            actual,
28        },
29        OffsetIntegrityIssue::FirstNonZero { actual } => BcsrError::FirstOffset { section, actual },
30        OffsetIntegrityIssue::NonMonotonic {
31            index,
32            previous,
33            actual,
34        } => BcsrError::NonMonotonicOffset {
35            section,
36            index,
37            previous,
38            actual,
39        },
40        OffsetIntegrityIssue::FinalMismatch {
41            final_offset,
42            value_len,
43        } => BcsrError::FinalOffset {
44            section,
45            final_offset,
46            value_len,
47        },
48        OffsetIntegrityIssue::UsizeOverflow { index } => {
49            let value = offsets
50                .get(index)
51                .copied()
52                .and_then(|w| w.get().to_usize())
53                .unwrap_or(usize::MAX);
54            BcsrError::UsizeOverflow { value }
55        }
56        _ => BcsrError::UsizeOverflow { value: usize::MAX },
57    }
58}
59
60/// Maps a value-range [`OffsetIntegrityIssue`] for vertex IDs into a
61/// [`BcsrError::VertexOutOfRange`].
62fn map_vertex_value_issue<W: LayoutWord>(
63    section: BcsrSection,
64    values: &[W],
65    issue: OffsetIntegrityIssue,
66) -> BcsrError {
67    match issue {
68        OffsetIntegrityIssue::ValueOutOfRange {
69            index,
70            value,
71            bound,
72        } => BcsrError::VertexOutOfRange {
73            section,
74            index,
75            vertex: value,
76            vertex_count: bound,
77        },
78        OffsetIntegrityIssue::UsizeOverflow { index } => {
79            let value = values
80                .get(index)
81                .copied()
82                .and_then(|w| w.get().to_usize())
83                .unwrap_or(usize::MAX);
84            BcsrError::UsizeOverflow { value }
85        }
86        _ => BcsrError::UsizeOverflow { value: usize::MAX },
87    }
88}
89
90/// Maps a value-range [`OffsetIntegrityIssue`] for hyperedge IDs into a
91/// [`BcsrError::HyperedgeOutOfRange`].
92fn map_hyperedge_value_issue<W: LayoutWord>(
93    section: BcsrSection,
94    values: &[W],
95    issue: OffsetIntegrityIssue,
96) -> BcsrError {
97    match issue {
98        OffsetIntegrityIssue::ValueOutOfRange {
99            index,
100            value,
101            bound,
102        } => BcsrError::HyperedgeOutOfRange {
103            section,
104            index,
105            hyperedge: value,
106            hyperedge_count: bound,
107        },
108        OffsetIntegrityIssue::UsizeOverflow { index } => {
109            let value = values
110                .get(index)
111                .copied()
112                .and_then(|w| w.get().to_usize())
113                .unwrap_or(usize::MAX);
114            BcsrError::UsizeOverflow { value }
115        }
116        _ => BcsrError::UsizeOverflow { value: usize::MAX },
117    }
118}
119
120/// Validation depth applied at view open time.
121///
122/// `Layout` is the cheap default and catches every violation that lets a
123/// downstream traversal walk out of bounds. `Strict` additionally verifies
124/// cross-direction consistency: the hyperedge-major and vertex-major
125/// indexes describe the same set of incidences. `Strict` is required for
126/// end-to-end semantic guarantees on untrusted producers.
127///
128/// # Performance
129///
130/// `perf: unspecified`; this is a metadata enum.
131#[non_exhaustive]
132#[derive(Clone, Copy, Debug, Eq, PartialEq)]
133pub enum BcsrValidation {
134    /// Length, monotonicity, in-range IDs, sorted-and-unique within ranges.
135    /// Cost is `O(P_head + P_tail + P_outgoing + P_incoming)`.
136    Layout,
137    /// Layout plus cross-direction multiset equality.
138    /// Cost is `O((P_head + P_tail) · log d)` where `d` is the maximum
139    /// vertex outgoing or incoming degree.
140    Strict,
141}
142
143/// Counts derived from validated offset arrays.
144///
145/// Returned by [`validate_sections`] so the view can store them without
146/// recomputing.
147///
148/// # Performance
149///
150/// `perf: unspecified`; copying is `O(1)`.
151#[derive(Clone, Copy, Debug)]
152pub(in crate::internal) struct DerivedCounts {
153    /// Number of vertices visible in this view.
154    pub(in crate::internal) vertex_count: usize,
155    /// Number of hyperedges visible in this view.
156    pub(in crate::internal) hyperedge_count: usize,
157    /// Number of outgoing incidences (`P_head == P_outgoing`).
158    pub(in crate::internal) p_outgoing: usize,
159    /// Number of incoming incidences (`P_tail == P_incoming`).
160    pub(in crate::internal) p_incoming: usize,
161    /// Total incidence count (`P_outgoing + P_incoming`). Validation
162    /// guarantees this fits in `usize`.
163    pub(in crate::internal) total_incidences: usize,
164}
165
166/// Validates the eight bipartite-CSR section payloads.
167///
168/// Walks the sections in a deterministic order and returns the derived
169/// vertex / hyperedge / per-direction incidence counts on success.
170///
171/// # Errors
172///
173/// Returns the first [`BcsrError`] encountered during validation.
174///
175/// # Performance
176///
177/// At [`BcsrValidation::Layout`] the cost is
178/// `O(P_head + P_tail + P_outgoing + P_incoming)`. [`BcsrValidation::Strict`]
179/// adds `O((P_head + P_tail) · log d)` for the cross-direction walk.
180pub(in crate::internal) fn validate_sections<OffsetWord, VertexWord, RelationWord>(
181    sections: &BcsrSections<'_, OffsetWord, VertexWord, RelationWord>,
182    level: BcsrValidation,
183) -> Result<DerivedCounts, BcsrError>
184where
185    OffsetWord: LayoutWord,
186    VertexWord: LayoutWord,
187    RelationWord: LayoutWord,
188{
189    let counts = derive_counts(sections)?;
190    validate_all_offsets(sections, counts)?;
191    validate_total_lengths(sections)?;
192    validate_value_ranges(sections, counts)?;
193    validate_within_range_sorted(sections)?;
194    if matches!(level, BcsrValidation::Strict) {
195        validate_cross_direction(sections)?;
196    }
197    Ok(counts)
198}
199
200/// Derives `vertex_count`, `hyperedge_count`, `P_outgoing`, `P_incoming`
201/// after checking offset slice lengths agree pairwise.
202fn derive_counts<OffsetWord, VertexWord, RelationWord>(
203    sections: &BcsrSections<'_, OffsetWord, VertexWord, RelationWord>,
204) -> Result<DerivedCounts, BcsrError>
205where
206    OffsetWord: LayoutWord,
207    VertexWord: LayoutWord,
208    RelationWord: LayoutWord,
209{
210    let head_len = sections.head_offsets.len();
211    let tail_len = sections.tail_offsets.len();
212    if head_len != tail_len {
213        return Err(BcsrError::HyperedgeOffsetLengthMismatch {
214            head_offsets_len: head_len,
215            tail_offsets_len: tail_len,
216        });
217    }
218    let outgoing_len = sections.vertex_outgoing_offsets.len();
219    let incoming_len = sections.vertex_incoming_offsets.len();
220    if outgoing_len != incoming_len {
221        return Err(BcsrError::VertexOffsetLengthMismatch {
222            outgoing_offsets_len: outgoing_len,
223            incoming_offsets_len: incoming_len,
224        });
225    }
226
227    let hyperedge_count = derive_count_from_offsets(head_len, BcsrSection::HeadOffsets)?;
228    // `head_len == tail_len` was enforced above and `hyperedge_count` is derived
229    // from `head_len`, so both paired hyperedge offset arrays are exactly
230    // `hyperedge_count + 1` long. This makes the "head offsets are canonical"
231    // assumption (head incidence IDs equal head-block positions directly)
232    // explicit rather than implicit. The same pairing holds for the vertex
233    // outgoing/incoming offset arrays via `outgoing_len == incoming_len`.
234    debug_assert_eq!(
235        head_len,
236        hyperedge_count + 1,
237        "head offsets must be hyperedge_count + 1"
238    );
239    debug_assert_eq!(
240        tail_len,
241        hyperedge_count + 1,
242        "tail offsets must be hyperedge_count + 1 (paired with head)"
243    );
244    let vertex_count = derive_count_from_offsets(outgoing_len, BcsrSection::VertexOutgoingOffsets)?;
245    debug_assert_eq!(
246        outgoing_len,
247        vertex_count + 1,
248        "vertex outgoing offsets must be vertex_count + 1"
249    );
250    debug_assert_eq!(
251        incoming_len,
252        vertex_count + 1,
253        "vertex incoming offsets must be vertex_count + 1 (paired with outgoing)"
254    );
255    let p_outgoing = sections.vertex_outgoing_hyperedges.len();
256    let p_incoming = sections.vertex_incoming_hyperedges.len();
257    let total_incidences =
258        p_outgoing
259            .checked_add(p_incoming)
260            .ok_or(BcsrError::TotalIncidenceCountOverflow {
261                p_head: p_outgoing,
262                p_tail: p_incoming,
263            })?;
264
265    Ok(DerivedCounts {
266        vertex_count,
267        hyperedge_count,
268        p_outgoing,
269        p_incoming,
270        total_incidences,
271    })
272}
273
274/// Returns `offsets.len() - 1` after checking length is non-zero.
275const fn derive_count_from_offsets(
276    offsets_len: usize,
277    section: BcsrSection,
278) -> Result<usize, BcsrError> {
279    if offsets_len == 0 {
280        return Err(BcsrError::OffsetLength {
281            section,
282            expected: 1,
283            actual: 0,
284        });
285    }
286    Ok(offsets_len - 1)
287}
288
289/// Validates each of the four offset arrays end-to-end.
290fn validate_all_offsets<OffsetWord, VertexWord, RelationWord>(
291    sections: &BcsrSections<'_, OffsetWord, VertexWord, RelationWord>,
292    counts: DerivedCounts,
293) -> Result<(), BcsrError>
294where
295    OffsetWord: LayoutWord,
296    VertexWord: LayoutWord,
297    RelationWord: LayoutWord,
298{
299    validate_one_offsets(
300        sections.head_offsets,
301        BcsrSection::HeadOffsets,
302        counts.hyperedge_count,
303        sections.head_participants.len(),
304    )?;
305    validate_one_offsets(
306        sections.tail_offsets,
307        BcsrSection::TailOffsets,
308        counts.hyperedge_count,
309        sections.tail_participants.len(),
310    )?;
311    validate_one_offsets(
312        sections.vertex_outgoing_offsets,
313        BcsrSection::VertexOutgoingOffsets,
314        counts.vertex_count,
315        sections.vertex_outgoing_hyperedges.len(),
316    )?;
317    validate_one_offsets(
318        sections.vertex_incoming_offsets,
319        BcsrSection::VertexIncomingOffsets,
320        counts.vertex_count,
321        sections.vertex_incoming_hyperedges.len(),
322    )
323}
324
325/// Validates one offset array: length is `count + 1`, first offset is 0,
326/// monotonic non-decreasing, final offset matches `value_len`. Delegates to
327/// [`oxgraph_layout_util::check_offset_section`] and stamps the [`BcsrSection`]
328/// discriminator on any returned issue.
329fn validate_one_offsets<Word: LayoutWord>(
330    offsets: &[Word],
331    section: BcsrSection,
332    count: usize,
333    value_len: usize,
334) -> Result<(), BcsrError> {
335    if count.checked_add(1).is_none() {
336        return Err(BcsrError::OffsetLengthOverflow { count });
337    }
338    check_offset_section(offsets, count, value_len)
339        .map_err(|issue| map_offsets_issue(section, offsets, issue))
340}
341
342/// Verifies that head/outgoing and tail/incoming totals agree, so the
343/// bipartite index has a single shared incidence count per direction.
344const fn validate_total_lengths<OffsetWord, VertexWord, RelationWord>(
345    sections: &BcsrSections<'_, OffsetWord, VertexWord, RelationWord>,
346) -> Result<(), BcsrError>
347where
348    OffsetWord: LayoutWord,
349    VertexWord: LayoutWord,
350    RelationWord: LayoutWord,
351{
352    if sections.head_participants.len() != sections.vertex_outgoing_hyperedges.len() {
353        return Err(BcsrError::OutgoingTotalMismatch {
354            head_participants_len: sections.head_participants.len(),
355            outgoing_hyperedges_len: sections.vertex_outgoing_hyperedges.len(),
356        });
357    }
358    if sections.tail_participants.len() != sections.vertex_incoming_hyperedges.len() {
359        return Err(BcsrError::IncomingTotalMismatch {
360            tail_participants_len: sections.tail_participants.len(),
361            incoming_hyperedges_len: sections.vertex_incoming_hyperedges.len(),
362        });
363    }
364    Ok(())
365}
366
367/// Verifies vertex IDs and hyperedge IDs in value sections are in range.
368fn validate_value_ranges<OffsetWord, VertexWord, RelationWord>(
369    sections: &BcsrSections<'_, OffsetWord, VertexWord, RelationWord>,
370    counts: DerivedCounts,
371) -> Result<(), BcsrError>
372where
373    OffsetWord: LayoutWord,
374    VertexWord: LayoutWord,
375    RelationWord: LayoutWord,
376{
377    check_vertex_values(
378        sections.head_participants,
379        BcsrSection::HeadParticipants,
380        counts.vertex_count,
381    )?;
382    check_vertex_values(
383        sections.tail_participants,
384        BcsrSection::TailParticipants,
385        counts.vertex_count,
386    )?;
387    check_hyperedge_values(
388        sections.vertex_outgoing_hyperedges,
389        BcsrSection::VertexOutgoingHyperedges,
390        counts.hyperedge_count,
391    )?;
392    check_hyperedge_values(
393        sections.vertex_incoming_hyperedges,
394        BcsrSection::VertexIncomingHyperedges,
395        counts.hyperedge_count,
396    )
397}
398
399/// Returns `Err` if any vertex word is `>= vertex_count`. Delegates to
400/// [`oxgraph_layout_util::check_value_range`] and stamps [`BcsrSection`] on the
401/// returned issue.
402fn check_vertex_values<Word: LayoutWord>(
403    values: &[Word],
404    section: BcsrSection,
405    vertex_count: usize,
406) -> Result<(), BcsrError> {
407    check_value_range(values, vertex_count)
408        .map_err(|issue| map_vertex_value_issue(section, values, issue))
409}
410
411/// Returns `Err` if any hyperedge word is `>= hyperedge_count`. Delegates to
412/// [`oxgraph_layout_util::check_value_range`] and stamps [`BcsrSection`] on the
413/// returned issue.
414fn check_hyperedge_values<Word: LayoutWord>(
415    values: &[Word],
416    section: BcsrSection,
417    hyperedge_count: usize,
418) -> Result<(), BcsrError> {
419    check_value_range(values, hyperedge_count)
420        .map_err(|issue| map_hyperedge_value_issue(section, values, issue))
421}
422
423/// Verifies that values within every per-bucket range are strictly
424/// ascending. Bipartite-CSR uses set semantics inside each range.
425fn validate_within_range_sorted<OffsetWord, VertexWord, RelationWord>(
426    sections: &BcsrSections<'_, OffsetWord, VertexWord, RelationWord>,
427) -> Result<(), BcsrError>
428where
429    OffsetWord: LayoutWord,
430    VertexWord: LayoutWord,
431    RelationWord: LayoutWord,
432{
433    check_strictly_ascending_buckets(
434        sections.head_offsets,
435        sections.head_participants,
436        BcsrSection::HeadParticipants,
437    )?;
438    check_strictly_ascending_buckets(
439        sections.tail_offsets,
440        sections.tail_participants,
441        BcsrSection::TailParticipants,
442    )?;
443    check_strictly_ascending_buckets(
444        sections.vertex_outgoing_offsets,
445        sections.vertex_outgoing_hyperedges,
446        BcsrSection::VertexOutgoingHyperedges,
447    )?;
448    check_strictly_ascending_buckets(
449        sections.vertex_incoming_offsets,
450        sections.vertex_incoming_hyperedges,
451        BcsrSection::VertexIncomingHyperedges,
452    )
453}
454
455/// Checks each `[offsets[i], offsets[i + 1])` bucket of `values` is
456/// strictly ascending.
457fn check_strictly_ascending_buckets<OffsetWord, Word>(
458    offsets: &[OffsetWord],
459    values: &[Word],
460    section: BcsrSection,
461) -> Result<(), BcsrError>
462where
463    OffsetWord: LayoutWord,
464    Word: LayoutWord,
465{
466    if offsets.len() < 2 {
467        return Ok(());
468    }
469    for window in offsets.windows(2) {
470        let start = index_to_usize(window[0].get())?;
471        let end = index_to_usize(window[1].get())?;
472        check_strictly_ascending_range(values, start, end, section)?;
473    }
474    Ok(())
475}
476
477/// Verifies `values[start..end]` is strictly ascending.
478fn check_strictly_ascending_range<Word: LayoutWord>(
479    values: &[Word],
480    start: usize,
481    end: usize,
482    section: BcsrSection,
483) -> Result<(), BcsrError> {
484    if end <= start + 1 {
485        return Ok(());
486    }
487    let mut previous = index_to_usize(values[start].get())?;
488    for relative in 1..(end - start) {
489        let index = start + relative;
490        let actual = index_to_usize(values[index].get())?;
491        if actual <= previous {
492            return Err(BcsrError::NotStrictlyAscending {
493                section,
494                index,
495                previous,
496                actual,
497            });
498        }
499        previous = actual;
500    }
501    Ok(())
502}
503
504/// Verifies that the hyperedge-major and vertex-major indexes describe the
505/// same multiset of incidences (Strict-tier check). Set semantics let the
506/// check use binary search; cost is `O((P_head + P_tail) · log d)`.
507fn validate_cross_direction<OffsetWord, VertexWord, RelationWord>(
508    sections: &BcsrSections<'_, OffsetWord, VertexWord, RelationWord>,
509) -> Result<(), BcsrError>
510where
511    OffsetWord: LayoutWord,
512    VertexWord: LayoutWord,
513    RelationWord: LayoutWord,
514{
515    // Forward containment: every hyperedge-major `(h, v)` incidence appears in
516    // vertex `v`'s vertex-major bucket.
517    cross_direction_walk(
518        sections.head_offsets,
519        sections.head_participants,
520        sections.vertex_outgoing_offsets,
521        sections.vertex_outgoing_hyperedges,
522        BcsrRoleSide::Outgoing,
523    )?;
524    cross_direction_walk(
525        sections.tail_offsets,
526        sections.tail_participants,
527        sections.vertex_incoming_offsets,
528        sections.vertex_incoming_hyperedges,
529        BcsrRoleSide::Incoming,
530    )?;
531    // Converse containment: every vertex-major `(v, h)` incidence appears in
532    // hyperedge `h`'s hyperedge-major bucket. Forward + converse containment
533    // over strictly-ascending (set-semantic) buckets is a true bidirectional
534    // multiset equality, not merely one-directional containment plus a count
535    // check.
536    converse_direction_walk(
537        sections.vertex_outgoing_offsets,
538        sections.vertex_outgoing_hyperedges,
539        sections.head_offsets,
540        sections.head_participants,
541        BcsrRoleSide::Outgoing,
542    )?;
543    converse_direction_walk(
544        sections.vertex_incoming_offsets,
545        sections.vertex_incoming_hyperedges,
546        sections.tail_offsets,
547        sections.tail_participants,
548        BcsrRoleSide::Incoming,
549    )
550}
551
552/// Walks one side of the bipartite index vertex-by-vertex and confirms every
553/// `(v, h)` pair appears in the matching hyperedge-major bucket. This is the
554/// converse of [`cross_direction_walk`].
555fn converse_direction_walk<OffsetWord, VertexWord, RelationWord>(
556    vertex_offsets: &[OffsetWord],
557    vertex_values: &[RelationWord],
558    edge_offsets: &[OffsetWord],
559    edge_values: &[VertexWord],
560    side: BcsrRoleSide,
561) -> Result<(), BcsrError>
562where
563    OffsetWord: LayoutWord,
564    VertexWord: LayoutWord,
565    RelationWord: LayoutWord,
566{
567    if vertex_offsets.len() < 2 {
568        return Ok(());
569    }
570    for vertex_index in 0..(vertex_offsets.len() - 1) {
571        let start = index_to_usize(vertex_offsets[vertex_index].get())?;
572        let end = index_to_usize(vertex_offsets[vertex_index + 1].get())?;
573        for word in vertex_values.iter().take(end).skip(start) {
574            let hyperedge = index_to_usize(word.get())?;
575            let bucket_start = index_to_usize(edge_offsets[hyperedge].get())?;
576            let bucket_end = index_to_usize(edge_offsets[hyperedge + 1].get())?;
577            if !bucket_contains(edge_values, bucket_start, bucket_end, vertex_index) {
578                return Err(BcsrError::CrossDirectionMismatch {
579                    side,
580                    hyperedge,
581                    vertex: vertex_index,
582                });
583            }
584        }
585    }
586    Ok(())
587}
588
589/// Walks one side of the bipartite index hyperedge-by-hyperedge and confirms
590/// every `(h, v)` pair appears in the matching vertex-major bucket.
591fn cross_direction_walk<OffsetWord, VertexWord, RelationWord>(
592    edge_offsets: &[OffsetWord],
593    edge_values: &[VertexWord],
594    vertex_offsets: &[OffsetWord],
595    vertex_values: &[RelationWord],
596    side: BcsrRoleSide,
597) -> Result<(), BcsrError>
598where
599    OffsetWord: LayoutWord,
600    VertexWord: LayoutWord,
601    RelationWord: LayoutWord,
602{
603    if edge_offsets.len() < 2 {
604        return Ok(());
605    }
606    for hyperedge_index in 0..(edge_offsets.len() - 1) {
607        let start = index_to_usize(edge_offsets[hyperedge_index].get())?;
608        let end = index_to_usize(edge_offsets[hyperedge_index + 1].get())?;
609        let hyperedge = hyperedge_index;
610        cross_direction_check_bucket(CrossDirectionBucket {
611            edge_values,
612            start,
613            end,
614            vertex_offsets,
615            vertex_values,
616            hyperedge,
617            side,
618        })?;
619    }
620    Ok(())
621}
622
623/// Parameter bundle for [`cross_direction_check_bucket`].
624#[derive(Clone, Copy)]
625struct CrossDirectionBucket<'a, OffsetWord, VertexWord, RelationWord> {
626    /// Hyperedge-major value slice (`head_participants` or `tail_participants`).
627    edge_values: &'a [VertexWord],
628    /// Inclusive start of the hyperedge's range inside `edge_values`.
629    start: usize,
630    /// Exclusive end of the hyperedge's range inside `edge_values`.
631    end: usize,
632    /// Vertex-major offset slice on the matching side.
633    vertex_offsets: &'a [OffsetWord],
634    /// Vertex-major hyperedge ID slice on the matching side.
635    vertex_values: &'a [RelationWord],
636    /// Hyperedge ID being checked.
637    hyperedge: usize,
638    /// Which side of the bipartite index this check covers.
639    side: BcsrRoleSide,
640}
641
642/// Confirms every vertex in `edge_values[start..end]` lists `hyperedge`
643/// in its own vertex-major bucket.
644fn cross_direction_check_bucket<OffsetWord, VertexWord, RelationWord>(
645    args: CrossDirectionBucket<'_, OffsetWord, VertexWord, RelationWord>,
646) -> Result<(), BcsrError>
647where
648    OffsetWord: LayoutWord,
649    VertexWord: LayoutWord,
650    RelationWord: LayoutWord,
651{
652    for word in args.edge_values.iter().take(args.end).skip(args.start) {
653        let vertex = index_to_usize(word.get())?;
654        let v_index = vertex;
655        let bucket_start = index_to_usize(args.vertex_offsets[v_index].get())?;
656        let bucket_end = index_to_usize(args.vertex_offsets[v_index + 1].get())?;
657        if !bucket_contains(args.vertex_values, bucket_start, bucket_end, args.hyperedge) {
658            return Err(BcsrError::CrossDirectionMismatch {
659                side: args.side,
660                hyperedge: args.hyperedge,
661                vertex,
662            });
663        }
664    }
665    Ok(())
666}
667
668/// Returns whether `values[start..end]` contains `needle`. Values within the
669/// range are required to be strictly ascending, so binary search is correct.
670fn bucket_contains<Word: LayoutWord>(
671    values: &[Word],
672    start: usize,
673    end: usize,
674    needle: usize,
675) -> bool {
676    let bucket = &values[start..end];
677    bucket
678        .binary_search_by(|word| index_to_usize_validated(word.get()).cmp(&needle))
679        .is_ok()
680}
681
682/// Converts a BCSR index to `usize`, returning a typed error on truncation.
683pub(in crate::internal) fn index_to_usize<Index: LayoutIndex>(
684    value: Index,
685) -> Result<usize, BcsrError> {
686    value
687        .to_usize()
688        .ok_or(BcsrError::UsizeOverflow { value: usize::MAX })
689}
690
691/// Converts a previously validated BCSR index to `usize`.
692///
693/// # Panics
694///
695/// Panics via `unreachable!()` only if validation has been bypassed. All
696/// in-tree callers run after [`validate_sections`], which surfaces truncation
697/// as [`BcsrError::UsizeOverflow`] before any `_validated` call.
698///
699/// # Performance
700///
701/// This function is `O(1)`.
702pub(in crate::internal) fn index_to_usize_validated<Index: LayoutIndex>(value: Index) -> usize {
703    oxgraph_layout_util::index_to_usize_validated(value)
704        .unwrap_or_else(|| unreachable!("validated bipartite-CSR index must fit usize"))
705}
706
707/// Converts a previously validated `usize` slot to a BCSR index.
708///
709/// # Panics
710///
711/// Panics via `unreachable!()` only if validation failed to enforce the target
712/// index width.
713///
714/// # Performance
715///
716/// This function is `O(1)`.
717pub(in crate::internal) fn usize_to_index_validated<Index: LayoutIndex>(value: usize) -> Index {
718    oxgraph_layout_util::usize_to_index_validated(value)
719        .unwrap_or_else(|| unreachable!("validated BCSR slot must fit index"))
720}