use std::collections::BTreeSet;
use oxgraph_hyper::{
DirectedHyperedgeParticipants, ElementIncidenceCount, HyperedgeParticipantCount,
HyperedgeParticipants, IncidentHyperedgeCount, IncidentHyperedges, RelationIncidenceCount,
};
use oxgraph_hyper_bcsr::{
BcsrError, BcsrHyperedgeId, BcsrHypergraph, BcsrRoleSide, BcsrSections, BcsrValidation,
BcsrVertexId,
};
use proptest::prelude::*;
type BcsrPicks = (u32, u32, Vec<Vec<u32>>, Vec<Vec<u32>>);
type FlattenedSide = (Vec<u32>, Vec<u32>, Vec<u32>);
#[derive(Debug)]
struct OwnedBcsr {
head_offsets: Vec<u32>,
head_participants: Vec<u32>,
tail_offsets: Vec<u32>,
tail_participants: Vec<u32>,
vertex_outgoing_offsets: Vec<u32>,
vertex_outgoing_hyperedges: Vec<u32>,
vertex_incoming_offsets: Vec<u32>,
vertex_incoming_hyperedges: Vec<u32>,
vertex_count: u32,
hyperedge_count: u32,
head_degrees: Vec<u32>,
tail_degrees: Vec<u32>,
outgoing_degrees: Vec<u32>,
incoming_degrees: Vec<u32>,
}
impl OwnedBcsr {
fn sections(&self) -> BcsrSections<'_, u32, u32, u32> {
BcsrSections {
head_offsets: &self.head_offsets,
head_participants: &self.head_participants,
tail_offsets: &self.tail_offsets,
tail_participants: &self.tail_participants,
vertex_outgoing_offsets: &self.vertex_outgoing_offsets,
vertex_outgoing_hyperedges: &self.vertex_outgoing_hyperedges,
vertex_incoming_offsets: &self.vertex_incoming_offsets,
vertex_incoming_hyperedges: &self.vertex_incoming_hyperedges,
}
}
}
fn gen_bcsr_picks() -> impl Strategy<Value = BcsrPicks> {
(0u32..16, 0u32..16).prop_flat_map(|(vertex_count, hyperedge_count)| {
let h_count = usize::try_from(hyperedge_count).unwrap_or(0);
let pick_count_max = if vertex_count == 0 { 0 } else { 4usize };
let pick_value_max = vertex_count.saturating_sub(1);
let head = proptest::collection::vec(
proptest::collection::vec(0u32..=pick_value_max, 0..=pick_count_max),
h_count,
);
let tail = proptest::collection::vec(
proptest::collection::vec(0u32..=pick_value_max, 0..=pick_count_max),
h_count,
);
(Just(vertex_count), Just(hyperedge_count), head, tail)
})
}
fn build_owned_bcsr(
vertex_count: u32,
hyperedge_count: u32,
head_picks: &[Vec<u32>],
tail_picks: &[Vec<u32>],
) -> Result<OwnedBcsr, TestCaseError> {
let head_sets = dedup_picks(head_picks, vertex_count);
let tail_sets = dedup_picks(tail_picks, vertex_count);
let (head_offsets, head_participants, head_degrees) = flatten_hyperedge_major(&head_sets)?;
let (tail_offsets, tail_participants, tail_degrees) = flatten_hyperedge_major(&tail_sets)?;
let v_count = try_into_usize(vertex_count)?;
let outgoing_per_vertex = invert_into_vertex_major(&head_sets, v_count)?;
let incoming_per_vertex = invert_into_vertex_major(&tail_sets, v_count)?;
let (vertex_outgoing_offsets, vertex_outgoing_hyperedges, outgoing_degrees) =
flatten_vertex_major(&outgoing_per_vertex)?;
let (vertex_incoming_offsets, vertex_incoming_hyperedges, incoming_degrees) =
flatten_vertex_major(&incoming_per_vertex)?;
Ok(OwnedBcsr {
head_offsets,
head_participants,
tail_offsets,
tail_participants,
vertex_outgoing_offsets,
vertex_outgoing_hyperedges,
vertex_incoming_offsets,
vertex_incoming_hyperedges,
vertex_count,
hyperedge_count,
head_degrees,
tail_degrees,
outgoing_degrees,
incoming_degrees,
})
}
fn dedup_picks(picks: &[Vec<u32>], vertex_count: u32) -> Vec<BTreeSet<u32>> {
picks
.iter()
.map(|inner| {
inner
.iter()
.copied()
.filter(|v| *v < vertex_count)
.collect()
})
.collect()
}
fn flatten_hyperedge_major(sets: &[BTreeSet<u32>]) -> Result<FlattenedSide, TestCaseError> {
let mut offsets = Vec::with_capacity(sets.len() + 1);
let mut flat = Vec::new();
let mut degrees = Vec::with_capacity(sets.len());
offsets.push(0u32);
for set in sets {
degrees.push(try_into_u32(set.len())?);
flat.extend(set.iter().copied());
offsets.push(try_into_u32(flat.len())?);
}
Ok((offsets, flat, degrees))
}
fn invert_into_vertex_major(
sets: &[BTreeSet<u32>],
vertex_count: usize,
) -> Result<Vec<BTreeSet<u32>>, TestCaseError> {
let mut per_vertex: Vec<BTreeSet<u32>> = (0..vertex_count).map(|_| BTreeSet::new()).collect();
for (h_idx, set) in sets.iter().enumerate() {
let h_id = try_into_u32(h_idx)?;
for vertex in set {
let v_idx = try_into_usize(*vertex)?;
per_vertex[v_idx].insert(h_id);
}
}
Ok(per_vertex)
}
fn flatten_vertex_major(per_vertex: &[BTreeSet<u32>]) -> Result<FlattenedSide, TestCaseError> {
let mut offsets = Vec::with_capacity(per_vertex.len() + 1);
let mut flat = Vec::new();
let mut degrees = Vec::with_capacity(per_vertex.len());
offsets.push(0u32);
for set in per_vertex {
degrees.push(try_into_u32(set.len())?);
flat.extend(set.iter().copied());
offsets.push(try_into_u32(flat.len())?);
}
Ok((offsets, flat, degrees))
}
fn try_into_u32(value: usize) -> Result<u32, TestCaseError> {
u32::try_from(value)
.map_err(|err| TestCaseError::fail(format!("usize {value} does not fit u32: {err:?}")))
}
fn try_into_usize(value: u32) -> Result<usize, TestCaseError> {
usize::try_from(value)
.map_err(|err| TestCaseError::fail(format!("u32 {value} does not fit usize: {err:?}")))
}
fn check_exact_size_walk<I>(mut iter: I) -> Result<(), TestCaseError>
where
I: ExactSizeIterator,
{
let mut remaining = iter.len();
while iter.next().is_some() {
remaining = remaining.checked_sub(1).ok_or_else(|| {
TestCaseError::fail("ExactSizeIterator yielded more elements than initial len()")
})?;
prop_assert_eq!(iter.len(), remaining);
}
if iter.len() != 0 {
return Err(TestCaseError::fail(format!(
"iterator len() == {} after exhaustion",
iter.len()
)));
}
Ok(())
}
fn tamper_single_outgoing_bucket(owned: &OwnedBcsr) -> Option<Vec<u32>> {
if owned.hyperedge_count < 2 {
return None;
}
for v_idx in 0..owned.outgoing_degrees.len() {
let start = usize::try_from(owned.vertex_outgoing_offsets[v_idx]).ok()?;
let end = usize::try_from(owned.vertex_outgoing_offsets[v_idx + 1]).ok()?;
if end.checked_sub(start)? != 1 {
continue;
}
let original = owned.vertex_outgoing_hyperedges[start];
let swapped = (original + 1) % owned.hyperedge_count;
if swapped == original {
continue;
}
let mut tampered = owned.vertex_outgoing_hyperedges.clone();
tampered[start] = swapped;
return Some(tampered);
}
None
}
proptest! {
#![proptest_config(ProptestConfig {
failure_persistence: None,
..ProptestConfig::default()
})]
#[test]
fn generated_bcsr_validates_at_layout_and_strict(
(vertex_count, hyperedge_count, head_picks, tail_picks) in gen_bcsr_picks()
) {
let owned = build_owned_bcsr(vertex_count, hyperedge_count, &head_picks, &tail_picks)?;
let sections = owned.sections();
prop_assert!(BcsrHypergraph::open(sections).is_ok());
prop_assert!(BcsrHypergraph::open_with(sections, BcsrValidation::Strict).is_ok());
}
#[test]
fn generated_bcsr_counts_match_traversal(
(vertex_count, hyperedge_count, head_picks, tail_picks) in gen_bcsr_picks()
) {
let owned = build_owned_bcsr(vertex_count, hyperedge_count, &head_picks, &tail_picks)?;
let view = match BcsrHypergraph::open_with(owned.sections(), BcsrValidation::Strict) {
Ok(value) => value,
Err(error) => return Err(TestCaseError::fail(format!(
"Strict open rejected valid input: {error:?}"
))),
};
prop_assert_eq!(view.vertex_count(), try_into_usize(owned.vertex_count)?);
prop_assert_eq!(view.hyperedge_count(), try_into_usize(owned.hyperedge_count)?);
for (h_idx, head_deg) in owned.head_degrees.iter().enumerate() {
let tail_deg = owned.tail_degrees[h_idx];
let expected = try_into_usize(*head_deg + tail_deg)?;
let h_id = BcsrHyperedgeId::new(try_into_u32(h_idx)?);
prop_assert_eq!(view.hyperedge_participants(h_id).count(), expected);
prop_assert_eq!(view.hyperedge_participant_count(h_id), expected);
prop_assert_eq!(view.relation_incidence_count(h_id), expected);
}
for (v_idx, out_deg) in owned.outgoing_degrees.iter().enumerate() {
let in_deg = owned.incoming_degrees[v_idx];
let expected = try_into_usize(*out_deg + in_deg)?;
let v_id = BcsrVertexId::new(try_into_u32(v_idx)?);
prop_assert_eq!(view.incident_hyperedges(v_id).count(), expected);
prop_assert_eq!(view.incident_hyperedge_count(v_id), expected);
prop_assert_eq!(view.element_incidence_count(v_id), expected);
}
let sum_head: u32 = owned.head_degrees.iter().sum();
let sum_outgoing: u32 = owned.outgoing_degrees.iter().sum();
prop_assert_eq!(sum_head, sum_outgoing);
let sum_tail: u32 = owned.tail_degrees.iter().sum();
let sum_incoming: u32 = owned.incoming_degrees.iter().sum();
prop_assert_eq!(sum_tail, sum_incoming);
}
#[test]
fn generated_bcsr_iterators_preserve_exact_size(
(vertex_count, hyperedge_count, head_picks, tail_picks) in gen_bcsr_picks()
) {
let owned = build_owned_bcsr(vertex_count, hyperedge_count, &head_picks, &tail_picks)?;
let view = match BcsrHypergraph::open(owned.sections()) {
Ok(value) => value,
Err(error) => return Err(TestCaseError::fail(format!(
"Layout open rejected valid input: {error:?}"
))),
};
for h_idx in 0..owned.hyperedge_count {
let h_id = BcsrHyperedgeId::new(h_idx);
check_exact_size_walk(view.source_participants(h_id))?;
check_exact_size_walk(view.target_participants(h_id))?;
}
}
#[test]
fn tampered_outgoing_bucket_is_layout_pass_strict_fail(
(vertex_count, hyperedge_count, head_picks, tail_picks) in gen_bcsr_picks()
) {
let owned = build_owned_bcsr(vertex_count, hyperedge_count, &head_picks, &tail_picks)?;
let Some(tampered) = tamper_single_outgoing_bucket(&owned) else {
return Ok(());
};
let tampered_sections = BcsrSections {
head_offsets: &owned.head_offsets,
head_participants: &owned.head_participants,
tail_offsets: &owned.tail_offsets,
tail_participants: &owned.tail_participants,
vertex_outgoing_offsets: &owned.vertex_outgoing_offsets,
vertex_outgoing_hyperedges: &tampered,
vertex_incoming_offsets: &owned.vertex_incoming_offsets,
vertex_incoming_hyperedges: &owned.vertex_incoming_hyperedges,
};
prop_assert!(
BcsrHypergraph::open(tampered_sections).is_ok(),
"Layout should accept a single-entry-bucket swap"
);
match BcsrHypergraph::open_with(tampered_sections, BcsrValidation::Strict) {
Err(BcsrError::CrossDirectionMismatch { side: BcsrRoleSide::Outgoing, .. }) => {}
other => return Err(TestCaseError::fail(format!(
"expected CrossDirectionMismatch{{side:Outgoing,..}} after tampering; got {other:?}"
))),
}
}
}