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