use std::collections::HashMap;
use crate::analyses::import_graph::{
ImportGraph, build_import_graph, build_import_graph_seeded, graph_metrics, tarjan_scc,
};
use crate::analyses::lineage;
use crate::analyses::query::query_map_collect;
use crate::facts::FactsDb;
use crate::{Options, Result};
const TRIAL_REMOVAL_BOUND: usize = 64;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CycleHealthRow {
pub cycle_id: u32,
pub size: u32,
pub members_preview: String,
pub heat_pct: f64,
pub verdict: String,
pub extract_candidate: String,
pub predicted_pc_drop: Option<f64>,
}
struct WindowActivity {
churn: f64,
revs: i64,
}
#[tracing::instrument(name = "cycle-health", skip_all, fields(window_days = opts.window_days))]
pub fn run_cycle_health(db: &FactsDb, opts: &Options) -> Result<Vec<CycleHealthRow>> {
let graph = build_import_graph(db)?;
if graph.is_empty() {
return Ok(Vec::new());
}
let mut cycles: Vec<Vec<usize>> = tarjan_scc(&graph.adj)
.into_iter()
.filter(|c| c.len() > 1)
.collect();
if cycles.is_empty() {
return Ok(Vec::new());
}
for members in &mut cycles {
members.sort_by(|&a, &b| graph.id_to_path[a].cmp(&graph.id_to_path[b]));
}
cycles.sort_by(|a, b| {
b.len()
.cmp(&a.len())
.then_with(|| graph.id_to_path[a[0]].cmp(&graph.id_to_path[b[0]]))
});
let window = window_activity(db, opts)?;
let total_churn: f64 = window.values().map(|w| w.churn).sum();
let full_pc = graph_metrics(&graph).propagation_cost;
let mut out: Vec<CycleHealthRow> = Vec::with_capacity(cycles.len());
for (i, members) in cycles.iter().enumerate() {
let paths: Vec<&str> = members
.iter()
.map(|&id| graph.id_to_path[id].as_str())
.collect();
let member_churn: f64 = paths
.iter()
.filter_map(|p| window.get(*p))
.map(|w| w.churn)
.sum();
let member_revs: i64 = paths
.iter()
.filter_map(|p| window.get(*p))
.map(|w| w.revs)
.sum();
let heat_pct = if total_churn > 0.0 {
100.0 * member_churn / total_churn
} else {
0.0
};
let (candidate, predicted_pc_drop) = if members.len() <= TRIAL_REMOVAL_BOUND {
let cand = extraction_candidate(&graph.adj, members);
let without = graph_metrics(&graph_without_node(&graph, cand));
(cand, Some(full_pc - without.propagation_cost))
} else {
(highest_degree_member(&graph.adj, members), None)
};
out.push(CycleHealthRow {
cycle_id: u32::try_from(i).unwrap_or(u32::MAX),
size: u32::try_from(members.len()).unwrap_or(u32::MAX),
members_preview: members_preview(&paths),
heat_pct,
verdict: if member_revs > 0 { "live" } else { "fossil" }.to_owned(),
extract_candidate: graph.id_to_path[candidate].clone(),
predicted_pc_drop,
});
}
out.sort_by(|a, b| {
b.heat_pct
.total_cmp(&a.heat_pct)
.then_with(|| b.size.cmp(&a.size))
.then_with(|| a.cycle_id.cmp(&b.cycle_id))
});
if let Some(limit) = opts.rows_limit {
out.truncate(limit as usize);
}
Ok(out)
}
fn members_preview(paths: &[&str]) -> String {
let head = paths.iter().take(3).copied().collect::<Vec<_>>().join(", ");
if paths.len() > 3 {
format!("{head} +{} more", paths.len() - 3)
} else {
head
}
}
fn window_activity(db: &FactsDb, opts: &Options) -> Result<HashMap<String, WindowActivity>> {
lineage::materialize_if_needed(db, opts)?;
let src = lineage::source_table(opts);
let now_anchor = crate::analyses::query::clamped_now_anchor("date");
let sql = format!(
"SELECT ch.path,
CAST(COALESCE(SUM(ch.loc_added + ch.loc_deleted), 0) AS DOUBLE) AS churn,
COUNT(ch.rev) AS revs
FROM {src} ch
JOIN commits co ON co.rev = ch.rev
WHERE co.date >= (SELECT {now_anchor} FROM commits) - INTERVAL (?) DAY
GROUP BY ch.path"
);
let rows: Vec<(String, f64, i64)> = query_map_collect(
db,
&sql,
duckdb::params![i64::from(opts.window_days)],
"cycle-health window activity",
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)?;
Ok(rows
.into_iter()
.map(|(path, churn, revs)| (path, WindowActivity { churn, revs }))
.collect())
}
pub(crate) fn extraction_candidate(adj: &[Vec<usize>], scc: &[usize]) -> usize {
let index: HashMap<usize, usize> = scc.iter().enumerate().map(|(i, &m)| (m, i)).collect();
let mut best = scc[0];
let mut best_score = (usize::MAX, usize::MAX);
for (ri, &removed) in scc.iter().enumerate() {
let local = |i: usize| if i < ri { i } else { i - 1 };
let mut sub: Vec<Vec<usize>> = vec![Vec::new(); scc.len() - 1];
for (ui, &u) in scc.iter().enumerate() {
if ui == ri {
continue;
}
for v in &adj[u] {
if let Some(&vi) = index.get(v)
&& vi != ri
{
sub[local(ui)].push(local(vi));
}
}
}
let mut largest = 0usize;
let mut cyclic = 0usize;
for comp in tarjan_scc(&sub) {
if comp.len() >= 2 {
largest = largest.max(comp.len());
cyclic += comp.len();
}
}
if (largest, cyclic) < best_score {
best_score = (largest, cyclic);
best = removed;
}
}
best
}
fn highest_degree_member(adj: &[Vec<usize>], members: &[usize]) -> usize {
let index: HashMap<usize, usize> = members.iter().enumerate().map(|(i, &m)| (m, i)).collect();
let mut deg = vec![0usize; members.len()];
for (ui, &u) in members.iter().enumerate() {
for v in &adj[u] {
if let Some(&vi) = index.get(v) {
deg[ui] += 1; deg[vi] += 1; }
}
}
let mut best = 0;
for i in 1..members.len() {
if deg[i] > deg[best] {
best = i;
}
}
members[best]
}
fn graph_without_node(graph: &ImportGraph, node: usize) -> ImportGraph {
let mut seeds: Vec<String> = Vec::with_capacity(graph.id_to_path.len().saturating_sub(1));
let mut edges: Vec<(String, String)> = Vec::new();
for (u, targets) in graph.adj.iter().enumerate() {
if u == node {
continue;
}
seeds.push(graph.id_to_path[u].clone());
for &v in targets {
if v == node {
continue;
}
edges.push((graph.id_to_path[u].clone(), graph.id_to_path[v].clone()));
}
}
build_import_graph_seeded(&seeds, &edges)
}
#[cfg(test)]
mod tests {
use super::{extraction_candidate, highest_degree_member};
#[test]
fn extraction_candidate_picks_the_acyclic_cut() {
let adj = vec![vec![1], vec![2, 0], vec![0]];
assert_eq!(extraction_candidate(&adj, &[0, 1, 2]), 0);
}
#[test]
fn extraction_candidate_finds_the_articulation_member() {
let adj = vec![vec![1], vec![0, 2], vec![3], vec![1]];
assert_eq!(extraction_candidate(&adj, &[0, 1, 2, 3]), 1);
}
#[test]
fn extraction_candidate_tie_breaks_by_member_order() {
let adj = vec![vec![1], vec![0]];
assert_eq!(extraction_candidate(&adj, &[0, 1]), 0);
assert_eq!(extraction_candidate(&adj, &[1, 0]), 1);
}
#[test]
fn degree_fallback_picks_the_hub() {
let adj = vec![vec![1, 2, 3], vec![0], vec![0], vec![0]];
assert_eq!(highest_degree_member(&adj, &[0, 1, 2, 3]), 0);
let two = vec![vec![1], vec![0]];
assert_eq!(highest_degree_member(&two, &[0, 1]), 0);
assert_eq!(highest_degree_member(&two, &[1, 0]), 1);
}
}