use super::*;
use std::cmp::Ordering;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Score {
primary: u64,
secondary: u64,
triangles: u64,
tetrahedra: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Candidate {
score: Score,
index: usize,
}
impl Ord for Candidate {
fn cmp(&self, other: &Self) -> Ordering {
self.score
.primary
.cmp(&other.score.primary)
.then(self.score.secondary.cmp(&other.score.secondary))
.then_with(|| other.index.cmp(&self.index))
}
}
impl PartialOrd for Candidate {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
fn tetrahedra(adj: &[Vec<AdjEntry>], cands: &[(usize, f64)]) -> u64 {
let mut total = 0u64;
for (p, &(x, _)) in cands.iter().enumerate() {
let list = &adj[x];
let mut i = 0usize;
let mut q = p + 1;
while i < list.len() && q < cands.len() {
let (y, d, _) = list[i];
match y.cmp(&cands[q].0) {
Ordering::Less => i += 1,
Ordering::Greater => q += 1,
Ordering::Equal => {
if d.is_finite() {
total = total.saturating_add(1);
}
i += 1;
q += 1;
}
}
}
}
total
}
fn score(adj: &[Vec<AdjEntry>], cands: &[(usize, f64)], objective: CollapseObjective) -> Score {
let triangles = cands.len() as u64;
let tetrahedra = match objective {
CollapseObjective::H1 => 0,
CollapseObjective::H2 => tetrahedra(adj, cands),
};
let (primary, secondary) = match objective {
CollapseObjective::H1 => (triangles, 0),
CollapseObjective::H2 => (tetrahedra, triangles),
};
Score {
primary,
secondary,
triangles,
tetrahedra,
}
}
pub fn collapse_dense_adaptive(
dist: &DistanceMatrix,
threshold: Option<f64>,
params: AdaptiveCollapseParams,
) -> Result<CollapsedRips> {
collapse_adaptive_in(dist, threshold, params)
}
pub fn collapse_sparse_adaptive(
dist: &SparseDistanceMatrix,
threshold: Option<f64>,
params: AdaptiveCollapseParams,
) -> Result<CollapsedRips> {
collapse_adaptive_in(dist, threshold, params)
}
pub(crate) fn collapse_adaptive_in<D: Distances>(
dist: &D,
threshold: Option<f64>,
params: AdaptiveCollapseParams,
) -> Result<CollapsedRips> {
AdaptiveExecution::new(prepare(dist, threshold)?, params).run()
}
enum PassPlan {
Candidates(Vec<Candidate>),
FixedPoint,
BudgetLimited,
}
struct AdaptiveExecution {
edges: Vec<EdgeRec>,
adj: Vec<Vec<AdjEntry>>,
run: Run,
params: AdaptiveCollapseParams,
stats: CollapseStats,
steps: Vec<RemovalStep>,
scratch: Scratch,
work_used: u64,
passes: usize,
}
impl AdaptiveExecution {
fn new(prepared: Prepared, params: AdaptiveCollapseParams) -> Self {
let count = prepared.edges.len();
Self {
edges: prepared.edges,
adj: prepared.adj,
run: prepared.run,
params,
stats: CollapseStats::new(count),
steps: Vec::new(),
scratch: Scratch::default(),
work_used: 0,
passes: 0,
}
}
fn run(mut self) -> Result<CollapsedRips> {
let budget_limited = loop {
self.passes += 1;
match self.plan_pass() {
PassPlan::FixedPoint => break false,
PassPlan::BudgetLimited => break true,
PassPlan::Candidates(candidates) => {
if self.retire_candidates(candidates) {
break true;
}
}
}
};
self.stats.epochs = self.passes;
self.stats.logical_tests = self.stats.edge_tests;
let completeness = if budget_limited {
CollapseCompleteness::BudgetLimited
} else {
CollapseCompleteness::CompleteFixedPoint
};
finish(
self.run,
Execution::Adaptive {
objective: self.params.objective,
completeness,
work_limit: self.params.work_limit,
work_used: self.work_used,
},
&self.edges,
self.steps,
self.stats,
CollapseTimings::default(),
)
}
fn plan_pass(&mut self) -> PassPlan {
let mut candidates = Vec::new();
for index in 0..self.edges.len() {
if !self.edges[index].alive {
continue;
}
if self.budget_exhausted() {
return PassPlan::BudgetLimited;
}
if let Some(candidate) = self.plan_candidate(index) {
candidates.push(candidate);
}
}
if candidates.is_empty() {
PassPlan::FixedPoint
} else {
candidates.sort_unstable_by(|a, b| b.cmp(a));
PassPlan::Candidates(candidates)
}
}
fn plan_candidate(&mut self, index: usize) -> Option<Candidate> {
self.charge_test();
let edge = &self.edges[index];
let witnesses = test_edge(
&self.adj,
edge.u,
edge.v,
edge.value,
self.run.terminal,
&mut self.scratch,
);
self.record_neighborhood();
witnesses.map(|_| {
self.stats.adaptive_score_evaluations += 1;
Candidate {
score: score(&self.adj, &self.scratch.cands, self.params.objective),
index,
}
})
}
fn retire_candidates(&mut self, candidates: Vec<Candidate>) -> bool {
for candidate in candidates {
self.stats.adaptive_queue_pops += 1;
if !self.edges[candidate.index].alive {
self.stats.adaptive_stale_pops += 1;
continue;
}
if self.budget_exhausted() {
return true;
}
self.retire_candidate(candidate.index);
}
false
}
fn retire_candidate(&mut self, index: usize) {
self.charge_test();
let edge = &self.edges[index];
let (u, v, value) = (edge.u, edge.v, edge.value);
let Some(witnesses) =
test_edge(&self.adj, u, v, value, self.run.terminal, &mut self.scratch)
else {
self.stats.adaptive_stale_pops += 1;
return;
};
self.record_neighborhood();
self.stats.adaptive_score_evaluations += 1;
let selected_score = score(&self.adj, &self.scratch.cands, self.params.objective);
self.edges[index].alive = false;
tombstone(&mut self.adj, u, v);
self.stats.witness_segments += witnesses.len();
self.stats.adaptive_triangles_removed = self
.stats
.adaptive_triangles_removed
.saturating_add(selected_score.triangles);
self.stats.adaptive_tetrahedra_removed = self
.stats
.adaptive_tetrahedra_removed
.saturating_add(selected_score.tetrahedra);
self.steps.push(RemovalStep {
u,
v,
value,
position: SchedulePosition::Sequence(self.steps.len() + 1),
witnesses,
});
}
fn budget_exhausted(&self) -> bool {
self.params
.work_limit
.is_some_and(|limit| self.work_used >= limit)
}
fn charge_test(&mut self) {
self.work_used += 1;
self.stats.edge_tests += 1;
}
fn record_neighborhood(&mut self) {
self.stats.max_common_neighborhood = self
.stats
.max_common_neighborhood
.max(self.scratch.cands.len());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::collapse::verify::{verify_dense, verify_sparse};
fn k4() -> DistanceMatrix {
DistanceMatrix::from_condensed(vec![1.0; 6]).unwrap()
}
#[test]
fn complete_k4_is_version_three_and_verifies() {
for objective in [CollapseObjective::H1, CollapseObjective::H2] {
let result = collapse_dense_adaptive(
&k4(),
None,
AdaptiveCollapseParams {
objective,
work_limit: None,
},
)
.unwrap();
assert_eq!(result.certificate.algorithm_version(), 3);
assert_eq!(result.certificate.objective(), Some(objective));
assert_eq!(
result.certificate.completeness(),
CollapseCompleteness::CompleteFixedPoint
);
assert_eq!(result.certificate.work_limit(), None);
assert_eq!(
result.certificate.work_used(),
result.stats.edge_tests as u64
);
assert_eq!(result.matrix.num_edges(), 3);
verify_dense(&k4(), None, &result).unwrap();
}
}
#[test]
fn zero_budget_returns_the_input_as_a_safe_partial_collapse() {
let result = collapse_dense_adaptive(
&k4(),
None,
AdaptiveCollapseParams {
objective: CollapseObjective::H2,
work_limit: Some(0),
},
)
.unwrap();
assert_eq!(
result.certificate.completeness(),
CollapseCompleteness::BudgetLimited
);
assert_eq!(result.certificate.work_used(), 0);
assert!(result.certificate.steps().is_empty());
assert_eq!(result.matrix.num_edges(), 6);
verify_dense(&k4(), None, &result).unwrap();
}
#[test]
fn dense_and_sparse_runs_are_identical() {
let dense = k4();
let sparse = SparseDistanceMatrix::from_triplets(
4,
&[
(0, 1, 1.0),
(0, 2, 1.0),
(0, 3, 1.0),
(1, 2, 1.0),
(1, 3, 1.0),
(2, 3, 1.0),
],
)
.unwrap();
let params = AdaptiveCollapseParams::default();
let a = collapse_dense_adaptive(&dense, None, params).unwrap();
let b = collapse_sparse_adaptive(&sparse, None, params).unwrap();
assert_eq!(
a.matrix.edges().collect::<Vec<_>>(),
b.matrix.edges().collect::<Vec<_>>()
);
assert_eq!(a.certificate, b.certificate);
assert_eq!(a.stats, b.stats);
verify_sparse(&sparse, None, &b).unwrap();
}
}