use std::alloc::{GlobalAlloc, Layout, System};
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicUsize, Ordering};
use petgraph::graph::{Graph, NodeIndex, UnGraph};
use petgraph::stable_graph::StableGraph;
use petgraph::{Directed, Undirected};
use proptest::prelude::*;
use rand::rngs::StdRng;
use rand::seq::SliceRandom;
use rand::{Rng, SeedableRng};
use crate::canonical::{
all_connected_classes, canonical_arg_by, canonical_by, class_to_adj, connected, perms,
};
use crate::catalog::{
count_diamonds, count_motif, count_pattern, find_diamonds, find_motif, Induced, MotifCatalog,
Pattern,
};
use crate::census::{count, enumerate, for_each_subset, Census, Selector};
use crate::orbit::{graphlet_degree_vectors, Registry};
use crate::rim::null_model::{
configuration_model, configuration_model_simple, double_edge_swap, lfr_benchmark,
watts_strogatz,
};
use crate::rim::scalable::{fast_count, fast_graphlet_degree_vectors, FAST_ORBIT_COUNT};
use crate::snapshot::Snapshot;
use crate::template::{
count_induced_matches, count_monomorphisms, count_monomorphisms_unlabelled, monomorphisms,
monomorphisms_unlabelled,
};
use crate::ClassId;
struct Track;
static CUR: AtomicUsize = AtomicUsize::new(0);
static PEAK: AtomicUsize = AtomicUsize::new(0);
unsafe impl GlobalAlloc for Track {
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
let p = System.alloc(l);
if !p.is_null() {
let now = CUR.fetch_add(l.size(), Ordering::Relaxed) + l.size();
let mut prev = PEAK.load(Ordering::Relaxed);
while now > prev {
match PEAK.compare_exchange_weak(prev, now, Ordering::Relaxed, Ordering::Relaxed) {
Ok(_) => break,
Err(x) => prev = x,
}
}
}
p
}
unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
CUR.fetch_sub(l.size(), Ordering::Relaxed);
System.dealloc(p, l);
}
}
#[global_allocator]
static ALLOC: Track = Track;
fn reset_peak() {
PEAK.store(CUR.load(Ordering::Relaxed), Ordering::Relaxed);
}
fn build_un(edges: &[(usize, usize)], n: usize) -> UnGraph<(), ()> {
let mut g = Graph::<(), (), Undirected>::new_undirected();
let idx: Vec<_> = (0..n).map(|_| g.add_node(())).collect();
for &(a, b) in edges {
g.add_edge(idx[a], idx[b], ());
}
g
}
fn build_perm_un(edges: &[(usize, usize)], n: usize, order: &[usize]) -> UnGraph<(), ()> {
let mut g = Graph::<(), (), Undirected>::new_undirected();
let mut idx = vec![NodeIndex::new(0); n];
for &logical in order {
idx[logical] = g.add_node(());
}
for &(a, b) in edges {
g.add_edge(idx[a], idx[b], ());
}
g
}
fn build_perm_stable(
edges: &[(usize, usize)],
n: usize,
order: &[usize],
) -> StableGraph<(), (), Undirected> {
let mut g = StableGraph::<(), (), Undirected>::default();
let mut idx = vec![petgraph::stable_graph::NodeIndex::new(0); n];
for &logical in order {
idx[logical] = g.add_node(());
}
for &(a, b) in edges {
g.add_edge(idx[a], idx[b], ());
}
g
}
fn build_weighted(edges: &[(usize, usize)], n: usize) -> Graph<char, f64, Undirected> {
let mut g = Graph::<char, f64, Undirected>::new_undirected();
let idx: Vec<_> = (0..n).map(|_| g.add_node('x')).collect();
for &(a, b) in edges {
g.add_edge(idx[a], idx[b], 1.5);
}
g
}
fn build_directed(edges: &[(usize, usize)], n: usize) -> Graph<(), (), Directed> {
let mut g = Graph::<(), (), Directed>::new();
let idx: Vec<_> = (0..n).map(|_| g.add_node(())).collect();
for &(a, b) in edges {
g.add_edge(idx[a], idx[b], ());
}
g
}
fn random_edges(n: usize, p: f64, seed: u64) -> Vec<(usize, usize)> {
let mut rng = StdRng::seed_from_u64(seed);
let mut e = Vec::new();
for i in 0..n {
for j in (i + 1)..n {
if rng.gen::<f64>() < p {
e.push((i, j));
}
}
}
e
}
fn path_edges(n: usize) -> Vec<(usize, usize)> {
(0..n - 1).map(|i| (i, i + 1)).collect()
}
fn cycle_edges(n: usize) -> Vec<(usize, usize)> {
let mut e = path_edges(n);
e.push((n - 1, 0));
e
}
fn star_edges(n: usize) -> Vec<(usize, usize)> {
(1..n).map(|i| (0, i)).collect()
}
fn complete_edges(n: usize) -> Vec<(usize, usize)> {
(0..n)
.flat_map(|i| ((i + 1)..n).map(move |j| (i, j)))
.collect()
}
fn sorted(c: &Census) -> Vec<(ClassId, u64)> {
let mut v: Vec<_> = c.iter().map(|(&k, &n)| (k, n)).collect();
v.sort();
v
}
fn total(c: &Census) -> u64 {
c.values().sum()
}
fn census_via_iter<G: crate::GraphAdapter>(g: G, k: usize) -> Census {
let mut c: Census = HashMap::new();
for inst in enumerate(g, &Selector::connected_k_subsets(k)) {
*c.entry(inst.class).or_insert(0) += 1;
}
c
}
#[test]
fn class_counts_match_ground_truth() {
assert_eq!(all_connected_classes(3).len(), 2);
assert_eq!(all_connected_classes(4).len(), 6);
assert_eq!(all_connected_classes(5).len(), 21);
for &(n, seed) in &[(20usize, 1u64), (24, 2), (28, 3)] {
let g = build_un(&random_edges(n, 0.5, seed), n);
for &k in &[3usize, 4, 5] {
let gt: HashSet<u64> = all_connected_classes(k).into_iter().collect();
let found: HashSet<u64> = count(&g, &Selector::connected_k_subsets(k))
.keys()
.map(|c| c.0)
.collect();
assert_eq!(
found, gt,
"dense G(n={n}) k={k} must exhibit exactly the class set"
);
}
}
}
#[test]
fn census_stable_under_relabelling() {
let n = 16;
let edges = random_edges(n, 0.5, 42);
for &k in &[3usize, 4, 5] {
let identity: Vec<usize> = (0..n).collect();
let reference = count(
&build_perm_un(&edges, n, &identity),
&Selector::connected_k_subsets(k),
);
let mut rng = StdRng::seed_from_u64(7 + k as u64);
for _ in 0..20 {
let mut order: Vec<usize> = (0..n).collect();
order.shuffle(&mut rng);
let g = build_perm_un(&edges, n, &order);
let sg = build_perm_stable(&edges, n, &order);
assert_eq!(
sorted(&count(&g, &Selector::connected_k_subsets(k))),
sorted(&reference)
);
assert_eq!(
sorted(&count(&sg, &Selector::connected_k_subsets(k))),
sorted(&reference)
);
}
}
}
#[test]
fn census_generic_over_flavours_and_iter_matches_count() {
let e = random_edges(16, 0.5, 42);
for &k in &[3usize, 4, 5] {
let ug = build_un(&e, 16);
let sg = build_perm_stable(&e, 16, &(0..16).collect::<Vec<_>>());
let wg = build_weighted(&e, 16);
let reference = count(&ug, &Selector::connected_k_subsets(k));
assert_eq!(sorted(&census_via_iter(&ug, k)), sorted(&reference));
assert_eq!(sorted(&census_via_iter(&sg, k)), sorted(&reference));
assert_eq!(sorted(&census_via_iter(&wg, k)), sorted(&reference));
}
let dg = build_directed(&e, 16);
let _ = count(&dg, &Selector::connected_k_subsets(3));
}
#[test]
fn count_streams_and_does_not_materialize() {
let n = 110;
let g = build_un(&random_edges(n, 0.14, 4), n);
let sel = Selector::connected_k_subsets(4);
reset_peak();
let before = CUR.load(Ordering::Relaxed);
let census = count(&g, &sel);
let count_peak = PEAK.load(Ordering::Relaxed).saturating_sub(before);
let instances = total(&census);
reset_peak();
let before2 = CUR.load(Ordering::Relaxed);
let collected: Vec<_> = enumerate(&g, &sel).collect();
let collect_peak = PEAK.load(Ordering::Relaxed).saturating_sub(before2);
assert_eq!(
instances as usize,
collected.len(),
"count and collect must see the same number of instances"
);
assert!(
instances > 50_000,
"test graph should have many instances (got {instances})"
);
assert!(
collect_peak > count_peak.saturating_mul(4),
"count peak ({count_peak}) must be far below collect peak ({collect_peak})"
);
}
fn combos(pool: &[usize], r: usize) -> Vec<Vec<usize>> {
let mut out = Vec::new();
fn rec(
pool: &[usize],
r: usize,
start: usize,
cur: &mut Vec<usize>,
out: &mut Vec<Vec<usize>>,
) {
if cur.len() == r {
out.push(cur.clone());
return;
}
for i in start..pool.len() {
cur.push(pool[i]);
rec(pool, r, i + 1, cur, out);
cur.pop();
}
}
rec(pool, r, 0, &mut Vec::new(), &mut out);
out
}
fn subset_connected(sub: &[usize], snap: &Snapshot<NodeIndex>) -> bool {
let m = sub.len();
let mut local = vec![Vec::new(); m];
for (i, &a) in sub.iter().enumerate() {
for (j, &b) in sub.iter().enumerate() {
if i != j && snap.adjacent(a, b) {
local[i].push(j);
}
}
}
connected(&local)
}
#[allow(clippy::needless_range_loop)]
fn gdv_oracle(snap: &Snapshot<NodeIndex>, reg: &Registry) -> Vec<Vec<u64>> {
let n = snap.len();
let mut gdv = vec![vec![0u64; reg.orbit_count()]; n];
for v in 0..n {
let others: Vec<usize> = (0..n).filter(|&x| x != v).collect();
for k in 2..=5 {
let ps = perms(k);
for rest in combos(&others, k - 1) {
let mut sub = vec![v];
sub.extend_from_slice(&rest);
if !subset_connected(&sub, snap) {
continue;
}
let (class, arg) = canonical_arg_by(k, &ps, |i, j| snap.adjacent(sub[i], sub[j]));
let slotmap = reg.slot_map(k, class);
for (c, &slot) in slotmap.iter().enumerate() {
if sub[arg[c]] == v {
gdv[v][slot] += 1;
break;
}
}
}
}
}
gdv
}
#[test]
fn gdv_matches_bruteforce_oracle() {
let reg = Registry::build();
assert_eq!(reg.orbit_count(), 73);
let mut cases: Vec<(Vec<(usize, usize)>, usize)> = vec![
(path_edges(6), 6),
(path_edges(8), 8),
(cycle_edges(5), 5),
(cycle_edges(7), 7),
(star_edges(6), 6),
(complete_edges(4), 4),
(complete_edges(5), 5),
(complete_edges(6), 6),
];
for seed in 0..8u64 {
let n = 9 + (seed as usize % 4);
cases.push((random_edges(n, 0.3, seed), n));
}
for (edges, n) in &cases {
let g = build_un(edges, *n);
let snap = Snapshot::new(&g);
let table = graphlet_degree_vectors(&g, ®);
let oracle = gdv_oracle(&snap, ®);
for (v, orow) in oracle.iter().enumerate() {
assert_eq!(table.row(v), orow.as_slice(), "node {v} GDV mismatch");
}
}
}
#[test]
fn gdv_sums_tie_to_class_census() {
let reg = Registry::build();
let g = build_un(&random_edges(14, 0.35, 99), 14);
let table = graphlet_degree_vectors(&g, ®);
let mut class_count: HashMap<(usize, u64), u64> = HashMap::new();
let snap = Snapshot::new(&g);
for k in 2..=5 {
let ps = perms(k);
for_each_subset(&snap, k, |sub| {
let class = canonical_by(k, &ps, |i, j| snap.adjacent(sub[i], sub[j]));
*class_count.entry((k, class)).or_insert(0) += 1;
});
}
for o in 0..reg.orbit_count() {
let (k, class, size) = reg.orbit_meta(o);
let sum_v: u64 = (0..table.len()).map(|v| table.row(v)[o]).sum();
let expect = class_count.get(&(k, class)).copied().unwrap_or(0) * size as u64;
assert_eq!(
sum_v, expect,
"orbit {o} sum must equal class_count * orbit_size"
);
}
}
fn class_adj(mask: u64, k: usize) -> Vec<Vec<usize>> {
class_to_adj(mask, k)
}
fn mono_labelled(padj: &[Vec<usize>], snap: &Snapshot<NodeIndex>) -> u64 {
let k = padj.len();
let n = snap.len();
fn rec(
i: usize,
k: usize,
padj: &[Vec<usize>],
n: usize,
assign: &mut Vec<usize>,
used: &mut Vec<bool>,
snap: &Snapshot<NodeIndex>,
) -> u64 {
if i == k {
return 1;
}
let mut total = 0;
for h in 0..n {
if used[h] {
continue;
}
let ok = padj[i]
.iter()
.filter(|&&nb| nb < i)
.all(|&nb| snap.adjacent(h, assign[nb]));
if !ok {
continue;
}
assign[i] = h;
used[h] = true;
total += rec(i + 1, k, padj, n, assign, used, snap);
used[h] = false;
}
total
}
rec(
0,
k,
padj,
n,
&mut vec![usize::MAX; k],
&mut vec![false; n],
snap,
)
}
#[test]
fn non_induced_counts_match_monomorphism_oracle() {
let mut hosts: Vec<(Vec<(usize, usize)>, usize)> = Vec::new();
for n in [4usize, 5, 6, 7] {
hosts.push((path_edges(n), n));
hosts.push((cycle_edges(n), n));
hosts.push((star_edges(n), n));
hosts.push((complete_edges(n), n));
}
for seed in 0..12u64 {
let n = 6 + (seed as usize % 4);
hosts.push((random_edges(n, 0.35, seed), n));
}
for k in 3..=4usize {
let ps = perms(k);
for mask in all_connected_classes(k) {
let padj = class_adj(mask, k);
let edges: Vec<(usize, usize)> = (0..k)
.flat_map(|i| ((i + 1)..k).map(move |j| (i, j)))
.filter(|&(i, j)| padj[i].contains(&j))
.collect();
let pat = Pattern::new(k, &edges);
let aut = {
let mut c = 0u64;
for perm in &ps {
let ok = (0..k).all(|i| {
padj[i]
.iter()
.filter(|&&j| j > i)
.all(|&j| padj[perm[i]].contains(&perm[j]))
});
if ok {
c += 1;
}
}
c
};
for (edges, n) in &hosts {
if *n < k {
continue;
}
let g = build_un(edges, *n);
let snap = Snapshot::new(&g);
let predicted = count_pattern(&g, &pat, Induced::No);
let oracle = mono_labelled(&padj, &snap) / aut;
assert_eq!(
predicted, oracle,
"non-induced P(mask={mask}) k={k} on host n={n}"
);
}
}
}
}
#[test]
fn diamond_catalog() {
let diamond_class = Pattern::diamond().class_id().0;
let k4_class = canonical_by(4, &perms(4), |i, j| i != j);
for (name, edges, n) in [
("diamond", vec![(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)], 4),
("K4", complete_edges(4), 4),
("K5", complete_edges(5), 5),
("C5", cycle_edges(5), 5),
("random", random_edges(10, 0.5, 3), 10),
] {
let g = build_un(&edges, n);
let census = count(&g, &Selector::connected_k_subsets(4));
let ind = census.get(&ClassId(diamond_class)).copied().unwrap_or(0);
let k4 = census.get(&ClassId(k4_class)).copied().unwrap_or(0);
assert_eq!(
count_diamonds(&g, Induced::Yes),
ind,
"{name} induced count"
);
assert_eq!(
find_diamonds(&g, Induced::Yes).len() as u64,
ind,
"{name} induced instances"
);
assert_eq!(
count_diamonds(&g, Induced::No),
ind + 6 * k4,
"{name} non-induced count"
);
assert_eq!(
find_diamonds(&g, Induced::No).len() as u64,
ind + 6 * k4,
"{name} non-induced instances"
);
}
let k4 = build_un(&complete_edges(4), 4);
assert_eq!(count_diamonds(&k4, Induced::Yes), 0);
assert_eq!(count_diamonds(&k4, Induced::No), 6);
let dia = build_un(&[(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)], 4);
assert_eq!(count_diamonds(&dia, Induced::Yes), 1);
assert_eq!(count_diamonds(&dia, Induced::No), 1);
}
fn adj_matrix(edges: &[(usize, usize)], n: usize) -> Vec<Vec<bool>> {
let mut m = vec![vec![false; n]; n];
for &(a, b) in edges {
if a != b {
m[a][b] = true;
m[b][a] = true;
}
}
m
}
fn indep_mask(m: &[Vec<bool>], sub: &[usize]) -> u64 {
let k = sub.len();
let mut best = u64::MAX;
for p in &perms(k) {
let mut mask = 0u64;
let mut bit = 0;
for i in 0..k {
for j in (i + 1)..k {
if m[sub[p[i]]][sub[p[j]]] {
mask |= 1 << bit;
}
bit += 1;
}
}
best = best.min(mask);
}
best
}
fn mat_connected(m: &[Vec<bool>], sub: &[usize]) -> bool {
let k = sub.len();
if k == 0 {
return true;
}
let mut seen = vec![false; k];
let mut stack = vec![0usize];
seen[0] = true;
let mut cnt = 1;
while let Some(x) = stack.pop() {
for y in 0..k {
if !seen[y] && m[sub[x]][sub[y]] {
seen[y] = true;
cnt += 1;
stack.push(y);
}
}
}
cnt == k
}
fn census_oracle(m: &[Vec<bool>], n: usize, k: usize) -> HashMap<u64, u64> {
let mut out: HashMap<u64, u64> = HashMap::new();
if k > n {
return out;
}
let pool: Vec<usize> = (0..n).collect();
for sub in combos(&pool, k) {
if mat_connected(m, &sub) {
*out.entry(indep_mask(m, &sub)).or_insert(0) += 1;
}
}
out
}
fn build_stable_with_holes(edges: &[(usize, usize)], n: usize) -> StableGraph<(), (), Undirected> {
let mut g = StableGraph::<(), (), Undirected>::default();
let mut real = Vec::with_capacity(n);
let mut dummies = Vec::with_capacity(n);
for _ in 0..n {
dummies.push(g.add_node(()));
real.push(g.add_node(()));
}
for &(a, b) in edges {
g.add_edge(real[a], real[b], ());
}
for d in dummies {
g.remove_node(d);
}
g
}
fn gdv_row_multiset<N: Copy>(t: &crate::GdvTable<N>) -> Vec<Vec<u64>> {
let mut rows: Vec<Vec<u64>> = (0..t.len()).map(|i| t.row(i).to_vec()).collect();
rows.sort();
rows
}
#[test]
fn stablegraph_holes_match_holefree() {
let reg = Registry::build();
let cases: Vec<(Vec<(usize, usize)>, usize)> = vec![
(complete_edges(5), 5),
(cycle_edges(6), 6),
(path_edges(7), 7),
(vec![(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)], 4), (random_edges(9, 0.4, 11), 9),
(random_edges(10, 0.5, 22), 10),
];
for (edges, n) in &cases {
let holed = build_stable_with_holes(edges, *n);
let clean = build_un(edges, *n);
for &k in &[2usize, 3, 4, 5] {
let sel = Selector::connected_k_subsets(k);
assert_eq!(
sorted(&count(&holed, &sel)),
sorted(&count(&clean, &sel)),
"holed StableGraph census mismatch n={n} k={k}"
);
}
assert_eq!(
gdv_row_multiset(&graphlet_degree_vectors(&holed, ®)),
gdv_row_multiset(&graphlet_degree_vectors(&clean, ®)),
"holed StableGraph GDV mismatch n={n}"
);
for ind in [Induced::Yes, Induced::No] {
assert_eq!(
count_diamonds(&holed, ind),
count_diamonds(&clean, ind),
"holed StableGraph diamond count mismatch n={n} {ind:?}"
);
assert_eq!(
find_diamonds(&holed, ind).len(),
find_diamonds(&clean, ind).len(),
"holed StableGraph diamond instances mismatch n={n} {ind:?}"
);
}
}
}
#[test]
#[should_panic(expected = "graphlet order k must be in 2")]
fn selector_rejects_k0() {
let _ = Selector::connected_k_subsets(0);
}
#[test]
#[should_panic(expected = "graphlet order k must be in 2")]
fn selector_rejects_k1() {
let _ = Selector::connected_k_subsets(1);
}
#[test]
#[should_panic(expected = "graphlet order k must be in 2")]
fn selector_rejects_k12_mask_overflow() {
let _ = Selector::connected_k_subsets(12);
}
#[test]
fn selector_boundary_k_values() {
assert_eq!(Selector::connected_k_subsets(2).k(), 2);
assert_eq!(
Selector::connected_k_subsets(crate::census::MAX_K).k(),
crate::census::MAX_K
);
let g = build_un(&complete_edges(6), 6);
let census = count(&g, &Selector::connected_k_subsets(6));
assert_eq!(census.values().sum::<u64>(), 1, "K6 has a single 6-subset");
}
#[test]
fn self_loops_and_parallel_edges_normalized() {
let clean = build_un(&[(0, 1), (1, 2), (2, 0)], 3);
let clean_census = sorted(&count(&clean, &Selector::connected_k_subsets(3)));
let with_loop = build_un(&[(0, 1), (1, 2), (2, 0), (0, 0)], 3);
assert_eq!(
sorted(&count(&with_loop, &Selector::connected_k_subsets(3))),
clean_census,
"self-loop must be stripped"
);
let with_parallels = build_un(&[(0, 1), (0, 1), (1, 0), (1, 2), (1, 2), (2, 0)], 3);
assert_eq!(
sorted(&count(&with_parallels, &Selector::connected_k_subsets(3))),
clean_census,
"parallel edges must be deduped"
);
let dg = build_directed(&[(0, 1), (1, 0)], 2);
assert_eq!(
count(&dg, &Selector::connected_k_subsets(2))
.values()
.sum::<u64>(),
1,
"directed reciprocal edges collapse to one"
);
}
#[test]
#[should_panic(expected = "self-loop")]
fn pattern_rejects_self_loop() {
let _ = Pattern::new(3, &[(0, 1), (1, 2), (2, 2)]);
}
#[test]
fn template_counts_raw_embeddings() {
use crate::template::count_induced_matches;
let p3 = build_un(&[(0, 1), (1, 2)], 3);
let triangle = build_un(&[(0, 1), (1, 2), (2, 0)], 3);
assert_eq!(
count_induced_matches(&p3, &triangle),
0,
"P3 not induced in K3"
);
let host_path = build_un(&[(0, 1), (1, 2)], 3);
assert_eq!(
count_induced_matches(&p3, &host_path),
2,
"P3 yields |Aut(P3)|=2 raw embeddings over one node-set"
);
assert_eq!(
count_induced_matches(&triangle, &triangle),
6,
"K3 in K3 yields |Aut(K3)|=6 raw embeddings"
);
let two_tri = build_un(&[(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3)], 6);
assert_eq!(count_induced_matches(&triangle, &two_tri), 12);
}
#[test]
fn edge_cases_across_entry_points() {
let reg = Registry::build();
let empty = build_un(&[], 0);
for &k in &[2usize, 3, 4, 5] {
assert!(count(&empty, &Selector::connected_k_subsets(k)).is_empty());
assert_eq!(
enumerate(&empty, &Selector::connected_k_subsets(k)).count(),
0
);
}
assert!(graphlet_degree_vectors(&empty, ®).is_empty());
assert_eq!(count_diamonds(&empty, Induced::Yes), 0);
assert!(find_diamonds(&empty, Induced::No).is_empty());
let single = build_un(&[], 1);
assert!(count(&single, &Selector::connected_k_subsets(2)).is_empty());
assert_eq!(graphlet_degree_vectors(&single, ®).len(), 1);
assert!(graphlet_degree_vectors(&single, ®)
.row(0)
.iter()
.all(|&x| x == 0));
let two = build_un(&[(0, 1)], 2);
assert!(count(&two, &Selector::connected_k_subsets(5)).is_empty());
assert_eq!(
count(&two, &Selector::connected_k_subsets(2))
.values()
.sum::<u64>(),
1
);
let two_tri = build_un(&[(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3)], 6);
assert_eq!(
count(&two_tri, &Selector::connected_k_subsets(3))
.values()
.sum::<u64>(),
2
);
let dtri = build_directed(&[(0, 1), (1, 2), (2, 0)], 3);
let utri = build_un(&[(0, 1), (1, 2), (2, 0)], 3);
assert_eq!(
sorted(&count(&dtri, &Selector::connected_k_subsets(3))),
sorted(&count(&utri, &Selector::connected_k_subsets(3)))
);
let holed_empty = build_stable_with_holes(&[], 3);
assert!(count(&holed_empty, &Selector::connected_k_subsets(2)).is_empty());
}
fn edges_from_bits(n: usize, bits: &[bool]) -> Vec<(usize, usize)> {
let mut e = Vec::new();
let mut idx = 0;
for i in 0..n {
for j in (i + 1)..n {
if bits.get(idx).copied().unwrap_or(false) {
e.push((i, j));
}
idx += 1;
}
}
e
}
fn graph_strategy() -> impl Strategy<Value = (usize, Vec<bool>, u64)> {
(0usize..=7).prop_flat_map(|n| {
let pairs = n * n.saturating_sub(1) / 2;
(
Just(n),
proptest::collection::vec(any::<bool>(), pairs),
any::<u64>(),
)
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(160))]
#[test]
fn prop_census_differential((n, bits, seed) in graph_strategy()) {
let edges = edges_from_bits(n, &bits);
let m = adj_matrix(&edges, n);
let g = build_un(&edges, n);
let mut order: Vec<usize> = (0..n).collect();
order.shuffle(&mut StdRng::seed_from_u64(seed));
let gp = build_perm_un(&edges, n, &order);
let holed = build_stable_with_holes(&edges, n);
for k in 2..=5usize {
let sel = Selector::connected_k_subsets(k);
let oracle = census_oracle(&m, n, k);
let by_mask: HashMap<u64, u64> = count(&g, &sel)
.into_iter()
.map(|(c, v)| (c.0, v))
.collect();
prop_assert_eq!(&by_mask, &oracle, "count vs oracle n={} k={}", n, k);
let by_iter: HashMap<u64, u64> = census_via_iter(&g, k)
.into_iter()
.map(|(c, v)| (c.0, v))
.collect();
prop_assert_eq!(&by_iter, &oracle, "enumerate vs oracle n={} k={}", n, k);
let gt: HashSet<u64> = all_connected_classes(k).into_iter().collect();
for mask in by_mask.keys() {
prop_assert!(gt.contains(mask), "spurious class {} k={}", mask, k);
}
prop_assert_eq!(sorted(&count(&gp, &sel)), sorted(&count(&g, &sel)));
prop_assert_eq!(sorted(&count(&holed, &sel)), sorted(&count(&g, &sel)));
}
}
#[test]
fn prop_gdv_differential((n, bits, _seed) in graph_strategy()) {
let reg = Registry::build();
let edges = edges_from_bits(n, &bits);
let g = build_un(&edges, n);
let snap = Snapshot::new(&g);
let table = graphlet_degree_vectors(&g, ®);
let oracle = gdv_oracle(&snap, ®);
for (v, orow) in oracle.iter().enumerate() {
prop_assert_eq!(table.row(v), orow.as_slice(), "GDV mismatch node {}", v);
}
let mut class_count: HashMap<(usize, u64), u64> = HashMap::new();
for k in 2..=5 {
let ps = perms(k);
for_each_subset(&snap, k, |sub| {
let class = canonical_by(k, &ps, |i, j| snap.adjacent(sub[i], sub[j]));
*class_count.entry((k, class)).or_insert(0) += 1;
});
}
for o in 0..reg.orbit_count() {
let (k, class, size) = reg.orbit_meta(o);
let sum_v: u64 = (0..table.len()).map(|v| table.row(v)[o]).sum();
let expect = class_count.get(&(k, class)).copied().unwrap_or(0) * size as u64;
prop_assert_eq!(sum_v, expect, "orbit {} sum mismatch", o);
}
let holed = build_stable_with_holes(&edges, n);
prop_assert_eq!(
gdv_row_multiset(&graphlet_degree_vectors(&holed, ®)),
gdv_row_multiset(&table)
);
}
#[test]
fn prop_non_induced_vs_monomorphism((n, bits, _seed) in graph_strategy()) {
let edges = edges_from_bits(n, &bits);
let g = build_un(&edges, n);
let snap = Snapshot::new(&g);
for k in 3..=5usize {
if n < k {
continue;
}
let ps = perms(k);
for mask in all_connected_classes(k) {
let padj = class_adj(mask, k);
let pedges: Vec<(usize, usize)> = (0..k)
.flat_map(|i| ((i + 1)..k).map(move |j| (i, j)))
.filter(|&(i, j)| padj[i].contains(&j))
.collect();
let pat = Pattern::new(k, &pedges);
let aut = {
let mut c = 0u64;
for perm in &ps {
let ok = (0..k).all(|i| {
padj[i]
.iter()
.filter(|&&j| j > i)
.all(|&j| padj[perm[i]].contains(&perm[j]))
});
if ok {
c += 1;
}
}
c
};
let predicted = count_pattern(&g, &pat, Induced::No);
let oracle = mono_labelled(&padj, &snap) / aut;
prop_assert_eq!(predicted, oracle, "non-induced mask={} k={}", mask, k);
}
}
}
#[test]
fn prop_template_matches_induced_census((n, bits, _seed) in graph_strategy()) {
use crate::template::count_induced_matches;
let edges = edges_from_bits(n, &bits);
let g = build_un(&edges, n);
let patterns: [(usize, Vec<(usize, usize)>); 5] = [
(3, vec![(0, 1), (1, 2)]), (3, vec![(0, 1), (1, 2), (2, 0)]), (4, vec![(0, 1), (1, 2), (2, 3)]), (4, vec![(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)]), (4, complete_edges(4)), ];
for (k, pedges) in &patterns {
if n < *k {
continue;
}
let pat_graph = build_un(pedges, *k);
let pat = Pattern::new(*k, pedges);
let ps = perms(*k);
let padj = {
let mut a = vec![Vec::new(); *k];
for &(x, y) in pedges {
if !a[x].contains(&y) {
a[x].push(y);
a[y].push(x);
}
}
a
};
let aut = ps
.iter()
.filter(|perm| {
(0..*k).all(|i| {
padj[i]
.iter()
.filter(|&&j| j > i)
.all(|&j| padj[perm[i]].contains(&perm[j]))
})
})
.count() as u64;
let raw = count_induced_matches(&pat_graph, &g) as u64;
let census = count(&g, &Selector::connected_k_subsets(*k));
let induced_nodesets = census.get(&pat.class_id()).copied().unwrap_or(0);
prop_assert_eq!(
raw / aut,
induced_nodesets,
"template raw/aut vs induced census k={}",
k
);
prop_assert_eq!(raw % aut, 0, "raw embeddings must be a multiple of |Aut|");
}
}
}
fn injective_maps(pk: usize, nh: usize, mut f: impl FnMut(&[usize])) {
fn rec(
i: usize,
pk: usize,
nh: usize,
a: &mut Vec<usize>,
u: &mut [bool],
f: &mut impl FnMut(&[usize]),
) {
if i == pk {
f(a);
return;
}
for h in 0..nh {
if u[h] {
continue;
}
a[i] = h;
u[h] = true;
rec(i + 1, pk, nh, a, u, f);
u[h] = false;
}
}
if pk > nh {
return; }
rec(0, pk, nh, &mut vec![0; pk], &mut vec![false; nh], &mut f);
}
fn dir_matrix(edges: &[(usize, usize)], n: usize) -> Vec<Vec<bool>> {
let mut m = vec![vec![false; n]; n];
for &(a, b) in edges {
if a != b {
m[a][b] = true;
}
}
m
}
fn bf_mono(pedges: &[(usize, usize)], pk: usize, hm: &[Vec<bool>], nh: usize) -> usize {
let mut c = 0usize;
injective_maps(pk, nh, |map| {
if pedges.iter().all(|&(i, j)| hm[map[i]][map[j]]) {
c += 1;
}
});
c
}
fn bf_induced(pm: &[Vec<bool>], pk: usize, hm: &[Vec<bool>], nh: usize) -> usize {
let mut c = 0usize;
injective_maps(pk, nh, |map| {
let ok = (0..pk).all(|i| (0..pk).all(|j| i == j || pm[i][j] == hm[map[i]][map[j]]));
if ok {
c += 1;
}
});
c
}
fn undirected_pattern_battery() -> Vec<(usize, Vec<(usize, usize)>)> {
vec![
(2, vec![(0, 1)]), (3, vec![(0, 1), (1, 2)]), (3, complete_edges(3)), (4, vec![(0, 1), (1, 2), (2, 3)]), (4, star_edges(4)), (4, cycle_edges(4)), (4, vec![(0, 1), (1, 2), (2, 0), (0, 3)]), (4, vec![(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)]), (4, complete_edges(4)), (5, path_edges(5)), (5, cycle_edges(5)), ]
}
fn undirected_host_battery() -> Vec<(Vec<(usize, usize)>, usize)> {
let mut hosts: Vec<(Vec<(usize, usize)>, usize)> = Vec::new();
for n in [4usize, 5, 6, 7] {
hosts.push((path_edges(n), n));
hosts.push((cycle_edges(n), n));
hosts.push((star_edges(n), n));
hosts.push((complete_edges(n), n));
}
hosts.push((
vec![
(0, 1),
(1, 2),
(2, 3),
(3, 4),
(4, 0), (5, 7),
(7, 9),
(9, 6),
(6, 8),
(8, 5), (0, 5),
(1, 6),
(2, 7),
(3, 8),
(4, 9), ],
10,
));
hosts.push((
vec![
(0, 1),
(1, 3),
(3, 2),
(2, 0), (4, 5),
(5, 7),
(7, 6),
(6, 4), (0, 4),
(1, 5),
(2, 6),
(3, 7), ],
8,
));
hosts.push((vec![(0, 2), (0, 3), (0, 4), (1, 2), (1, 3), (1, 4)], 5));
for seed in 0..12u64 {
let n = 6 + (seed as usize % 4);
hosts.push((random_edges(n, 0.35, seed), n));
}
hosts
}
#[test]
fn monomorphism_enumerator_vs_bruteforce_undirected() {
let patterns = undirected_pattern_battery();
let hosts = undirected_host_battery();
for (pk, pedges) in &patterns {
let pat = build_un(pedges, *pk);
for (hedges, n) in &hosts {
let host = build_un(hedges, *n);
let hm = adj_matrix(hedges, *n);
let oracle = bf_mono(pedges, *pk, &hm, *n);
let insts = monomorphisms_unlabelled(&pat, &host);
let cnt = count_monomorphisms_unlabelled(&pat, &host);
assert_eq!(cnt, oracle, "count k={pk} host n={n}");
assert_eq!(insts.len(), oracle, "instances k={pk} host n={n}");
for e in &insts {
assert_eq!(e.len(), *pk);
let uniq: HashSet<usize> = e.iter().copied().collect();
assert_eq!(uniq.len(), *pk, "embedding must be injective");
for &(i, j) in pedges {
assert!(
hm[e[i]][e[j]],
"embedding must preserve pattern edge ({i},{j})"
);
}
}
}
}
}
#[test]
fn monomorphism_enumerator_vs_bruteforce_directed() {
let patterns: Vec<(usize, Vec<(usize, usize)>)> = vec![
(2, vec![(0, 1)]), (3, vec![(0, 1), (1, 2)]), (3, vec![(0, 1), (1, 2), (0, 2)]), (3, vec![(0, 1), (1, 2), (2, 0)]), (4, vec![(0, 1), (0, 2), (3, 1), (3, 2)]), ];
let mut hosts: Vec<(Vec<(usize, usize)>, usize)> = vec![
(vec![(0, 1), (1, 2), (0, 2)], 3), (vec![(0, 1), (1, 2), (2, 0)], 3), (vec![(0, 1), (0, 2), (3, 1), (3, 2)], 4), ((0..5).map(|i| (i, (i + 1) % 5)).collect(), 5), ];
for seed in 0..14u64 {
let n = 5 + (seed as usize % 3);
let mut rng = StdRng::seed_from_u64(seed + 100);
let mut e = Vec::new();
for a in 0..n {
for b in 0..n {
if a != b && rng.gen::<f64>() < 0.3 {
e.push((a, b));
}
}
}
hosts.push((e, n));
}
for (pk, parcs) in &patterns {
let pat = build_directed(parcs, *pk);
for (harcs, n) in &hosts {
let host = build_directed(harcs, *n);
let hm = dir_matrix(harcs, *n);
let oracle = bf_mono(parcs, *pk, &hm, *n);
let cnt = count_monomorphisms_unlabelled(&pat, &host);
let insts = monomorphisms_unlabelled(&pat, &host);
assert_eq!(cnt, oracle, "directed count k={pk} host n={n}");
assert_eq!(insts.len(), oracle, "directed instances k={pk} host n={n}");
for e in &insts {
for &(i, j) in parcs {
assert!(
hm[e[i]][e[j]],
"directed embedding must preserve arc ({i},{j})"
);
}
}
}
}
}
#[test]
fn monomorphism_predicates_gate_matches() {
fn build_labeled_dir(
nodes: &[char],
edges: &[(usize, usize, i32)],
) -> Graph<char, i32, Directed> {
let mut g = Graph::<char, i32, Directed>::new();
let idx: Vec<_> = nodes.iter().map(|&c| g.add_node(c)).collect();
for &(a, b, w) in edges {
g.add_edge(idx[a], idx[b], w);
}
g
}
let host = build_labeled_dir(&['x', 'y', 'x'], &[(0, 1, 10), (1, 2, 20)]);
let pat = build_labeled_dir(&['x', 'y'], &[(0, 1, 10)]);
assert_eq!(
count_monomorphisms(&pat, &host, |_, _| true, |_, _| true),
2
);
let node_eq = |p: &char, h: &char| p == h;
assert_eq!(count_monomorphisms(&pat, &host, node_eq, |_, _| true), 1);
let edge_eq = |p: &i32, h: &i32| p == h;
assert_eq!(count_monomorphisms(&pat, &host, node_eq, edge_eq), 1);
let pat20 = build_labeled_dir(&['x', 'y'], &[(0, 1, 20)]);
assert_eq!(count_monomorphisms(&pat20, &host, node_eq, edge_eq), 0);
let insts = monomorphisms(&pat, &host, node_eq, edge_eq);
assert_eq!(insts, vec![vec![0, 1]]);
}
#[test]
fn monomorphism_cross_check_spc() {
let hosts = undirected_host_battery();
for k in 3..=5usize {
let ps = perms(k);
for mask in all_connected_classes(k) {
let padj = class_adj(mask, k);
let pedges: Vec<(usize, usize)> = (0..k)
.flat_map(|i| ((i + 1)..k).map(move |j| (i, j)))
.filter(|&(i, j)| padj[i].contains(&j))
.collect();
let pat = Pattern::new(k, &pedges);
let pat_g = build_un(&pedges, k);
let aut = ps
.iter()
.filter(|perm| {
(0..k).all(|i| {
padj[i]
.iter()
.filter(|&&j| j > i)
.all(|&j| padj[perm[i]].contains(&perm[j]))
})
})
.count() as u64;
for (hedges, n) in &hosts {
if *n < k {
continue;
}
let host = build_un(hedges, *n);
let hm = adj_matrix(hedges, *n);
let raw = count_monomorphisms_unlabelled(&pat_g, &host) as u64;
assert_eq!(raw, bf_mono(&pedges, k, &hm, *n) as u64, "raw vs oracle");
assert_eq!(
raw / aut,
count_pattern(&host, &pat, Induced::No),
"enumerator vs s(P,C) mask={mask} k={k} n={n}"
);
}
}
}
}
#[test]
fn induced_matches_vs_bruteforce() {
let patterns = undirected_pattern_battery();
let hosts = undirected_host_battery();
for (pk, pedges) in &patterns {
let pat = build_un(pedges, *pk);
let pm = adj_matrix(pedges, *pk);
for (hedges, n) in &hosts {
let host = build_un(hedges, *n);
let hm = adj_matrix(hedges, *n);
assert_eq!(
count_induced_matches(&pat, &host),
bf_induced(&pm, *pk, &hm, *n),
"induced VF2 vs oracle k={pk} n={n}"
);
}
}
}
#[test]
fn find_motif_matches_count_and_is_valid() {
let named: Vec<(usize, Vec<(usize, usize)>)> = undirected_pattern_battery();
let hosts = undirected_host_battery();
for (pk, pedges) in &named {
let pat = Pattern::new(*pk, pedges);
for (hedges, n) in &hosts {
let g = build_un(hedges, *n);
let hm = adj_matrix(hedges, *n);
for ind in [Induced::Yes, Induced::No] {
let insts = find_motif(&g, &pat, ind);
let cnt = count_motif(&g, &pat, ind);
assert_eq!(
insts.len() as u64,
cnt,
"find vs count k={pk} n={n} {ind:?}"
);
let mut nodesets: HashSet<Vec<usize>> = HashSet::new();
for m in &insts {
let idxs: Vec<usize> = m.iter().map(|nid| nid.index()).collect();
let uniq: HashSet<usize> = idxs.iter().copied().collect();
assert_eq!(uniq.len(), *pk, "occurrence nodes distinct");
for &(i, j) in pedges {
assert!(hm[idxs[i]][idxs[j]], "occurrence preserves edge");
}
if ind == Induced::Yes {
let mut s = idxs.clone();
s.sort_unstable();
assert!(nodesets.insert(s), "induced occurrences distinct node-sets");
}
}
}
}
}
}
#[test]
fn named_motifs_and_catalog() {
assert_eq!(Pattern::path(5).order(), 5);
assert_eq!(Pattern::cycle(4).order(), 4);
assert_eq!(Pattern::star(5).order(), 5);
assert_eq!(Pattern::complete(4).order(), 4);
assert_eq!(Pattern::paw().order(), 4);
assert_eq!(
Pattern::triangle().class_id(),
Pattern::complete(3).class_id()
);
assert_eq!(Pattern::claw().class_id(), Pattern::star(4).class_id());
assert_eq!(
Pattern::diamond().class_id(),
Pattern::new(4, &[(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)]).class_id()
);
let four: Vec<ClassId> = [
Pattern::path(4),
Pattern::claw(),
Pattern::cycle(4),
Pattern::paw(),
Pattern::diamond(),
Pattern::complete(4),
]
.iter()
.map(Pattern::class_id)
.collect();
let uniq: HashSet<ClassId> = four.iter().copied().collect();
assert_eq!(uniq.len(), 6, "the six 4-node motifs are distinct classes");
let c4 = build_un(&cycle_edges(4), 4);
assert_eq!(count_motif(&c4, &Pattern::cycle(4), Induced::Yes), 1);
assert_eq!(count_motif(&c4, &Pattern::complete(4), Induced::Yes), 0);
let s5 = build_un(&star_edges(5), 5);
assert_eq!(count_motif(&s5, &Pattern::claw(), Induced::Yes), 4);
let mut cat = MotifCatalog::standard();
assert_eq!(cat.len(), 12);
assert!(cat.get("diamond").is_some());
assert!(cat.get("nope").is_none());
assert_eq!(
cat.get("triangle").unwrap().class_id(),
Pattern::triangle().class_id()
);
let prev = cat.register("mine", Pattern::cycle(5));
assert!(prev.is_none());
assert_eq!(cat.len(), 13);
assert_eq!(
cat.get("mine").unwrap().class_id(),
Pattern::cycle(5).class_id()
);
let names = cat.names();
assert!(names.windows(2).all(|w| w[0] <= w[1]), "names sorted");
assert!(names.contains(&"mine") && names.contains(&"k5"));
let empty = MotifCatalog::new();
assert!(empty.is_empty());
}
#[test]
fn motif_engine_edge_cases() {
let k4_pat = build_un(&complete_edges(4), 4);
let triangle = build_un(&complete_edges(3), 3);
assert_eq!(count_monomorphisms_unlabelled(&k4_pat, &triangle), 0);
assert!(monomorphisms_unlabelled(&k4_pat, &triangle).is_empty());
let dia = Pattern::diamond();
let tri_host = build_un(&complete_edges(3), 3);
assert!(find_motif(&tri_host, &dia, Induced::No).is_empty());
assert_eq!(count_motif(&tri_host, &dia, Induced::Yes), 0);
let empty: UnGraph<(), ()> = build_un(&[], 0);
let p3 = build_un(&[(0, 1), (1, 2)], 3);
assert_eq!(count_monomorphisms_unlabelled(&p3, &empty), 0);
assert!(find_motif(&empty, &Pattern::path(3), Induced::No).is_empty());
let single = build_un(&[], 1);
let edge = build_un(&[(0, 1)], 2);
assert_eq!(count_monomorphisms_unlabelled(&edge, &single), 0);
let two_tri = build_un(&[(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3)], 6);
let tri_pat = build_un(&complete_edges(3), 3);
assert_eq!(count_monomorphisms_unlabelled(&tri_pat, &two_tri), 12);
assert_eq!(
find_motif(&two_tri, &Pattern::triangle(), Induced::Yes).len(),
2
);
assert_eq!(
find_motif(&two_tri, &Pattern::triangle(), Induced::No).len(),
2
);
let mut loop_pat = Graph::<(), (), Undirected>::new_undirected();
let lp = loop_pat.add_node(());
loop_pat.add_edge(lp, lp, ());
let mut host_loop = Graph::<(), (), Undirected>::new_undirected();
let a = host_loop.add_node(());
let b = host_loop.add_node(());
host_loop.add_edge(a, a, ());
host_loop.add_edge(a, b, ());
assert_eq!(count_monomorphisms_unlabelled(&loop_pat, &host_loop), 1);
assert_eq!(
monomorphisms_unlabelled(&loop_pat, &host_loop),
vec![vec![0]]
);
}
fn dir_edges_from_bits(n: usize, bits: &[bool]) -> Vec<(usize, usize)> {
let mut e = Vec::new();
let mut idx = 0;
for i in 0..n {
for j in 0..n {
if i != j {
if bits.get(idx).copied().unwrap_or(false) {
e.push((i, j));
}
idx += 1;
}
}
}
e
}
fn dir_graph_strategy() -> impl Strategy<Value = (usize, Vec<bool>)> {
(0usize..=6).prop_flat_map(|n| {
let pairs = n * n.saturating_sub(1);
(Just(n), proptest::collection::vec(any::<bool>(), pairs))
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(120))]
#[test]
fn prop_monomorphism_differential_undirected((n, bits, _s) in graph_strategy()) {
let hedges = edges_from_bits(n, &bits);
let hm = adj_matrix(&hedges, n);
let host = build_un(&hedges, n);
for (pk, pedges) in undirected_pattern_battery() {
if pk > n {
prop_assert_eq!(count_monomorphisms_unlabelled(&build_un(&pedges, pk), &host), 0);
continue;
}
let pat = build_un(&pedges, pk);
let oracle = bf_mono(&pedges, pk, &hm, n);
let cnt = count_monomorphisms_unlabelled(&pat, &host);
let insts = monomorphisms_unlabelled(&pat, &host);
prop_assert_eq!(cnt, oracle, "count k={} n={}", pk, n);
prop_assert_eq!(insts.len(), oracle, "instances k={} n={}", pk, n);
}
}
#[test]
fn prop_monomorphism_differential_directed((n, bits) in dir_graph_strategy()) {
let harcs = dir_edges_from_bits(n, &bits);
let hm = dir_matrix(&harcs, n);
let host = build_directed(&harcs, n);
let patterns: Vec<(usize, Vec<(usize, usize)>)> = vec![
(2, vec![(0, 1)]),
(3, vec![(0, 1), (1, 2)]),
(3, vec![(0, 1), (1, 2), (0, 2)]), (3, vec![(0, 1), (1, 2), (2, 0)]), (4, vec![(0, 1), (0, 2), (3, 1), (3, 2)]), ];
for (pk, parcs) in patterns {
if pk > n {
continue;
}
let pat = build_directed(&parcs, pk);
prop_assert_eq!(
count_monomorphisms_unlabelled(&pat, &host),
bf_mono(&parcs, pk, &hm, n),
"directed k={} n={}", pk, n
);
}
}
#[test]
fn prop_find_motif_matches_count((n, bits, _s) in graph_strategy()) {
let edges = edges_from_bits(n, &bits);
let g = build_un(&edges, n);
for (pk, pedges) in undirected_pattern_battery() {
let pat = Pattern::new(pk, &pedges);
for ind in [Induced::Yes, Induced::No] {
let insts = find_motif(&g, &pat, ind);
prop_assert_eq!(insts.len() as u64, count_motif(&g, &pat, ind),
"k={} n={} {:?}", pk, n, ind);
if ind == Induced::Yes {
let mut sets: HashSet<Vec<usize>> = HashSet::new();
for m in &insts {
let mut s: Vec<usize> = m.iter().map(|nid| nid.index()).collect();
s.sort_unstable();
prop_assert!(sets.insert(s), "induced node-sets distinct");
}
}
}
}
}
}
fn stub_degree_of(g: &UnGraph<(), ()>, i: usize) -> usize {
use petgraph::visit::EdgeRef as _;
g.edge_references()
.map(|e| {
let a = e.source().index();
let b = e.target().index();
if a == b {
if a == i {
2
} else {
0
}
} else {
usize::from(a == i) + usize::from(b == i)
}
})
.sum()
}
fn simple_degree(g: &UnGraph<(), ()>, i: usize) -> usize {
g.edges(NodeIndex::new(i)).count()
}
fn no_self_loops(g: &UnGraph<(), ()>) -> bool {
use petgraph::visit::EdgeRef as _;
g.edge_references()
.all(|e| e.source().index() != e.target().index())
}
fn no_parallel_edges(g: &UnGraph<(), ()>) -> bool {
use petgraph::visit::EdgeRef as _;
let mut seen: HashSet<(usize, usize)> = HashSet::new();
for e in g.edge_references() {
let a = e.source().index();
let b = e.target().index();
if a == b {
continue;
}
let k = if a < b { (a, b) } else { (b, a) };
if !seen.insert(k) {
return false;
}
}
true
}
fn edge_set_sorted(g: &UnGraph<(), ()>) -> Vec<(usize, usize)> {
use petgraph::visit::EdgeRef as _;
let mut v: Vec<(usize, usize)> = g
.edge_references()
.map(|e| {
let a = e.source().index();
let b = e.target().index();
if a < b {
(a, b)
} else {
(b, a)
}
})
.collect();
v.sort_unstable();
v
}
#[test]
fn config_model_raw_exact_degrees() {
let mut rng = StdRng::seed_from_u64(1);
for deg_seq in &[
vec![2usize, 2, 2, 2],
vec![1, 1, 2, 2],
vec![3, 3, 2, 2, 2, 2],
vec![4, 4, 4, 4, 4, 4],
vec![1, 1, 1, 1, 1, 1, 1, 1],
] {
let g = configuration_model(deg_seq, &mut rng);
assert_eq!(g.node_count(), deg_seq.len(), "node count");
assert_eq!(
g.edge_count(),
deg_seq.iter().sum::<usize>() / 2,
"edge count"
);
for (i, &d) in deg_seq.iter().enumerate() {
assert_eq!(stub_degree_of(&g, i), d, "stub-degree mismatch at node {i}");
}
}
}
#[test]
#[should_panic(expected = "even stub sum")]
fn config_model_odd_sum_panics() {
let mut rng = StdRng::seed_from_u64(0);
let _ = configuration_model(&[1, 2], &mut rng); }
#[test]
fn config_model_simple_no_loops_no_parallel() {
let mut rng = StdRng::seed_from_u64(2);
for deg_seq in &[
vec![2usize, 2, 2, 2],
vec![3, 3, 3, 3, 3, 3],
vec![1, 1, 2, 2, 2, 2],
vec![4, 4, 4, 4, 4, 4, 4, 4],
] {
let g = configuration_model_simple(deg_seq, &mut rng);
assert_eq!(g.node_count(), deg_seq.len(), "node count");
assert!(no_self_loops(&g), "no self-loops");
assert!(no_parallel_edges(&g), "no parallel edges");
for (i, &d) in deg_seq.iter().enumerate() {
assert!(
simple_degree(&g, i) <= d,
"realized degree > requested degree at node {i}"
);
}
}
}
#[test]
fn des_degree_preservation() {
let mut rng = StdRng::seed_from_u64(3);
let n = 12;
let edges = random_edges(n, 0.4, 7);
let g = build_un(&edges, n);
let orig_degrees: Vec<usize> = (0..n).map(|i| simple_degree(&g, i)).collect();
let swapped = double_edge_swap(&g, 200, &mut rng);
let new_degrees: Vec<usize> = (0..n).map(|i| simple_degree(&swapped, i)).collect();
assert_eq!(new_degrees, orig_degrees, "degrees must be preserved");
}
#[test]
fn des_no_self_loops_or_parallel() {
let n = 16;
let edges = random_edges(n, 0.4, 8);
let g = build_un(&edges, n);
for swaps in [50, 200, 500] {
let mut r = StdRng::seed_from_u64(swaps as u64);
let swapped = double_edge_swap(&g, swaps, &mut r);
assert!(no_self_loops(&swapped), "no self-loops after {swaps} swaps");
assert!(
no_parallel_edges(&swapped),
"no parallel edges after {swaps} swaps"
);
}
}
#[test]
fn des_edge_count_preserved() {
let mut rng = StdRng::seed_from_u64(5);
let n = 14;
let edges = random_edges(n, 0.35, 9);
let g = build_un(&edges, n);
let m = g.edge_count();
let swapped = double_edge_swap(&g, 300, &mut rng);
assert_eq!(swapped.edge_count(), m, "edge count preserved");
}
#[test]
fn des_mixing_evidence() {
let n = 20;
let edges = random_edges(n, 0.4, 11);
let g = build_un(&edges, n);
let orig = edge_set_sorted(&g);
let mut rng = StdRng::seed_from_u64(42);
let swapped = double_edge_swap(&g, 500, &mut rng);
let new = edge_set_sorted(&swapped);
assert_ne!(orig, new, "edge set must change after sufficient swaps");
}
#[test]
fn des_empty_and_single_edge_passthrough() {
let mut rng = StdRng::seed_from_u64(6);
let empty: UnGraph<(), ()> = build_un(&[], 0);
let r = double_edge_swap(&empty, 10, &mut rng);
assert_eq!(r.node_count(), 0);
assert_eq!(r.edge_count(), 0);
let single = build_un(&[(0, 1)], 2);
let r2 = double_edge_swap(&single, 10, &mut rng);
assert_eq!(r2.edge_count(), 1);
}
#[test]
fn ws_node_and_edge_count() {
let mut rng = StdRng::seed_from_u64(7);
for &(n, k) in &[(10usize, 4usize), (20, 6), (50, 4), (100, 8)] {
let g = watts_strogatz(n, k, 0.3, &mut rng);
assert_eq!(g.node_count(), n, "node count n={n} k={k}");
assert_eq!(g.edge_count(), n * k / 2, "edge count n={n} k={k}");
}
}
#[test]
fn ws_p0_is_pure_ring_lattice() {
let n = 20;
let k = 4;
let mut rng = StdRng::seed_from_u64(8);
let g = watts_strogatz(n, k, 0.0, &mut rng);
assert_eq!(g.node_count(), n);
assert_eq!(g.edge_count(), n * k / 2);
let half_k = k / 2;
for i in 0..n {
for j in 1..=half_k {
let nb = (i + j) % n;
assert!(
g.contains_edge(NodeIndex::new(i), NodeIndex::new(nb)),
"ring lattice edge ({i},{nb}) missing"
);
}
}
assert!(no_self_loops(&g));
assert!(no_parallel_edges(&g));
}
#[test]
fn ws_simple_at_all_p() {
let mut rng = StdRng::seed_from_u64(9);
let n = 30;
let k = 4;
for &p in &[0.0, 0.1, 0.5, 1.0] {
let g = watts_strogatz(n, k, p, &mut rng);
assert_eq!(g.node_count(), n, "p={p}");
assert_eq!(g.edge_count(), n * k / 2, "edge count p={p}");
assert!(no_self_loops(&g), "self-loop at p={p}");
assert!(no_parallel_edges(&g), "parallel edge at p={p}");
}
}
#[test]
fn ws_p1_not_ring() {
let n = 40;
let k = 4;
let half_k = k / 2;
let mut found_non_ring = false;
for seed in 0..20u64 {
let mut rng = StdRng::seed_from_u64(seed + 100);
let g = watts_strogatz(n, k, 1.0, &mut rng);
let any_missing = (0..n).any(|i| {
(1..=half_k).any(|j| {
let nb = (i + j) % n;
!g.contains_edge(NodeIndex::new(i), NodeIndex::new(nb))
})
});
if any_missing {
found_non_ring = true;
break;
}
}
assert!(found_non_ring, "p=1 graph should not be a ring lattice");
}
#[test]
#[should_panic(expected = "k must be even")]
fn ws_odd_k_panics() {
let mut rng = StdRng::seed_from_u64(0);
let _ = watts_strogatz(10, 3, 0.1, &mut rng);
}
#[test]
#[should_panic(expected = "n must be greater than k")]
fn ws_n_le_k_panics() {
let mut rng = StdRng::seed_from_u64(0);
let _ = watts_strogatz(4, 4, 0.1, &mut rng);
}
#[test]
fn lfr_node_and_community_count() {
let mut rng = StdRng::seed_from_u64(10);
let n = 100;
let (g, community) = lfr_benchmark(n, 5.0, 15, 0.1, 2.5, 1.5, 10, 50, &mut rng);
assert_eq!(g.node_count(), n, "node count");
assert_eq!(community.len(), n, "community vec length");
}
#[test]
fn lfr_community_labels_in_range() {
let mut rng = StdRng::seed_from_u64(11);
let n = 80;
let (_, community) = lfr_benchmark(n, 4.0, 12, 0.2, 2.5, 1.5, 8, 30, &mut rng);
let max_label = *community.iter().max().unwrap();
assert!(max_label < n, "community labels must be < n");
assert_eq!(community.len(), n);
}
#[test]
fn lfr_degree_distribution_sanity() {
let mut rng = StdRng::seed_from_u64(12);
let n = 200;
let max_degree = 20;
let avg_degree = 6.0;
let (g, _) = lfr_benchmark(n, avg_degree, max_degree, 0.1, 2.5, 1.5, 10, 60, &mut rng);
assert_eq!(g.node_count(), n);
let degrees: Vec<usize> = (0..n).map(|i| g.edges(NodeIndex::new(i)).count()).collect();
assert!(
degrees.iter().all(|&d| d <= max_degree * 2),
"some degree exceeds 2*max_degree (LFR allows degree growth from external matching)"
);
let mean: f64 = degrees.iter().sum::<usize>() as f64 / n as f64;
assert!(mean > 0.5, "mean degree must be positive, got {mean}");
}
#[test]
fn lfr_mixing_fraction_roughly_mu() {
let mu = 0.15f64;
let mut rng = StdRng::seed_from_u64(13);
let n = 200;
let (g, community) = lfr_benchmark(n, 6.0, 20, mu, 2.5, 1.5, 10, 60, &mut rng);
use petgraph::visit::EdgeRef as _;
let mut external_fractions: Vec<f64> = Vec::new();
for i in 0..n {
let deg = g.edges(NodeIndex::new(i)).count();
if deg == 0 {
continue;
}
let external = g
.edges(NodeIndex::new(i))
.filter(|e| {
let nb = if e.source().index() == i {
e.target().index()
} else {
e.source().index()
};
community[nb] != community[i]
})
.count();
external_fractions.push(external as f64 / deg as f64);
}
if !external_fractions.is_empty() {
let realized_mu = external_fractions.iter().sum::<f64>() / external_fractions.len() as f64;
assert!(
realized_mu < mu + 0.25,
"realized mixing {realized_mu:.3} much higher than mu={mu}"
);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(200))]
#[test]
fn prop_des_degree_preservation((n, bits, seed) in graph_strategy()) {
let edges = edges_from_bits(n, &bits);
let g = build_un(&edges, n);
let orig_deg: Vec<usize> = (0..n).map(|i| simple_degree(&g, i)).collect();
let n_swaps = n.saturating_mul(5).max(10);
let mut rng = StdRng::seed_from_u64(seed);
let swapped = double_edge_swap(&g, n_swaps, &mut rng);
let new_deg: Vec<usize> = (0..n).map(|i| simple_degree(&swapped, i)).collect();
prop_assert_eq!(new_deg, orig_deg, "degree preservation n={}", n);
}
#[test]
fn prop_des_simple_output((n, bits, seed) in graph_strategy()) {
let edges = edges_from_bits(n, &bits);
let g = build_un(&edges, n);
let mut rng = StdRng::seed_from_u64(seed);
let swapped = double_edge_swap(&g, 50, &mut rng);
prop_assert!(no_self_loops(&swapped), "self-loop in output n={}", n);
prop_assert!(no_parallel_edges(&swapped), "parallel edge in output n={}", n);
}
}
fn even_degree_seq_strategy() -> impl Strategy<Value = Vec<usize>> {
proptest::collection::vec(1usize..=6, 2..=8).prop_map(|mut v| {
if v.iter().sum::<usize>() % 2 != 0 {
v[0] += 1; }
v
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(300))]
#[test]
fn prop_config_model_raw_exact_stubs(deg_seq in even_degree_seq_strategy(), seed: u64) {
let mut rng = StdRng::seed_from_u64(seed);
let g = configuration_model(°_seq, &mut rng);
prop_assert_eq!(g.node_count(), deg_seq.len());
prop_assert_eq!(g.edge_count(), deg_seq.iter().sum::<usize>() / 2);
for (i, &d) in deg_seq.iter().enumerate() {
prop_assert_eq!(
stub_degree_of(&g, i), d,
"stub-degree mismatch at node {} (deg_seq={:?})", i, deg_seq
);
}
}
#[test]
fn prop_config_model_simple_valid(deg_seq in even_degree_seq_strategy(), seed: u64) {
let mut rng = StdRng::seed_from_u64(seed);
let g = configuration_model_simple(°_seq, &mut rng);
prop_assert_eq!(g.node_count(), deg_seq.len());
prop_assert!(no_self_loops(&g), "self-loop in simple variant");
prop_assert!(no_parallel_edges(&g), "parallel edge in simple variant");
for (i, &d) in deg_seq.iter().enumerate() {
prop_assert!(
simple_degree(&g, i) <= d,
"realized degree {} > requested {} at node {}",
simple_degree(&g, i), d, i
);
}
}
}
use crate::rim::significance::{
census_significance_profile, compute_significance_stats, motif_significance, NullModel,
SignificanceEntry,
};
#[allow(clippy::too_many_arguments)]
fn assert_sig_entry(
entry: &SignificanceEntry,
observed: u64,
null_mean: f64,
null_std: f64,
z_score: f64,
p_value_over: f64,
eps: f64,
label: &str,
) {
assert_eq!(entry.observed, observed, "{label}: observed");
assert!(
(entry.null_mean - null_mean).abs() < eps,
"{label}: null_mean got {} expected {null_mean}",
entry.null_mean
);
assert!(
(entry.null_std - null_std).abs() < eps,
"{label}: null_std got {} expected {null_std}",
entry.null_std
);
if z_score.is_infinite() {
assert_eq!(
entry.z_score.is_sign_positive(),
z_score.is_sign_positive(),
"{label}: z_score sign"
);
assert!(
entry.z_score.is_infinite(),
"{label}: z_score must be infinite"
);
} else {
assert!(
(entry.z_score - z_score).abs() < eps,
"{label}: z_score got {} expected {z_score}",
entry.z_score
);
}
assert!(
(entry.p_value_over - p_value_over).abs() < eps,
"{label}: p_value_over got {} expected {p_value_over}",
entry.p_value_over
);
}
#[test]
fn sig_mechanics_hand_computed() {
let eps = 1e-12_f64;
{
let nulls: Vec<u64> = vec![3, 5, 5, 7];
let e = compute_significance_stats(5, &nulls);
let sqrt2 = 2.0_f64.sqrt();
assert_sig_entry(&e, 5, 5.0, sqrt2, 0.0, 0.75, eps, "case1");
}
{
let nulls: Vec<u64> = vec![3, 5, 5, 7];
let e = compute_significance_stats(8, &nulls);
let sqrt2 = 2.0_f64.sqrt();
let z = 3.0 / sqrt2;
assert_sig_entry(&e, 8, 5.0, sqrt2, z, 0.0, eps, "case2");
}
{
let nulls: Vec<u64> = vec![3, 3, 3, 3];
let e = compute_significance_stats(3, &nulls);
assert_sig_entry(&e, 3, 3.0, 0.0, 0.0, 1.0, eps, "case3-z0");
}
{
let nulls: Vec<u64> = vec![3, 3, 3, 3];
let e = compute_significance_stats(5, &nulls);
assert_sig_entry(&e, 5, 3.0, 0.0, f64::INFINITY, 0.0, eps, "case4-z+inf");
}
{
let nulls: Vec<u64> = vec![3, 3, 3, 3];
let e = compute_significance_stats(2, &nulls);
assert!(
e.z_score.is_infinite() && e.z_score.is_sign_negative(),
"case5: z=-inf"
);
assert!((e.p_value_over - 1.0).abs() < eps, "case5: p_over=1");
}
{
let e = compute_significance_stats(7, &[7]);
assert_sig_entry(&e, 7, 7.0, 0.0, 0.0, 1.0, eps, "case6-single");
}
{
let e = compute_significance_stats(0, &[7]);
assert!(
e.z_score.is_infinite() && e.z_score.is_sign_negative(),
"case7: z=-inf"
);
assert!((e.p_value_over - 1.0).abs() < eps, "case7: p_over=1");
}
{
let nulls: Vec<u64> = vec![3, 5, 7, 5];
let e = compute_significance_stats(5, &nulls);
assert!((e.p_value_over - 0.75).abs() < eps, "case8: tie p_over");
}
}
#[test]
fn sig_planted_triangles_over_represented() {
let g: UnGraph<(), ()> = build_un(
&[
(0, 1),
(1, 2),
(2, 0),
(3, 4),
(4, 5),
(5, 3),
(6, 7),
(7, 8),
(8, 6),
(9, 10),
(10, 11),
(11, 9),
(12, 13),
(13, 14),
(14, 12),
],
15,
);
let tri = Pattern::triangle();
let p3 = Pattern::path(3);
let mut rng = StdRng::seed_from_u64(42);
let results = motif_significance(
&g,
&[("triangle", &tri, Induced::Yes), ("p3", &p3, Induced::Yes)],
100,
NullModel::DegreePreserving {
n_swaps_per_edge: 10,
},
&mut rng,
);
assert_eq!(results.len(), 2);
let tri_entry = &results[0].1;
let p3_entry = &results[1].1;
assert_eq!(tri_entry.observed, 5, "5 disjoint triangles");
assert_eq!(
p3_entry.observed, 0,
"no induced P3 in K_3 components (every triple is a triangle)"
);
assert!(
tri_entry.z_score > 2.0,
"triangle z-score should be large positive, got {}",
tri_entry.z_score
);
assert!(
p3_entry.z_score < 0.0,
"P3 z-score should be negative (under-represented), got {}",
p3_entry.z_score
);
assert!(
tri_entry.z_score > p3_entry.z_score,
"triangle z-score ({}) should exceed P3 z-score ({})",
tri_entry.z_score,
p3_entry.z_score
);
assert!((0.0..=1.0).contains(&tri_entry.p_value_over));
assert!((0.0..=1.0).contains(&p3_entry.p_value_over));
}
#[test]
fn sig_planted_triangles_config_model_null() {
let mut edges = complete_edges(5); for i in 3..8usize {
for j in (i + 1)..8 {
edges.push((i, j));
}
}
let g = build_un(&edges, 8);
let tri = Pattern::triangle();
let mut rng = StdRng::seed_from_u64(99);
let results = motif_significance(
&g,
&[("triangle", &tri, Induced::Yes)],
80,
NullModel::ConfigurationModel,
&mut rng,
);
let entry = &results[0].1;
assert!(entry.observed > 0, "two overlapping K5s have triangles");
assert!(
entry.z_score >= 0.0 || entry.null_std == 0.0,
"triangle z-score should be non-negative for clique vs config null, got {}",
entry.z_score
);
assert!((0.0..=1.0).contains(&entry.p_value_over));
}
#[test]
fn sig_determinism_same_seed() {
let g: UnGraph<(), ()> = build_un(&random_edges(20, 0.3, 7), 20);
let tri = Pattern::triangle();
let run = |seed: u64| {
let mut rng = StdRng::seed_from_u64(seed);
motif_significance(
&g,
&[("triangle", &tri, Induced::Yes)],
50,
NullModel::DegreePreserving {
n_swaps_per_edge: 10,
},
&mut rng,
)
};
let r1 = run(123);
let r2 = run(123);
let r3 = run(456);
assert_eq!(
r1[0].1.null_mean, r2[0].1.null_mean,
"same seed → same mean"
);
assert_eq!(r1[0].1.null_std, r2[0].1.null_std, "same seed → same std");
assert_eq!(r1[0].1.z_score, r2[0].1.z_score, "same seed → same z-score");
assert_eq!(
r1[0].1.p_value_over, r2[0].1.p_value_over,
"same seed → same p-value"
);
assert_eq!(
r1[0].1.observed, r3[0].1.observed,
"observed independent of seed"
);
}
#[test]
fn sig_census_profile_structure() {
use crate::canonical::all_connected_classes;
let g: UnGraph<(), ()> = build_un(&random_edges(15, 0.4, 5), 15);
let mut rng = StdRng::seed_from_u64(77);
let profile = census_significance_profile(
&g,
3,
50,
NullModel::DegreePreserving {
n_swaps_per_edge: 10,
},
&mut rng,
false,
);
assert_eq!(profile.entries.len(), 2, "k=3 has exactly 2 classes");
assert_eq!(profile.z_scores.len(), 2);
assert!(profile.normalized.is_none());
let gt: Vec<u64> = {
let mut v = all_connected_classes(3);
v.sort_unstable();
v
};
for (i, (cid, _)) in profile.entries.iter().enumerate() {
assert_eq!(cid.0, gt[i], "class id at position {i}");
}
for z in &profile.z_scores {
assert!(!z.is_nan(), "z-score must not be NaN");
}
for (_, e) in &profile.entries {
assert!((0.0..=1.0).contains(&e.p_value_over));
}
}
#[test]
fn sig_census_profile_normalization_unit_length() {
let g: UnGraph<(), ()> = build_un(&random_edges(18, 0.35, 88), 18);
let mut rng = StdRng::seed_from_u64(55);
let profile = census_significance_profile(
&g,
3,
60,
NullModel::DegreePreserving {
n_swaps_per_edge: 10,
},
&mut rng,
true,
);
let norm_vec = profile.normalized.as_ref().expect("normalized requested");
assert_eq!(norm_vec.len(), profile.z_scores.len());
let norm_sq: f64 = norm_vec
.iter()
.filter(|z| z.is_finite())
.map(|&z| z * z)
.sum();
let norm = norm_sq.sqrt();
let z_all_zero = profile.z_scores.iter().all(|&z| z == 0.0);
if z_all_zero {
assert_eq!(norm, 0.0, "all-zero z-scores → zero normalized vector");
} else {
assert!(
(norm - 1.0).abs() < 1e-10,
"normalized z-score vector must have unit length, got norm={norm}"
);
}
}
#[test]
fn sig_near_zero_for_null_graph() {
let base: UnGraph<(), ()> = build_un(&random_edges(30, 0.2, 11), 30);
let mut rng_prep = StdRng::seed_from_u64(314);
let null_graph = double_edge_swap(&base, 2000, &mut rng_prep);
let mut rng = StdRng::seed_from_u64(999);
let profile = census_significance_profile(
&null_graph,
3,
200,
NullModel::DegreePreserving {
n_swaps_per_edge: 10,
},
&mut rng,
false,
);
for (i, &z) in profile.z_scores.iter().enumerate() {
if z.is_finite() {
assert!(
z.abs() < 4.0,
"k=3 class[{i}] z-score={z:.3} should be near 0 for a null-graph sample"
);
}
}
}
#[test]
fn sig_census_profile_normalize_no_nan_on_infinite_zscore() {
let mut edges: Vec<(usize, usize)> = Vec::new();
for i in 0usize..5 {
for j in (i + 1)..5 {
edges.push((i, j));
}
}
edges.extend([(4, 5), (5, 6), (6, 7), (7, 8), (8, 9)]);
let g: UnGraph<(), ()> = build_un(&edges, 10);
let mut rng = StdRng::seed_from_u64(2024);
let profile =
census_significance_profile(&g, 5, 20, NullModel::ConfigurationModel, &mut rng, true);
assert!(
profile.z_scores.iter().any(|z| !z.is_finite()),
"repro premise failed: expected at least one non-finite z-score at k=5"
);
for &z in &profile.z_scores {
assert!(!z.is_nan(), "z_scores must never contain NaN");
}
match &profile.normalized {
None => {} Some(nv) => {
for &v in nv {
assert!(!v.is_nan(), "normalized must never contain NaN");
}
}
}
assert!(
profile.normalized.is_none(),
"normalized must be None when any z-score is non-finite"
);
let g2: UnGraph<(), ()> = build_un(&random_edges(18, 0.35, 88), 18);
let mut rng2 = StdRng::seed_from_u64(55);
let profile2 = census_significance_profile(
&g2,
3,
60,
NullModel::DegreePreserving {
n_swaps_per_edge: 10,
},
&mut rng2,
true,
);
assert!(
profile2.z_scores.iter().all(|z| z.is_finite()),
"sanity: this finite-case graph should not hit the infinite-z-score edge case"
);
let nv2 = profile2
.normalized
.as_ref()
.expect("finite z-scores must still normalize");
let norm: f64 = nv2.iter().map(|&z| z * z).sum::<f64>().sqrt();
assert!(
(norm - 1.0).abs() < 1e-10 || norm == 0.0,
"ordinary finite case must still normalize to unit length, got norm={norm}"
);
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(80))]
#[test]
fn prop_significance_valid_stats((n, bits, seed) in graph_strategy()) {
let edges = edges_from_bits(n, &bits);
let g = build_un(&edges, n);
let tri = Pattern::triangle();
let p3 = Pattern::path(3);
for model in [
NullModel::DegreePreserving { n_swaps_per_edge: 5 },
NullModel::ConfigurationModel,
] {
let mut rng = StdRng::seed_from_u64(seed);
if n < 3 {
let _ = motif_significance(
&g,
&[("tri", &tri, Induced::Yes)],
5,
model,
&mut rng,
);
continue;
}
let results = motif_significance(
&g,
&[
("tri", &tri, Induced::Yes),
("p3", &p3, Induced::Yes),
],
10,
model,
&mut rng,
);
for (_name, entry) in &results {
prop_assert!(!entry.z_score.is_nan(), "z_score must not be NaN");
prop_assert!(
(0.0..=1.0).contains(&entry.p_value_over),
"p_value_over={} out of [0,1]", entry.p_value_over
);
prop_assert!(
entry.null_std >= 0.0,
"null_std must be non-negative, got {}", entry.null_std
);
}
}
}
#[test]
fn prop_census_profile_valid((n, bits, seed) in graph_strategy()) {
if n < 3 {
return Ok(());
}
let edges = edges_from_bits(n, &bits);
let g = build_un(&edges, n);
let mut rng = StdRng::seed_from_u64(seed);
let profile = census_significance_profile(
&g,
3,
10,
NullModel::DegreePreserving { n_swaps_per_edge: 5 },
&mut rng,
true,
);
for &z in &profile.z_scores {
prop_assert!(!z.is_nan(), "z-score must not be NaN");
}
for (_, e) in &profile.entries {
prop_assert!(
(0.0..=1.0).contains(&e.p_value_over),
"p_value_over out of [0,1]"
);
}
if let Some(nv) = &profile.normalized {
let norm_sq: f64 = nv.iter().filter(|z| z.is_finite()).map(|&z| z * z).sum();
let norm = norm_sq.sqrt();
let z_zero = profile.z_scores.iter().all(|&z| z == 0.0 || z.is_infinite());
if !z_zero {
prop_assert!(
(norm - 1.0).abs() < 1e-9 || norm == 0.0,
"normalized vector norm={norm} should be 1 or 0"
);
}
}
}
}
use crate::rim::neighborhood::{
adamic_adar, average_clustering, census_triangle_count, common_neighbors, degree_assortativity,
global_clustering, jaccard, local_clustering, node_triangles, preferential_attachment,
resource_allocation, rich_club, rich_club_curve, score_non_edges, total_triangles,
};
#[test]
fn clustering_complete_graph() {
for n in 3..=6usize {
let g = build_un(&complete_edges(n), n);
for v in 0..n {
let u = NodeIndex::new(v);
let lc = local_clustering(&g, u);
assert!(
(lc - 1.0).abs() < 1e-12,
"K{n}: local_clustering(node {v}) = {lc}, expected 1.0"
);
}
let ac = average_clustering(&g);
assert!(
(ac - 1.0).abs() < 1e-12,
"K{n}: average_clustering = {ac}, expected 1.0"
);
let gc = global_clustering(&g);
assert!(
(gc - 1.0).abs() < 1e-12,
"K{n}: global_clustering = {gc}, expected 1.0"
);
let expected_tri = n * (n - 1) * (n - 2) / 6;
assert_eq!(total_triangles(&g), expected_tri, "K{n}: total_triangles");
}
}
#[test]
fn clustering_star_zero() {
for n in 3..=6usize {
let g = build_un(&star_edges(n), n);
for v in 0..n {
let u = NodeIndex::new(v);
let lc = local_clustering(&g, u);
assert!(
lc.abs() < 1e-12,
"S{n}: local_clustering(node {v}) = {lc}, expected 0.0"
);
}
let ac = average_clustering(&g);
assert!(
ac.abs() < 1e-12,
"S{n}: average_clustering = {ac}, expected 0.0"
);
let gc = global_clustering(&g);
assert!(
gc.abs() < 1e-12,
"S{n}: global_clustering = {gc}, expected 0.0"
);
assert_eq!(total_triangles(&g), 0, "S{n}: total_triangles");
}
}
#[test]
fn clustering_cycle_zero() {
for n in 4..=8usize {
let g = build_un(&cycle_edges(n), n);
for v in 0..n {
let u = NodeIndex::new(v);
let lc = local_clustering(&g, u);
assert!(
lc.abs() < 1e-12,
"C{n}: local_clustering(node {v}) = {lc}, expected 0.0"
);
}
assert_eq!(total_triangles(&g), 0, "C{n}: total_triangles");
}
}
#[test]
fn clustering_hand_graph() {
let g = build_un(&[(0, 1), (1, 2), (2, 0), (0, 3)], 4);
let n0 = NodeIndex::new(0);
let n1 = NodeIndex::new(1);
let n2 = NodeIndex::new(2);
let n3 = NodeIndex::new(3);
let lc0 = local_clustering(&g, n0);
assert!(
(lc0 - 1.0 / 3.0).abs() < 1e-12,
"node 0 local_clustering = {lc0}, expected 1/3"
);
let lc1 = local_clustering(&g, n1);
assert!(
(lc1 - 1.0).abs() < 1e-12,
"node 1 local_clustering = {lc1}, expected 1.0"
);
let lc2 = local_clustering(&g, n2);
assert!(
(lc2 - 1.0).abs() < 1e-12,
"node 2 local_clustering = {lc2}, expected 1.0"
);
let lc3 = local_clustering(&g, n3);
assert!(
lc3.abs() < 1e-12,
"node 3 local_clustering = {lc3}, expected 0.0"
);
let ac = average_clustering(&g);
assert!(
(ac - 7.0 / 12.0).abs() < 1e-12,
"average_clustering = {ac}, expected 7/12"
);
let gc = global_clustering(&g);
assert!(
(gc - 3.0 / 5.0).abs() < 1e-12,
"global_clustering = {gc}, expected 3/5"
);
assert_eq!(total_triangles(&g), 1, "one triangle");
assert_eq!(node_triangles(&g, n0), 1, "node 0: one triangle");
assert_eq!(node_triangles(&g, n1), 1, "node 1: one triangle");
assert_eq!(node_triangles(&g, n2), 1, "node 2: one triangle");
assert_eq!(node_triangles(&g, n3), 0, "node 3: no triangles");
}
#[test]
fn total_triangles_matches_census() {
let cases: Vec<(Vec<(usize, usize)>, usize)> = vec![
(complete_edges(3), 3),
(complete_edges(4), 4),
(complete_edges(5), 5),
(cycle_edges(6), 6),
(path_edges(8), 8),
(star_edges(6), 6),
(vec![(0, 1), (1, 2), (2, 0), (0, 3)], 4), (vec![(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)], 4), (random_edges(10, 0.5, 1), 10),
(random_edges(12, 0.4, 2), 12),
(random_edges(14, 0.35, 3), 14),
(random_edges(16, 0.3, 4), 16),
];
for (edges, n) in &cases {
let g = build_un(edges, *n);
let from_neighborhood = total_triangles(&g);
let from_census = census_triangle_count(&g);
assert_eq!(
from_neighborhood, from_census,
"total_triangles vs census triangle count n={n}"
);
}
}
#[test]
fn assortativity_star_is_negative() {
for n in 4..=8usize {
let g = build_un(&star_edges(n), n);
let r = degree_assortativity(&g);
assert!(
r.is_nan() || r < 0.0,
"S{n}: assortativity = {r}, expected negative"
);
}
}
#[test]
fn assortativity_regular_is_nan() {
for n in 3..=5usize {
let g = build_un(&complete_edges(n), n);
let r = degree_assortativity(&g);
assert!(
r.is_nan(),
"K{n}: assortativity should be NAN (zero variance), got {r}"
);
}
for n in 4..=6usize {
let g = build_un(&cycle_edges(n), n);
let r = degree_assortativity(&g);
assert!(
r.is_nan(),
"C{n}: assortativity should be NAN (zero variance), got {r}"
);
}
}
#[test]
fn assortativity_hand_graph() {
let g = build_un(&[(0, 1), (1, 2), (2, 0), (0, 3)], 4);
let r = degree_assortativity(&g);
let expected = -5.0 / 7.0;
assert!(
(r - expected).abs() < 1e-10,
"hand graph assortativity = {r}, expected {expected}"
);
}
#[test]
fn assortativity_no_edges_is_nan() {
let g = build_un(&[], 5);
let r = degree_assortativity(&g);
assert!(r.is_nan(), "empty graph assortativity should be NAN");
}
#[test]
fn rich_club_complete_graph() {
for n in 3..=6usize {
let g = build_un(&complete_edges(n), n);
for k in 0..(n - 1) {
let phi = rich_club(&g, k);
assert!(
(phi - 1.0).abs() < 1e-12,
"K{n} phi({k}) = {phi}, expected 1.0"
);
}
let phi_high = rich_club(&g, n - 1);
assert!(
phi_high.abs() < 1e-12,
"K{n} phi({}) = {phi_high}, expected 0.0",
n - 1
);
}
}
#[test]
fn rich_club_curve_hand_graph() {
let g = build_un(&[(0, 1), (1, 2), (2, 0), (0, 3)], 4);
let curve = rich_club_curve(&g);
assert_eq!(curve.len(), 4);
let (k0, phi0) = curve[0];
assert_eq!(k0, 0);
assert!(
(phi0 - 2.0 / 3.0).abs() < 1e-12,
"phi(0) = {phi0}, expected 2/3"
);
let (k1, phi1) = curve[1];
assert_eq!(k1, 1);
assert!((phi1 - 1.0).abs() < 1e-12, "phi(1) = {phi1}, expected 1.0");
let (k2, phi2) = curve[2];
assert_eq!(k2, 2);
assert!(phi2.abs() < 1e-12, "phi(2) = {phi2}, expected 0.0");
let (k3, phi3) = curve[3];
assert_eq!(k3, 3);
assert!(phi3.abs() < 1e-12, "phi(3) = {phi3}, expected 0.0");
}
#[test]
fn link_prediction_hand_graph() {
let g = build_un(&[(0, 1), (1, 2), (2, 0), (0, 3)], 4);
let n1 = NodeIndex::new(1usize);
let n2 = NodeIndex::new(2usize);
let n3 = NodeIndex::new(3usize);
assert_eq!(common_neighbors(&g, n1, n3), 1);
assert_eq!(common_neighbors(&g, n2, n3), 1);
assert_eq!(common_neighbors(&g, n1, n2), 1);
let j13 = jaccard(&g, n1, n3);
assert!(
(j13 - 1.0 / 2.0).abs() < 1e-12,
"jaccard(1,3) = {j13}, expected 0.5"
);
let aa13 = adamic_adar(&g, n1, n3);
let expected_aa = 1.0 / (3.0f64).ln();
assert!(
(aa13 - expected_aa).abs() < 1e-12,
"adamic_adar(1,3) = {aa13}, expected 1/ln(3)"
);
let ra13 = resource_allocation(&g, n1, n3);
assert!(
(ra13 - 1.0 / 3.0).abs() < 1e-12,
"resource_allocation(1,3) = {ra13}, expected 1/3"
);
assert_eq!(preferential_attachment(&g, n1, n3), 2);
let g2 = build_un(&[], 2);
let a = NodeIndex::new(0);
let b = NodeIndex::new(1);
let j = jaccard(&g2, a, b);
assert!(
j.abs() < 1e-12,
"jaccard of isolated pair = {j}, expected 0.0"
);
let aa_no_common = adamic_adar(&g2, a, b);
assert!(
aa_no_common.abs() < 1e-12,
"adamic_adar with no common neighbors = 0"
);
}
#[test]
fn score_non_edges_correctness() {
let g = build_un(&[(0, 1), (1, 2), (2, 0), (0, 3)], 4);
let scores = score_non_edges(&g);
assert_eq!(scores.len(), 2, "expected 2 non-edges");
let mut pairs: Vec<(usize, usize)> = scores
.iter()
.map(|(u, v, _)| (u.index().min(v.index()), u.index().max(v.index())))
.collect();
pairs.sort_unstable();
assert!(
pairs.contains(&(1, 3)),
"expected non-edge (1,3), got {pairs:?}"
);
assert!(
pairs.contains(&(2, 3)),
"expected non-edge (2,3), got {pairs:?}"
);
for (u, v, sc) in &scores {
let ui = u.index();
let vi = v.index();
let pa_direct = preferential_attachment(&g, *u, *v);
assert_eq!(
sc.preferential_attachment, pa_direct,
"PA mismatch for ({ui},{vi})"
);
let cn_direct = common_neighbors(&g, *u, *v);
assert_eq!(
sc.common_neighbors, cn_direct,
"common_neighbors mismatch for ({ui},{vi})"
);
}
let kg = build_un(&complete_edges(4), 4);
assert!(score_non_edges(&kg).is_empty(), "K4 has no non-edges");
let eg = build_un(&[], 4);
assert_eq!(
score_non_edges(&eg).len(),
6,
"empty 4-node graph has C(4,2)=6 non-edges"
);
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(160))]
#[test]
fn prop_clustering_in_unit_interval((n, bits, _seed) in graph_strategy()) {
let edges = edges_from_bits(n, &bits);
let g = build_un(&edges, n);
for v in 0..n {
let u = NodeIndex::new(v);
let lc = local_clustering(&g, u);
prop_assert!(
(0.0..=1.0).contains(&lc),
"local_clustering({v}) = {lc} out of [0,1]"
);
}
let ac = average_clustering(&g);
prop_assert!(
(0.0..=1.0).contains(&ac),
"average_clustering = {ac} out of [0,1]"
);
let gc = global_clustering(&g);
prop_assert!(
(0.0..=1.0).contains(&gc),
"global_clustering = {gc} out of [0,1]"
);
}
#[test]
fn prop_jaccard_in_unit_interval((n, bits, _seed) in graph_strategy()) {
let edges = edges_from_bits(n, &bits);
let g = build_un(&edges, n);
for i in 0..n {
for j in (i + 1)..n {
let u = NodeIndex::new(i);
let v = NodeIndex::new(j);
let j_val = jaccard(&g, u, v);
prop_assert!(
(0.0..=1.0).contains(&j_val),
"jaccard({i},{j}) = {j_val} out of [0,1]"
);
}
}
}
#[test]
fn prop_assortativity_in_range((n, bits, _seed) in graph_strategy()) {
let edges = edges_from_bits(n, &bits);
let g = build_un(&edges, n);
let r = degree_assortativity(&g);
if !r.is_nan() {
prop_assert!(
(-1.0 - 1e-9..=1.0 + 1e-9).contains(&r),
"assortativity = {r} out of [-1,1]"
);
}
}
#[test]
fn prop_triangle_count_vs_census((n, bits, _seed) in graph_strategy()) {
if n < 3 {
return Ok(());
}
let edges = edges_from_bits(n, &bits);
let g = build_un(&edges, n);
let via_neighborhood = total_triangles(&g);
let via_census = census_triangle_count(&g);
prop_assert_eq!(
via_neighborhood, via_census,
"total_triangles n={}",
n
);
}
#[test]
fn prop_rich_club_in_unit_interval((n, bits, _seed) in graph_strategy()) {
let edges = edges_from_bits(n, &bits);
let g = build_un(&edges, n);
let curve = rich_club_curve(&g);
for (k, phi) in &curve {
prop_assert!(
(0.0..=1.0 + 1e-12).contains(phi),
"phi({k}) = {phi} out of [0,1]"
);
}
}
}
use crate::rim::kernels::{
degree_labeling, gram_matrix, graphlet_features, graphlet_gram_matrix, graphlet_kernel,
graphlet_kernel_cosine, label_histogram, shortest_path_gram_matrix, shortest_path_histogram,
shortest_path_kernel, wl_gram_matrix, wl_kernel, wl_kernel_pair, wl_refine, GramNormalization,
};
#[test]
fn graphlet_features_matches_census_directly() {
let g = build_un(&[(0, 1), (1, 2), (2, 3), (3, 0), (0, 2), (3, 4)], 5);
for k in 2..=4usize {
let sel = Selector::connected_k_subsets(k);
let census = count(&g, &sel);
let features = graphlet_features(&g, k);
assert_eq!(features.k, k, "graphlet_features must tag its own k");
assert_eq!(
features.census, census,
"graphlet_features must equal census at k={k}"
);
}
let k3 = graphlet_features(&g, 3);
assert_eq!(k3.census.values().sum::<u64>(), 6);
}
#[test]
fn graphlet_kernel_hand_triangle_vs_path() {
let triangle = build_un(&[(0, 1), (1, 2), (2, 0)], 3);
let path = build_un(&[(0, 1), (1, 2)], 3);
let ft = graphlet_features(&triangle, 3);
let fp = graphlet_features(&path, 3);
assert_eq!(ft.census.values().sum::<u64>(), 1);
assert_eq!(fp.census.values().sum::<u64>(), 1);
let tri_class = Pattern::triangle().class_id();
let path_class = Pattern::path(3).class_id();
assert_ne!(
tri_class, path_class,
"triangle and P3 must be distinct classes"
);
assert_eq!(ft.census.get(&tri_class).copied(), Some(1));
assert_eq!(fp.census.get(&path_class).copied(), Some(1));
assert_eq!(graphlet_kernel(&ft, &fp), 0);
assert_eq!(graphlet_kernel_cosine(&ft, &fp), 0.0);
assert!((graphlet_kernel_cosine(&ft, &ft) - 1.0).abs() < 1e-12);
assert!((graphlet_kernel_cosine(&fp, &fp) - 1.0).abs() < 1e-12);
assert_eq!(graphlet_kernel(&ft, &fp), graphlet_kernel(&fp, &ft));
}
#[test]
fn graphlet_kernel_isomorphism_invariant() {
let edges = [(0, 1), (1, 2), (2, 3), (3, 0), (0, 2), (3, 4)];
let g = build_un(&edges, 5);
let order = [4, 2, 0, 3, 1]; let gp = build_perm_un(&edges, 5, &order);
for k in 2..=4usize {
let fg = graphlet_features(&g, k);
let fgp = graphlet_features(&gp, k);
assert_eq!(
fg, fgp,
"graphlet feature vector must be relabelling-invariant at k={k}"
);
assert_eq!(graphlet_kernel(&fg, &fg), graphlet_kernel(&fg, &fgp));
}
}
#[test]
fn graphlet_kernel_cross_k_comparison_is_caught() {
let triangle: UnGraph<(), ()> = build_un(&[(0, 1), (1, 2), (2, 0)], 3);
let star5: UnGraph<(), ()> = build_un(&[(0, 1), (0, 2), (0, 3), (0, 4)], 5);
let f_tri_k3 = graphlet_features(&triangle, 3);
let f_star_k4 = graphlet_features(&star5, 4);
assert_eq!(f_tri_k3.census.values().sum::<u64>(), 1);
assert_eq!(f_star_k4.census.len(), 1, "star5 at k=4 has a single class");
assert_eq!(f_star_k4.census.values().sum::<u64>(), 4);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
graphlet_kernel(&f_tri_k3, &f_star_k4)
}));
assert!(
result.is_err(),
"graphlet_kernel must reject/catch a cross-k comparison rather than silently \
returning a bogus value"
);
let f_tri_k3_again = graphlet_features(&triangle, 3);
assert_eq!(graphlet_kernel(&f_tri_k3, &f_tri_k3_again), 1);
}
#[test]
fn wl_hand_p4_labels_and_histograms() {
let g = build_un(&path_edges(4), 4);
let initial = degree_labeling(&g);
assert_eq!(initial, vec![1, 2, 2, 1], "P4 degrees");
let history = wl_refine(&[&g], &[initial], 2);
assert_eq!(history.len(), 1);
assert_eq!(history[0].len(), 3);
let h0 = label_histogram(&history[0][0]);
let mut v0: Vec<u64> = h0.values().copied().collect();
v0.sort_unstable();
assert_eq!(v0, vec![2, 2]);
let l1 = &history[0][1];
assert_eq!(l1[0], l1[3], "iter1: node0 and node3 must share a label");
assert_eq!(l1[1], l1[2], "iter1: node1 and node2 must share a label");
assert_ne!(l1[0], l1[1], "iter1: the two groups must differ");
let mut v1: Vec<u64> = label_histogram(l1).values().copied().collect();
v1.sort_unstable();
assert_eq!(v1, vec![2, 2]);
let l2 = &history[0][2];
assert_eq!(l2[0], l2[3], "iter2: node0 and node3 must share a label");
assert_eq!(l2[1], l2[2], "iter2: node1 and node2 must share a label");
assert_ne!(l2[0], l2[1], "iter2: the two groups must differ");
let mut v2: Vec<u64> = label_histogram(l2).values().copied().collect();
v2.sort_unstable();
assert_eq!(v2, vec![2, 2]);
}
#[test]
fn wl_kernel_symmetry_and_self_positive() {
let g = build_un(&[(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)], 4); let h = build_un(&path_edges(5), 5);
let kgh = wl_kernel_pair(&g, &h, 3);
let khg = wl_kernel_pair(&h, &g, 3);
assert_eq!(kgh, khg, "WL kernel must be symmetric");
assert!(
wl_kernel_pair(&g, &g, 3) > 0,
"self-kernel must be positive for a nonempty graph"
);
let initial = vec![degree_labeling(&g), degree_labeling(&h)];
let history = wl_refine(&[&g, &h], &initial, 3);
assert_eq!(wl_kernel(&history[0], &history[1]), kgh);
let gm = gram_matrix(
&history,
|a, b| wl_kernel(a, b) as f64,
GramNormalization::Raw,
);
assert_eq!(gm[0][1], kgh as f64);
assert_eq!(gm[0][0], wl_kernel(&history[0], &history[0]) as f64);
}
#[test]
fn wl_kernel_isomorphism_invariant() {
let edges = [(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)]; let g = build_un(&edges, 4);
let order = [3, 1, 0, 2];
let gp = build_perm_un(&edges, 4, &order);
for h in 0..=3usize {
assert_eq!(
wl_kernel_pair(&g, &g, h),
wl_kernel_pair(&g, &gp, h),
"WL kernel(g,g) must equal kernel(g, relabelled g) at h={h}"
);
}
}
#[test]
fn sp_hand_histograms_p4_c4_k4() {
let p4 = build_un(&path_edges(4), 4);
let hp4 = shortest_path_histogram(&p4);
assert_eq!(hp4.get(&1).copied(), Some(3));
assert_eq!(hp4.get(&2).copied(), Some(2));
assert_eq!(hp4.get(&3).copied(), Some(1));
assert_eq!(hp4.values().sum::<u64>(), 6);
let c4 = build_un(&cycle_edges(4), 4);
let hc4 = shortest_path_histogram(&c4);
assert_eq!(hc4.get(&1).copied(), Some(4));
assert_eq!(hc4.get(&2).copied(), Some(2));
assert_eq!(
hc4.len(),
2,
"C4 histogram has exactly two distance buckets"
);
assert_eq!(hc4.values().sum::<u64>(), 6);
let k4 = build_un(&complete_edges(4), 4);
let hk4 = shortest_path_histogram(&k4);
assert_eq!(hk4.get(&1).copied(), Some(6));
assert_eq!(hk4.len(), 1, "K4 histogram has exactly one distance bucket");
}
#[test]
fn sp_kernel_symmetry_and_isomorphism_invariant() {
let edges = [(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)]; let g = build_un(&edges, 4);
let h = build_un(&cycle_edges(5), 5);
let hg = shortest_path_histogram(&g);
let hh = shortest_path_histogram(&h);
assert_eq!(
shortest_path_kernel(&hg, &hh),
shortest_path_kernel(&hh, &hg),
"SP kernel must be symmetric"
);
let order = [3, 1, 0, 2];
let gp = build_perm_un(&edges, 4, &order);
let hgp = shortest_path_histogram(&gp);
assert_eq!(hg, hgp, "SP histogram must be relabelling-invariant");
assert_eq!(
shortest_path_kernel(&hg, &hg),
shortest_path_kernel(&hg, &hgp)
);
}
#[test]
fn gram_matrix_cosine_unit_diagonal() {
let graphs: Vec<UnGraph<(), ()>> = vec![
build_un(&complete_edges(3), 3),
build_un(&path_edges(4), 4),
build_un(&cycle_edges(4), 4),
build_un(&star_edges(5), 5),
build_un(&[(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)], 4), ];
let refs: Vec<&UnGraph<(), ()>> = graphs.iter().collect();
let raw = graphlet_gram_matrix(&refs, 3, GramNormalization::Raw);
let cos = graphlet_gram_matrix(&refs, 3, GramNormalization::Cosine);
for (i, cos_row) in cos.iter().enumerate() {
assert!(
(cos_row[i] - 1.0).abs() < 1e-9,
"cosine diag[{i}] = {}",
cos_row[i]
);
}
for i in 0..graphs.len() {
for j in 0..graphs.len() {
assert!(
(raw[i][j] - raw[j][i]).abs() < 1e-9,
"raw gram matrix not symmetric at ({i},{j})"
);
assert!(
(cos[i][j] - cos[j][i]).abs() < 1e-9,
"cosine gram matrix not symmetric at ({i},{j})"
);
}
}
let wl_cos = wl_gram_matrix(&refs, 2, GramNormalization::Cosine);
let sp_cos = shortest_path_gram_matrix(&refs, GramNormalization::Cosine);
for i in 0..graphs.len() {
assert!((wl_cos[i][i] - 1.0).abs() < 1e-9, "WL cosine diag[{i}]");
assert!((sp_cos[i][i] - 1.0).abs() < 1e-9, "SP cosine diag[{i}]");
}
}
fn is_psd_via_cholesky(m: &[Vec<f64>], eps: f64) -> bool {
let n = m.len();
let mut l = vec![vec![0.0f64; n]; n];
for i in 0..n {
for j in 0..=i {
let mut sum = m[i][j];
for (li, lj) in l[i].iter().zip(l[j].iter()).take(j) {
sum -= li * lj;
}
if i == j {
if sum < -eps {
return false;
}
l[i][j] = sum.max(0.0).sqrt();
} else if l[j][j] > eps {
l[i][j] = sum / l[j][j];
} else if sum.abs() > eps {
return false;
} else {
l[i][j] = 0.0;
}
}
}
true
}
#[test]
fn gram_matrices_are_psd_fixed_battery() {
let graphs: Vec<UnGraph<(), ()>> = vec![
build_un(&complete_edges(3), 3),
build_un(&complete_edges(4), 4),
build_un(&path_edges(4), 4),
build_un(&path_edges(6), 6),
build_un(&cycle_edges(4), 4),
build_un(&cycle_edges(6), 6),
build_un(&star_edges(5), 5),
build_un(&[(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)], 4), build_un(&[(0, 1), (1, 2), (2, 3), (3, 0), (0, 2), (3, 4)], 5), build_un(&random_edges(8, 0.4, 7), 8),
];
let refs: Vec<&UnGraph<(), ()>> = graphs.iter().collect();
let g_raw = graphlet_gram_matrix(&refs, 3, GramNormalization::Raw);
assert!(
is_psd_via_cholesky(&g_raw, 1e-6),
"graphlet kernel Gram matrix not PSD"
);
let wl_raw = wl_gram_matrix(&refs, 3, GramNormalization::Raw);
assert!(
is_psd_via_cholesky(&wl_raw, 1e-6),
"WL kernel Gram matrix not PSD"
);
let sp_raw = shortest_path_gram_matrix(&refs, GramNormalization::Raw);
assert!(
is_psd_via_cholesky(&sp_raw, 1e-6),
"shortest-path kernel Gram matrix not PSD"
);
let g_cos = graphlet_gram_matrix(&refs, 3, GramNormalization::Cosine);
assert!(
is_psd_via_cholesky(&g_cos, 1e-6),
"cosine graphlet Gram matrix not PSD"
);
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(120))]
#[test]
fn prop_kernel_symmetry((n, bits, _seed) in graph_strategy()) {
let edges = edges_from_bits(n, &bits);
let g = build_un(&edges, n);
let bits2: Vec<bool> = bits.iter().rev().copied().collect();
let edges2 = edges_from_bits(n, &bits2);
let h = build_un(&edges2, n);
if n >= 2 {
let fg = graphlet_features(&g, 2);
let fh = graphlet_features(&h, 2);
prop_assert_eq!(graphlet_kernel(&fg, &fh), graphlet_kernel(&fh, &fg));
}
prop_assert_eq!(wl_kernel_pair(&g, &h, 2), wl_kernel_pair(&h, &g, 2));
let sg = shortest_path_histogram(&g);
let sh = shortest_path_histogram(&h);
prop_assert_eq!(shortest_path_kernel(&sg, &sh), shortest_path_kernel(&sh, &sg));
}
#[test]
fn prop_kernel_isomorphism_invariant((n, bits, seed) in graph_strategy()) {
let edges = edges_from_bits(n, &bits);
let g = build_un(&edges, n);
let mut order: Vec<usize> = (0..n).collect();
order.shuffle(&mut StdRng::seed_from_u64(seed));
let gp = build_perm_un(&edges, n, &order);
if n >= 2 {
let fg = graphlet_features(&g, 2);
let fgp = graphlet_features(&gp, 2);
prop_assert_eq!(&fg, &fgp, "graphlet features must be relabelling-invariant");
}
let hg = shortest_path_histogram(&g);
let hgp = shortest_path_histogram(&gp);
prop_assert_eq!(&hg, &hgp, "SP histogram must be relabelling-invariant");
prop_assert_eq!(
wl_kernel_pair(&g, &g, 2),
wl_kernel_pair(&g, &gp, 2),
"WL kernel(g,g) must equal kernel(g, relabelled g)"
);
}
#[test]
fn prop_gram_matrix_psd(seed in any::<u64>()) {
let mut rng = StdRng::seed_from_u64(seed);
let batch: Vec<UnGraph<(), ()>> = (0u64..6)
.map(|i| {
let n = 2 + (rng.gen::<u8>() % 6) as usize; build_un(&random_edges(n, 0.4, seed.wrapping_add(i)), n)
})
.collect();
let refs: Vec<&UnGraph<(), ()>> = batch.iter().collect();
let g_raw = graphlet_gram_matrix(&refs, 2, GramNormalization::Raw);
prop_assert!(is_psd_via_cholesky(&g_raw, 1e-5), "graphlet Gram not PSD, seed={seed}");
let g_cos = graphlet_gram_matrix(&refs, 2, GramNormalization::Cosine);
prop_assert!(is_psd_via_cholesky(&g_cos, 1e-5), "graphlet cosine Gram not PSD, seed={seed}");
let wl_raw = wl_gram_matrix(&refs, 2, GramNormalization::Raw);
prop_assert!(is_psd_via_cholesky(&wl_raw, 1e-5), "WL Gram not PSD, seed={seed}");
let sp_raw = shortest_path_gram_matrix(&refs, GramNormalization::Raw);
prop_assert!(is_psd_via_cholesky(&sp_raw, 1e-5), "SP Gram not PSD, seed={seed}");
}
}
fn assert_fast_equals_exact(edges: &[(usize, usize)], n: usize, label: &str) {
let g = build_un(edges, n);
let reg = Registry::build();
let exact_gdv = graphlet_degree_vectors(&g, ®);
let fast_gdv = fast_graphlet_degree_vectors(&g, ®);
assert_eq!(fast_gdv.orbit_count(), reg.orbit_count());
assert_eq!(fast_gdv.orbit_count(), 73);
assert!(FAST_ORBIT_COUNT <= fast_gdv.orbit_count());
assert_eq!(
exact_gdv.len(),
fast_gdv.len(),
"{label}: node count mismatch"
);
for i in 0..exact_gdv.len() {
assert_eq!(
exact_gdv.row(i),
fast_gdv.row(i),
"{label}: GDV mismatch at node {i} (host id {:?})",
exact_gdv.id(i)
);
}
for k in 2..=5 {
let sel = Selector::connected_k_subsets(k);
let exact_census = count(&g, &sel);
let fast_census = fast_count(&g, ®, &sel);
assert_eq!(exact_census, fast_census, "{label}: k={k} census mismatch");
}
}
#[test]
fn fast_orbit_counts_exact_on_structured_and_fuzzed_battery() {
for (edges, n) in undirected_host_battery() {
let label = format!("n={n} edges={}", edges.len());
assert_fast_equals_exact(&edges, n, &label);
}
for seed in 0..40u64 {
let n = 4 + (seed as usize % 20);
let p = 0.05 + 0.9 * ((seed % 10) as f64 / 10.0);
let edges = random_edges(n, p, 1000 + seed);
assert_fast_equals_exact(&edges, n, &format!("fuzz n={n} p={p:.2} seed={seed}"));
}
}
#[test]
fn fast_orbit_counts_exact_on_trees_and_bipartite() {
let star = star_edges(9);
assert_fast_equals_exact(&star, 9, "star9");
let caterpillar: Vec<(usize, usize)> = (0..7)
.map(|i| (i, i + 1))
.chain(
(0..8)
.step_by(2)
.filter(|&i| i + 8 < 16)
.map(|i| (i, i + 8)),
)
.collect();
assert_fast_equals_exact(&caterpillar, 16, "caterpillar");
let mut binary = Vec::new();
for i in 0..7usize {
binary.push((i, 2 * i + 1));
binary.push((i, 2 * i + 2));
}
assert_fast_equals_exact(&binary, 15, "binary-tree-depth3");
let k34: Vec<(usize, usize)> = (0..3).flat_map(|a| (3..7).map(move |b| (a, b))).collect();
assert_fast_equals_exact(&k34, 7, "K_3_4");
let k44: Vec<(usize, usize)> = (0..4).flat_map(|a| (4..8).map(move |b| (a, b))).collect();
assert_fast_equals_exact(&k44, 8, "K_4_4");
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(200))]
#[test]
fn prop_fast_orbit_counts_equal_exact((n, bits, _seed) in graph_strategy()) {
let edges = edges_from_bits(n, &bits);
let g = build_un(&edges, n);
let reg = Registry::build();
let exact_gdv = graphlet_degree_vectors(&g, ®);
let fast_gdv = fast_graphlet_degree_vectors(&g, ®);
prop_assert_eq!(fast_gdv.orbit_count(), 73);
prop_assert_eq!(exact_gdv.len(), fast_gdv.len());
for i in 0..exact_gdv.len() {
prop_assert_eq!(
exact_gdv.row(i),
fast_gdv.row(i),
"GDV mismatch at node {}", i
);
}
for k in 2..=5 {
let sel = Selector::connected_k_subsets(k);
prop_assert_eq!(count(&g, &sel), fast_count(&g, ®, &sel));
}
}
}
#[test]
fn fast_orbit_counts_exact_order5_dense_battery() {
for seed in 0..60u64 {
let n = 8 + (seed as usize % 8); let p = 0.2 + 0.7 * ((seed % 8) as f64 / 8.0); let edges = random_edges(n, p, 7000 + seed);
assert_fast_equals_exact(
&edges,
n,
&format!("order5 fuzz n={n} p={p:.2} seed={seed}"),
);
}
for rim in [5usize, 6, 7, 8] {
let mut edges: Vec<(usize, usize)> = (0..rim).map(|i| (i, (i + 1) % rim)).collect();
edges.extend((0..rim).map(|i| (i, rim)));
assert_fast_equals_exact(&edges, rim + 1, &format!("wheel W{rim}"));
}
}
#[test]
#[ignore]
fn fast_order5_speedup() {
use std::time::Instant;
let reg = Registry::build();
for &(n, p) in &[(40usize, 0.3_f64), (60, 0.3), (80, 0.25)] {
let edges = random_edges(n, p, 42);
let g = build_un(&edges, n);
let t0 = Instant::now();
let exact = graphlet_degree_vectors(&g, ®);
let te = t0.elapsed();
let t1 = Instant::now();
let fast = fast_graphlet_degree_vectors(&g, ®);
let tf = t1.elapsed();
for i in 0..exact.len() {
assert_eq!(exact.row(i), fast.row(i), "n={n} node {i}");
}
let speedup = te.as_secs_f64() / tf.as_secs_f64();
println!(
"n={n} p={p} edges={} exact={te:?} fast={tf:?} speedup={speedup:.1}x",
edges.len()
);
assert!(
speedup > 3.0,
"fast order-5 path must substantially beat exact (got {speedup:.1}x at n={n})"
);
}
}
use crate::rim::directed::triad::{classify as triad_classify, triad_census, TriadType};
use crate::rim::directed::{
count_directed, directed_graphlet_degree_vectors, enumerate_directed, DirectedCensus,
DirectedClassId, DirectedRegistry, DirectedSelector,
};
fn shared_directed_registry() -> &'static DirectedRegistry {
static REG: std::sync::OnceLock<DirectedRegistry> = std::sync::OnceLock::new();
REG.get_or_init(DirectedRegistry::build)
}
fn triad_local_mask(has_arc: &impl Fn(usize, usize) -> bool) -> u64 {
let pairs = [(0, 1), (1, 0), (0, 2), (2, 0), (1, 2), (2, 1)];
let mut m = 0u64;
for (b, &(i, j)) in pairs.iter().enumerate() {
if has_arc(i, j) {
m |= 1 << b;
}
}
m
}
fn triad_canonical_mask(has_arc: &impl Fn(usize, usize) -> bool) -> u64 {
let perms3: [[usize; 3]; 6] = [
[0, 1, 2],
[0, 2, 1],
[1, 0, 2],
[1, 2, 0],
[2, 0, 1],
[2, 1, 0],
];
perms3
.iter()
.map(|p| triad_local_mask(&|i, j| has_arc(p[i], p[j])))
.min()
.unwrap()
}
fn oracle_triad_table() -> HashMap<u64, TriadType> {
let examples: Vec<(Vec<(usize, usize)>, TriadType)> = vec![
(vec![], TriadType::T003),
(vec![(0, 1)], TriadType::T012),
(vec![(0, 1), (1, 0)], TriadType::T102),
(vec![(0, 1), (0, 2)], TriadType::T021D),
(vec![(1, 0), (2, 0)], TriadType::T021U),
(vec![(0, 1), (1, 2)], TriadType::T021C),
(vec![(0, 1), (1, 0), (2, 1)], TriadType::T111D),
(vec![(0, 1), (1, 0), (1, 2)], TriadType::T111U),
(vec![(0, 1), (1, 2), (0, 2)], TriadType::T030T),
(vec![(0, 1), (1, 2), (2, 0)], TriadType::T030C),
(vec![(0, 1), (1, 0), (0, 2), (2, 0)], TriadType::T201),
(vec![(1, 0), (1, 2), (0, 2), (2, 0)], TriadType::T120D),
(vec![(0, 1), (2, 1), (0, 2), (2, 0)], TriadType::T120U),
(vec![(0, 1), (1, 2), (0, 2), (2, 0)], TriadType::T120C),
(
vec![(0, 1), (1, 0), (0, 2), (2, 0), (1, 2)],
TriadType::T210,
),
(
vec![(0, 1), (1, 0), (0, 2), (2, 0), (1, 2), (2, 1)],
TriadType::T300,
),
];
let mut table = HashMap::new();
for (arcs, ty) in examples {
let has_arc = |i: usize, j: usize| arcs.contains(&(i, j));
let mask = triad_canonical_mask(&has_arc);
let prior = table.insert(mask, ty);
assert!(
prior.is_none() || prior == Some(ty),
"canonical-mask collision building oracle table: {ty:?} vs {prior:?}"
);
}
table
}
fn oracle_classify(
x: usize,
y: usize,
z: usize,
has_arc: impl Fn(usize, usize) -> bool,
) -> TriadType {
let local = |i: usize, j: usize| {
let g = [x, y, z];
has_arc(g[i], g[j])
};
let mask = triad_canonical_mask(&local);
let table = oracle_triad_table();
table[&mask]
}
#[test]
fn triad_oracle_table_has_exactly_16_classes() {
let table = oracle_triad_table();
assert_eq!(
table.len(),
16,
"expected exactly 16 distinct triad classes"
);
}
#[test]
fn triad_classify_matches_oracle_exhaustive() {
let mut prod_classes = HashSet::new();
let mut oracle_classes = HashSet::new();
for bits in 0u32..64 {
let pairs = [(0, 1), (1, 0), (0, 2), (2, 0), (1, 2), (2, 1)];
let arcs: Vec<(usize, usize)> = pairs
.iter()
.enumerate()
.filter(|&(b, _)| bits & (1 << b) != 0)
.map(|(_, &p)| p)
.collect();
let has_arc = |i: usize, j: usize| arcs.contains(&(i, j));
let prod = triad_classify(0, 1, 2, has_arc);
let oracle = oracle_classify(0, 1, 2, has_arc);
assert_eq!(
prod, oracle,
"bits={bits:06b} arcs={arcs:?} production={prod:?} oracle={oracle:?}"
);
prod_classes.insert(prod);
oracle_classes.insert(oracle);
}
assert_eq!(prod_classes.len(), 16);
assert_eq!(oracle_classes.len(), 16);
}
#[test]
fn triad_classify_stable_under_relabelling() {
let node_perms: [[usize; 3]; 6] = [
[0, 1, 2],
[0, 2, 1],
[1, 0, 2],
[1, 2, 0],
[2, 0, 1],
[2, 1, 0],
];
for bits in 0u32..64 {
let pairs = [(0, 1), (1, 0), (0, 2), (2, 0), (1, 2), (2, 1)];
let arcs: Vec<(usize, usize)> = pairs
.iter()
.enumerate()
.filter(|&(b, _)| bits & (1 << b) != 0)
.map(|(_, &p)| p)
.collect();
let has_arc = |i: usize, j: usize| arcs.contains(&(i, j));
let base = triad_classify(0, 1, 2, has_arc);
for p in &node_perms {
let ty = triad_classify(p[0], p[1], p[2], has_arc);
assert_eq!(ty, base, "bits={bits:06b} perm={p:?}");
}
}
}
fn triad_census_oracle(dm: &[Vec<bool>], n: usize) -> HashMap<TriadType, u64> {
let mut out: HashMap<TriadType, u64> = HashMap::new();
for x in 0..n {
for y in (x + 1)..n {
for z in (y + 1)..n {
let ty = oracle_classify(x, y, z, |a, b| dm[a][b]);
*out.entry(ty).or_insert(0) += 1;
}
}
}
out
}
#[test]
fn triad_census_matches_oracle_and_totals() {
let mut cases: Vec<(Vec<(usize, usize)>, usize)> = vec![
(vec![], 5), (path_edges(6), 6), (cycle_edges(5), 5), (star_edges(6), 6), (complete_edges(5), 5), (
(0..5)
.flat_map(|i| ((i + 1)..5).map(move |j| (i, j)))
.collect(),
5,
),
{
let mut e = path_edges(5);
e.extend(path_edges(5).into_iter().map(|(a, b)| (b, a)));
(e, 5)
},
];
for seed in 0..10u64 {
let n = 5 + (seed as usize % 4);
let mut e = random_edges(n, 0.3, seed)
.into_iter()
.map(|(a, b)| if seed % 2 == 0 { (a, b) } else { (b, a) })
.collect::<Vec<_>>();
let mut rng = StdRng::seed_from_u64(seed + 1000);
for i in 0..n {
for j in (i + 1)..n {
if rng.gen::<f64>() < 0.15 {
e.push((i, j));
e.push((j, i));
}
}
}
cases.push((e, n));
}
for (arcs, n) in &cases {
let g = build_directed(arcs, *n);
let dm = dir_matrix(arcs, *n);
let oracle = triad_census_oracle(&dm, *n);
let census = triad_census(&g);
for ty in TriadType::all() {
let expect = oracle.get(&ty).copied().unwrap_or(0);
assert_eq!(
census.get(ty),
expect,
"triad type {} mismatch, n={n}",
ty.label()
);
}
let total_expected = (*n * (*n - 1) * (*n - 2) / 6) as u64;
assert_eq!(census.total(), total_expected, "total triads, n={n}");
assert_eq!(
oracle.values().sum::<u64>(),
total_expected,
"oracle total triads, n={n}"
);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(150))]
#[test]
fn prop_triad_census_matches_oracle((n, bits) in dir_graph_strategy()) {
let arcs = dir_edges_from_bits(n, &bits);
let dm = dir_matrix(&arcs, n);
let g = build_directed(&arcs, n);
let oracle = triad_census_oracle(&dm, n);
let census = triad_census(&g);
for ty in TriadType::all() {
prop_assert_eq!(census.get(ty), oracle.get(&ty).copied().unwrap_or(0), "type {}", ty.label());
}
let total_expected = (n * n.saturating_sub(1) * n.saturating_sub(2) / 6) as u64;
prop_assert_eq!(census.total(), total_expected);
}
}
#[test]
fn directed_class_counts_match_published_ground_truth() {
let reg = shared_directed_registry();
assert_eq!(
reg.class_count(2),
2,
"k=2 weakly-connected digraph classes"
);
assert_eq!(
reg.class_count(3),
13,
"k=3 weakly-connected digraph classes"
);
assert_eq!(
reg.class_count(4),
199,
"k=4 weakly-connected digraph classes"
);
assert_eq!(
reg.class_count(5),
9364,
"k=5 weakly-connected digraph classes"
);
}
fn dir_matrix_local(arcs: &[(usize, usize)], n: usize) -> Vec<Vec<bool>> {
let mut m = vec![vec![false; n]; n];
for &(a, b) in arcs {
if a != b {
m[a][b] = true;
}
}
m
}
fn dir_sub_weakly_connected(dm: &[Vec<bool>], sub: &[usize]) -> bool {
let k = sub.len();
let mut und = vec![Vec::new(); k];
for i in 0..k {
for j in 0..k {
if i != j && (dm[sub[i]][sub[j]] || dm[sub[j]][sub[i]]) {
und[i].push(j);
}
}
}
connected(&und)
}
fn indep_directed_mask(dm: &[Vec<bool>], sub: &[usize]) -> u64 {
let k = sub.len();
let mut best = u64::MAX;
for p in &perms(k) {
let mut mask = 0u64;
let mut bit = 0u32;
for i in 0..k {
for j in 0..k {
if i != j {
if dm[sub[p[i]]][sub[p[j]]] {
mask |= 1 << bit;
}
bit += 1;
}
}
}
best = best.min(mask);
}
best
}
fn directed_census_oracle(dm: &[Vec<bool>], n: usize, k: usize) -> HashMap<u64, u64> {
let mut out: HashMap<u64, u64> = HashMap::new();
if k > n {
return out;
}
let pool: Vec<usize> = (0..n).collect();
for sub in combos(&pool, k) {
if dir_sub_weakly_connected(dm, &sub) {
*out.entry(indep_directed_mask(dm, &sub)).or_insert(0) += 1;
}
}
out
}
fn directed_adversarial_battery() -> Vec<(Vec<(usize, usize)>, usize)> {
let mut hosts: Vec<(Vec<(usize, usize)>, usize)> = Vec::new();
for n in [4usize, 5, 6] {
hosts.push((path_edges(n), n)); hosts.push((cycle_edges(n), n)); hosts.push((star_edges(n), n)); hosts.push((star_edges(n).into_iter().map(|(a, b)| (b, a)).collect(), n));
hosts.push((
(0..n)
.flat_map(|i| ((i + 1)..n).map(move |j| (i, j)))
.collect(),
n,
));
let mut bidir = path_edges(n);
bidir.extend(path_edges(n).into_iter().map(|(a, b)| (b, a)));
hosts.push((bidir, n));
hosts.push((
(0..n)
.flat_map(|i| ((i + 1)..n).map(move |j| (i, j)))
.collect(),
n,
));
}
for seed in 0..10u64 {
let n = 5 + (seed as usize % 3);
let mut e: Vec<(usize, usize)> = Vec::new();
let mut rng = StdRng::seed_from_u64(seed + 2000);
for i in 0..n {
for j in 0..n {
if i != j && rng.gen::<f64>() < 0.25 {
e.push((i, j));
}
}
}
hosts.push((e, n));
}
hosts
}
#[test]
fn directed_census_matches_bruteforce_oracle() {
for (arcs, n) in directed_adversarial_battery() {
let dm = dir_matrix_local(&arcs, n);
let g = build_directed(&arcs, n);
for k in 2..=5 {
if k > n {
continue;
}
let sel = DirectedSelector::weakly_connected_k_subsets(k);
let census: DirectedCensus = count_directed(&g, &sel);
let oracle = directed_census_oracle(&dm, n, k);
let by_mask: HashMap<u64, u64> = census.iter().map(|(c, &v)| (c.0, v)).collect();
assert_eq!(by_mask, oracle, "census vs oracle n={n} k={k}");
let insts: Vec<_> = enumerate_directed(&g, &sel).collect();
let mut by_iter: HashMap<u64, u64> = HashMap::new();
for inst in &insts {
*by_iter.entry(inst.class.0).or_insert(0) += 1;
assert_eq!(inst.nodes.len(), k);
}
assert_eq!(by_iter, oracle, "enumerate vs oracle n={n} k={k}");
}
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_directed_census_matches_oracle((n, bits) in dir_graph_strategy()) {
let arcs = dir_edges_from_bits(n.min(6), &bits[..bits.len().min(6 * 5)]);
let dm = dir_matrix_local(&arcs, n);
let g = build_directed(&arcs, n);
for k in 2..=5usize {
if k > n {
continue;
}
let sel = DirectedSelector::weakly_connected_k_subsets(k);
let census: DirectedCensus = count_directed(&g, &sel);
let oracle = directed_census_oracle(&dm, n, k);
let by_mask: HashMap<u64, u64> = census.iter().map(|(c, &v)| (c.0, v)).collect();
prop_assert_eq!(by_mask, oracle, "n={} k={}", n, k);
}
}
}
fn indep_directed_canonical_arg(dm: &[Vec<bool>], sub: &[usize]) -> (u64, Vec<usize>) {
let k = sub.len();
let mut best = u64::MAX;
let mut arg = (0..k).collect::<Vec<usize>>();
for p in &perms(k) {
let mut mask = 0u64;
let mut bit = 0u32;
for i in 0..k {
for j in 0..k {
if i != j {
if dm[sub[p[i]]][sub[p[j]]] {
mask |= 1 << bit;
}
bit += 1;
}
}
}
if mask < best {
best = mask;
arg = p.clone();
}
}
(best, arg)
}
#[allow(clippy::needless_range_loop)]
fn directed_gdv_oracle(dm: &[Vec<bool>], n: usize, reg: &DirectedRegistry) -> Vec<Vec<u64>> {
let mut gdv = vec![vec![0u64; reg.orbit_count()]; n];
for v in 0..n {
let others: Vec<usize> = (0..n).filter(|&x| x != v).collect();
for k in 2..=5usize {
for rest in combos(&others, k - 1) {
let mut sub = vec![v];
sub.extend_from_slice(&rest);
if !dir_sub_weakly_connected(dm, &sub) {
continue;
}
let (class, arg) = indep_directed_canonical_arg(dm, &sub);
let slotmap = reg.slot_map(k, class);
for (c, &slot) in slotmap.iter().enumerate() {
if sub[arg[c]] == v {
gdv[v][slot] += 1;
break;
}
}
}
}
}
gdv
}
#[test]
#[allow(clippy::needless_range_loop)]
fn directed_gdv_matches_bruteforce_oracle() {
let reg = shared_directed_registry();
for (arcs, n) in directed_adversarial_battery() {
if n > 7 {
continue; }
let dm = dir_matrix_local(&arcs, n);
let g = build_directed(&arcs, n);
let gdv = directed_graphlet_degree_vectors(&g, reg);
let oracle = directed_gdv_oracle(&dm, n, reg);
for v in 0..n {
assert_eq!(gdv.row(v), oracle[v].as_slice(), "node {v} n={n}");
}
}
}
#[test]
fn directed_census_stable_under_relabelling() {
let mut rng = StdRng::seed_from_u64(4242);
for (arcs, n) in directed_adversarial_battery() {
let mut order: Vec<usize> = (0..n).collect();
order.shuffle(&mut rng);
let permuted_arcs: Vec<(usize, usize)> =
arcs.iter().map(|&(a, b)| (order[a], order[b])).collect();
for k in 2..=5 {
if k > n {
continue;
}
let sel = DirectedSelector::weakly_connected_k_subsets(k);
let g1 = build_directed(&arcs, n);
let g2 = build_directed(&permuted_arcs, n);
let mut c1: Vec<(DirectedClassId, u64)> =
count_directed(&g1, &sel).into_iter().collect();
let mut c2: Vec<(DirectedClassId, u64)> =
count_directed(&g2, &sel).into_iter().collect();
c1.sort_by_key(|(c, _)| c.0);
c2.sort_by_key(|(c, _)| c.0);
assert_eq!(c1, c2, "census must be relabelling-invariant, n={n} k={k}");
}
}
}