use petgraph::graph::{NodeIndex, UnGraph};
use rand::Rng;
use crate::canonical::{all_connected_classes, ClassId};
use crate::catalog::{count_motif, Induced, Pattern};
use crate::census::{count, Selector};
use crate::rim::null_model::{configuration_model_simple, double_edge_swap};
use crate::snapshot::{GraphAdapter, Snapshot};
#[derive(Clone, Debug)]
pub enum NullModel {
DegreePreserving {
n_swaps_per_edge: usize,
},
ConfigurationModel,
}
#[derive(Clone, Debug)]
pub struct SignificanceEntry {
pub observed: u64,
pub null_mean: f64,
pub null_std: f64,
pub z_score: f64,
pub p_value_over: f64,
}
#[derive(Clone, Debug)]
pub struct SignificanceProfile {
pub entries: Vec<(ClassId, SignificanceEntry)>,
pub z_scores: Vec<f64>,
pub normalized: Option<Vec<f64>>,
}
pub fn compute_significance_stats(observed: u64, null_counts: &[u64]) -> SignificanceEntry {
assert!(
!null_counts.is_empty(),
"null_counts must be non-empty; got empty slice"
);
let n = null_counts.len() as f64;
let null_mean = null_counts.iter().sum::<u64>() as f64 / n;
let null_var = null_counts
.iter()
.map(|&c| {
let d = c as f64 - null_mean;
d * d
})
.sum::<f64>()
/ n;
let null_std = null_var.sqrt();
let z_score = if null_std == 0.0 {
let obs_f = observed as f64;
match obs_f.partial_cmp(&null_mean) {
Some(std::cmp::Ordering::Equal) => 0.0,
Some(std::cmp::Ordering::Greater) => f64::INFINITY,
_ => f64::NEG_INFINITY,
}
} else {
(observed as f64 - null_mean) / null_std
};
let p_value_over = null_counts.iter().filter(|&&c| c >= observed).count() as f64 / n;
SignificanceEntry {
observed,
null_mean,
null_std,
z_score,
p_value_over,
}
}
fn to_ungraph<G: GraphAdapter>(g: G) -> UnGraph<(), ()> {
let snap = Snapshot::new(g);
let n = snap.len();
let mut out = UnGraph::with_capacity(n, 0);
for _ in 0..n {
out.add_node(());
}
for i in 0..n {
for &j in snap.neighbors(i) {
if i < j {
out.add_edge(NodeIndex::new(i), NodeIndex::new(j), ());
}
}
}
out
}
fn make_null<R: Rng>(base: &UnGraph<(), ()>, model: &NullModel, rng: &mut R) -> UnGraph<(), ()> {
match model {
NullModel::DegreePreserving { n_swaps_per_edge } => {
let n_swaps = base.edge_count().saturating_mul(*n_swaps_per_edge);
double_edge_swap(base, n_swaps, rng)
}
NullModel::ConfigurationModel => {
let degree_seq: Vec<usize> = (0..base.node_count())
.map(|i| base.neighbors(NodeIndex::new(i)).count())
.collect();
configuration_model_simple(°ree_seq, rng)
}
}
}
pub fn motif_significance<G, R>(
graph: G,
targets: &[(&str, &Pattern, Induced)],
ensemble_size: usize,
null_model: NullModel,
rng: &mut R,
) -> Vec<(String, SignificanceEntry)>
where
G: GraphAdapter,
R: Rng,
{
assert!(ensemble_size > 0, "ensemble_size must be >= 1");
let base = to_ungraph(graph);
let observed: Vec<u64> = targets
.iter()
.map(|(_, pat, ind)| count_motif(&base, pat, *ind))
.collect();
let mut null_counts: Vec<Vec<u64>> = vec![Vec::with_capacity(ensemble_size); targets.len()];
for _ in 0..ensemble_size {
let ng = make_null(&base, &null_model, rng);
for (i, (_, pat, ind)) in targets.iter().enumerate() {
null_counts[i].push(count_motif(&ng, pat, *ind));
}
}
targets
.iter()
.enumerate()
.map(|(i, (name, _, _))| {
(
name.to_string(),
compute_significance_stats(observed[i], &null_counts[i]),
)
})
.collect()
}
pub fn census_significance_profile<G, R>(
graph: G,
k: usize,
ensemble_size: usize,
null_model: NullModel,
rng: &mut R,
normalize: bool,
) -> SignificanceProfile
where
G: GraphAdapter,
R: Rng,
{
assert!(ensemble_size > 0, "ensemble_size must be >= 1");
let sel = Selector::connected_k_subsets(k);
let base = to_ungraph(graph);
let obs_census = count(&base, &sel);
let mut null_censuses: Vec<std::collections::HashMap<ClassId, u64>> =
Vec::with_capacity(ensemble_size);
for _ in 0..ensemble_size {
let ng = make_null(&base, &null_model, rng);
null_censuses.push(count(&ng, &sel));
}
let mut all_masks: Vec<u64> = if k <= 5 {
all_connected_classes(k)
} else {
let mut s: std::collections::HashSet<u64> = obs_census.keys().map(|c| c.0).collect();
for nc in &null_censuses {
s.extend(nc.keys().map(|c| c.0));
}
s.into_iter().collect()
};
all_masks.sort_unstable();
let class_ids: Vec<ClassId> = all_masks.iter().map(|&m| ClassId(m)).collect();
let entries: Vec<(ClassId, SignificanceEntry)> = class_ids
.iter()
.map(|&cid| {
let obs = obs_census.get(&cid).copied().unwrap_or(0);
let nulls: Vec<u64> = null_censuses
.iter()
.map(|nc| nc.get(&cid).copied().unwrap_or(0))
.collect();
(cid, compute_significance_stats(obs, &nulls))
})
.collect();
let z_scores: Vec<f64> = entries.iter().map(|(_, e)| e.z_score).collect();
let normalized = if normalize {
if z_scores.iter().any(|z| !z.is_finite()) {
None
} else {
let norm_sq: f64 = z_scores.iter().map(|&z| z * z).sum();
let norm = norm_sq.sqrt();
if norm == 0.0 {
Some(z_scores.clone())
} else {
Some(z_scores.iter().map(|&z| z / norm).collect())
}
}
} else {
None
};
SignificanceProfile {
entries,
z_scores,
normalized,
}
}