use std::collections::{HashMap, HashSet};
use duckdb::params;
use crate::facts::FactsDb;
use crate::options::TimeBucket;
use crate::{CodeLoreError, Options, Result};
pub(crate) fn good_commits_cte(bucket: Option<TimeBucket>, use_lineage: bool) -> String {
let physical_src = if use_lineage {
"changes_lineage"
} else {
"changes"
};
if let Some(b) = bucket {
let unit = b.as_sql_unit();
format!(
"good_commits AS (
SELECT bucket_rev AS rev FROM (
SELECT CAST(date_trunc('{unit}', m.date) AS TEXT) AS bucket_rev,
c.rev AS commit_rev,
COUNT(*) AS files
FROM {physical_src} c
INNER JOIN commits m ON m.rev = c.rev
GROUP BY c.rev, date_trunc('{unit}', m.date)
) per_commit_in_bucket
GROUP BY bucket_rev
HAVING MAX(files) <= ?
)"
)
} else {
format!(
"good_commits AS (
SELECT rev
FROM (SELECT rev, COUNT(*) AS files FROM {physical_src} GROUP BY rev) t
WHERE files <= ?
)"
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CouplingMemoKey {
max_changeset_size: u32,
min_revs: u32,
min_shared_revs: u32,
min_coupling_pct: u8,
max_coupling_pct: u8,
fisher_significance_bits: u64,
time_bucket: Option<TimeBucket>,
use_canonical_lineage: bool,
code_maat_compat: bool,
fdr_correction: bool,
}
impl CouplingMemoKey {
fn from_opts(opts: &Options) -> Self {
Self {
max_changeset_size: opts.max_changeset_size,
min_revs: opts.min_revs,
min_shared_revs: opts.min_shared_revs,
min_coupling_pct: opts.min_coupling_pct,
max_coupling_pct: opts.max_coupling_pct,
fisher_significance_bits: opts.fisher_significance.to_bits(),
time_bucket: opts.time_bucket,
use_canonical_lineage: opts.use_canonical_lineage,
code_maat_compat: opts.code_maat_compat,
fdr_correction: opts.fdr_correction,
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CouplingRow {
pub entity_a: String,
pub entity_b: String,
pub shared: u32,
pub revs_a: u32,
pub revs_b: u32,
pub average_revs: u32,
pub degree: f64,
pub fisher_p: f64,
}
#[must_use]
pub fn partner_index(rows: &[CouplingRow]) -> HashMap<String, HashSet<String>> {
let mut partners: HashMap<String, HashSet<String>> = HashMap::new();
for r in rows {
partners
.entry(r.entity_a.clone())
.or_default()
.insert(r.entity_b.clone());
partners
.entry(r.entity_b.clone())
.or_default()
.insert(r.entity_a.clone());
}
partners
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct CouplingAbsence {
pub touched_file: String,
pub expected_partner: String,
pub historical_coupling: f64,
pub fisher_p: f64,
pub historical_shared_revs: u32,
}
#[must_use]
pub fn compute_coupling_absences<S: std::hash::BuildHasher>(
base_coupling: &[CouplingRow],
pr_files: &HashSet<String, S>,
min_shared: u32,
fisher_p_gate: f64,
) -> Vec<CouplingAbsence> {
base_coupling
.iter()
.filter(|c| c.shared >= min_shared && c.fisher_p < fisher_p_gate)
.filter_map(|c| {
let a_in = pr_files.contains(&c.entity_a);
let b_in = pr_files.contains(&c.entity_b);
if a_in && !b_in {
Some(CouplingAbsence {
touched_file: c.entity_a.clone(),
expected_partner: c.entity_b.clone(),
historical_coupling: c.degree,
fisher_p: c.fisher_p,
historical_shared_revs: c.shared,
})
} else if b_in && !a_in {
Some(CouplingAbsence {
touched_file: c.entity_b.clone(),
expected_partner: c.entity_a.clone(),
historical_coupling: c.degree,
fisher_p: c.fisher_p,
historical_shared_revs: c.shared,
})
} else {
None
}
})
.collect()
}
fn source_table(opts: &Options) -> &'static str {
if opts.time_bucket.is_some() {
"changes_bucketed"
} else if opts.use_canonical_lineage {
"changes_lineage"
} else {
"changes"
}
}
fn build_coupling_sql(
src: &str,
code_maat_compat: bool,
bucket: Option<TimeBucket>,
use_lineage: bool,
) -> String {
let avg_revs_expr = if code_maat_compat {
"CAST(CEIL((fr_a.revs + fr_b.revs) / 2.0) AS UINTEGER)"
} else {
"(fr_a.revs + fr_b.revs) / 2"
};
let (file_revs_gate, pair_avg_gate) = if code_maat_compat {
(
"HAVING ? IS NOT NULL",
"AND (fr_a.revs + fr_b.revs) / 2.0 >= ?",
)
} else {
("HAVING revs >= ?", "AND ? IS NOT NULL")
};
let good_cte = good_commits_cte(bucket, use_lineage);
format!(
"WITH {good_cte},
filtered_changes AS (
-- Pre-filter `changes` by `good_commits` ONCE so both downstream
-- CTEs share the result. DuckDB materializes a CTE referenced
-- 2+ times, so this guarantees:
-- 1. `file_revs` reads the pre-filtered set (was already
-- joining good_commits inline).
-- 2. The `pairs` self-join is over the small filtered set
-- instead of the full `{src}` (which would be O(N²) on the
-- raw row count) — without this, DuckDB's planner *may*
-- push the filter down but isn't required to. On large
-- repos (changes >> good_commits) this difference is
-- decisive (e.g. 1M rows → trillion vs 100k² → 10B comps).
SELECT rev, path
FROM {src}
INNER JOIN good_commits USING(rev)
),
file_revs AS (
-- (rev, path) is the changes PK; COUNT(rev) == COUNT(DISTINCT rev)
-- per path. Plain COUNT skips DuckDB's distinct-tracking overhead.
SELECT path, COUNT(rev) AS revs
FROM filtered_changes
GROUP BY path
{file_revs_gate}
),
pairs AS (
-- The triple (a.rev, a.path, b.path) is unique per (path_a,
-- path_b) group because (rev, path) is the changes PK and
-- `a.rev = b.rev` collapses the cardinality to one rev per
-- joined row. COUNT(a.rev) == COUNT(*) == COUNT(DISTINCT a.rev)
-- here; plain COUNT skips DuckDB's distinct-tracking overhead.
-- Self-join is over the pre-filtered set, not the raw `{src}` —
-- see the `filtered_changes` CTE comment above for the
-- complexity rationale.
SELECT
a.path AS path_a,
b.path AS path_b,
COUNT(a.rev) AS shared
FROM filtered_changes a
INNER JOIN filtered_changes b ON a.rev = b.rev AND a.path < b.path
GROUP BY a.path, b.path
HAVING shared >= ?
)
SELECT
p.path_a,
p.path_b,
p.shared,
fr_a.revs AS revs_a,
fr_b.revs AS revs_b,
{avg_revs_expr} AS average_revs,
100.0 * p.shared / NULLIF((fr_a.revs + fr_b.revs) / 2.0, 0) AS degree
FROM pairs p
INNER JOIN file_revs fr_a ON fr_a.path = p.path_a
INNER JOIN file_revs fr_b ON fr_b.path = p.path_b
WHERE 100.0 * p.shared / NULLIF((fr_a.revs + fr_b.revs) / 2.0, 0) >= ?
AND 100.0 * p.shared / NULLIF((fr_a.revs + fr_b.revs) / 2.0, 0) <= ?
{pair_avg_gate}
ORDER BY degree DESC, average_revs DESC, p.path_a ASC, p.path_b ASC"
)
}
fn build_total_commits_sql(bucket: Option<TimeBucket>, use_lineage: bool) -> String {
let good_cte = good_commits_cte(bucket, use_lineage);
format!(
"WITH {good_cte}
SELECT COUNT(*) FROM good_commits"
)
}
fn fisher_two_tail(shared: u32, revs_a: u32, revs_b: u32, total: u32) -> Option<f64> {
if shared > revs_a || shared > revs_b {
return None;
}
let union_ab = revs_a.saturating_add(revs_b).saturating_sub(shared);
if union_ab > total {
return None;
}
let a = shared;
let b = revs_a - shared; let c = revs_b - shared; let d = total - union_ab; crate::stats::fisher_two_tail_pvalue(a, b, c, d)
}
#[tracing::instrument(name = "coupling", skip_all, fields(min_revs = opts.min_revs))]
pub fn run_coupling(db: &FactsDb, opts: &Options) -> Result<Vec<CouplingRow>> {
let memo_key = CouplingMemoKey::from_opts(opts);
let memo = db.analysis_memo::<crate::analyses::memo::CouplingMemo>();
if let Some(cached) = memo.get(&memo_key) {
let mut out = (*cached).clone();
if let Some(n) = opts.rows_limit {
out.truncate(n as usize);
}
return Ok(out);
}
crate::analyses::lineage::materialize_source(db, opts)?;
let src = source_table(opts);
let total_sql = build_total_commits_sql(opts.time_bucket, opts.use_canonical_lineage);
let total_commits: i64 = db
.conn()
.query_row(&total_sql, params![opts.max_changeset_size], |r| r.get(0))
.map_err(|e| CodeLoreError::Analysis(format!("total commits query: {e}")))?;
let total = u32::try_from(total_commits).unwrap_or(u32::MAX);
let coupling_sql = build_coupling_sql(
src,
opts.code_maat_compat,
opts.time_bucket,
opts.use_canonical_lineage,
);
crate::analyses::query::explain_if_requested(
db,
&coupling_sql,
params![
opts.max_changeset_size,
opts.min_revs,
opts.min_shared_revs,
opts.min_coupling_pct,
opts.max_coupling_pct,
opts.min_revs,
],
"coupling",
opts,
)?;
let mut stmt = db
.conn()
.prepare(&coupling_sql)
.map_err(|e| CodeLoreError::Analysis(format!("prepare coupling: {e}")))?;
let out = collect_fisher_filtered(&mut stmt, total, opts)?;
let full = std::rc::Rc::new(out);
memo.put(memo_key, std::rc::Rc::clone(&full));
let mut out = (*full).clone();
if let Some(n) = opts.rows_limit {
out.truncate(n as usize);
}
Ok(out)
}
fn collect_fisher_filtered(
stmt: &mut duckdb::Statement<'_>,
total: u32,
opts: &Options,
) -> Result<Vec<CouplingRow>> {
let raw_rows = stmt
.query_map(
params![
opts.max_changeset_size,
opts.min_revs,
opts.min_shared_revs,
opts.min_coupling_pct,
opts.max_coupling_pct,
opts.min_revs,
],
|r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, i64>(2)?,
r.get::<_, i64>(3)?,
r.get::<_, i64>(4)?,
r.get::<_, i64>(5)?,
r.get::<_, f64>(6)?,
))
},
)
.map_err(|e| CodeLoreError::Analysis(format!("query coupling: {e}")))?;
let mut candidates = Vec::new();
for raw in raw_rows {
let (path_a, path_b, shared_raw, count_a, count_b, avg_raw, degree) =
raw.map_err(|e| CodeLoreError::Analysis(format!("collect coupling row: {e}")))?;
let shared = u32::try_from(shared_raw).unwrap_or(u32::MAX);
let revs_a = u32::try_from(count_a).unwrap_or(u32::MAX);
let revs_b = u32::try_from(count_b).unwrap_or(u32::MAX);
let average_revs = u32::try_from(avg_raw).unwrap_or(u32::MAX);
let Some(fisher_p) = fisher_two_tail(shared, revs_a, revs_b, total) else {
continue; };
candidates.push(CouplingRow {
entity_a: path_a,
entity_b: path_b,
shared,
revs_a,
revs_b,
average_revs,
degree,
fisher_p,
});
}
Ok(select_significant(candidates, opts))
}
fn select_significant(candidates: Vec<CouplingRow>, opts: &Options) -> Vec<CouplingRow> {
if opts.code_maat_compat {
return candidates;
}
if opts.fdr_correction {
let pvalues: Vec<f64> = candidates.iter().map(|r| r.fisher_p).collect();
let cutoff = crate::stats::bh_fdr_threshold(&pvalues, opts.fisher_significance);
return candidates
.into_iter()
.filter(|r| r.fisher_p <= cutoff)
.collect();
}
candidates
.into_iter()
.filter(|r| r.fisher_p < opts.fisher_significance)
.collect()
}
pub fn run_coupling_scoped(
db: &FactsDb,
opts: &Options,
changes_source: &str,
) -> Result<Vec<CouplingRow>> {
crate::analyses::lineage::materialize_source(db, opts)?;
let total_sql = format!(
"WITH good_commits AS (
SELECT rev
FROM (SELECT rev, COUNT(*) AS files FROM {changes_source} GROUP BY rev) t
WHERE files <= ?
)
SELECT COUNT(*) FROM good_commits"
);
let total_commits: i64 = db
.conn()
.query_row(&total_sql, params![opts.max_changeset_size], |r| r.get(0))
.map_err(|e| CodeLoreError::Analysis(format!("total commits query: {e}")))?;
let total = u32::try_from(total_commits).unwrap_or(u32::MAX);
let coupling_sql = build_coupling_sql(
changes_source,
opts.code_maat_compat,
opts.time_bucket,
opts.use_canonical_lineage,
);
crate::analyses::query::explain_if_requested(
db,
&coupling_sql,
params![
opts.max_changeset_size,
opts.min_revs,
opts.min_shared_revs,
opts.min_coupling_pct,
opts.max_coupling_pct,
opts.min_revs,
],
"coupling",
opts,
)?;
let mut stmt = db
.conn()
.prepare(&coupling_sql)
.map_err(|e| CodeLoreError::Analysis(format!("prepare coupling: {e}")))?;
let mut out = collect_fisher_filtered(&mut stmt, total, opts)?;
if let Some(n) = opts.rows_limit {
out.truncate(n as usize);
}
Ok(out)
}
pub fn count_coupling_nodes(db: &FactsDb, opts: &Options) -> Result<u64> {
let src = source_table(opts);
let use_lineage =
opts.use_canonical_lineage && opts.time_bucket.is_none() && src == "changes_lineage";
if use_lineage {
crate::analyses::lineage::materialize_if_needed(db, opts)?;
}
let sql = format!(
"WITH good_commits AS (
SELECT rev FROM (
SELECT rev, COUNT(path) AS n
FROM {src}
GROUP BY rev
) WHERE n <= ?
),
file_revs AS (
SELECT path, COUNT(rev) AS revs
FROM {src}
INNER JOIN good_commits USING(rev)
GROUP BY path
HAVING revs >= ?
)
SELECT COUNT(*) FROM file_revs"
);
let count: i64 = db
.conn()
.query_row(&sql, params![opts.max_changeset_size, opts.min_revs], |r| {
r.get::<_, i64>(0)
})
.map_err(|e| CodeLoreError::Analysis(format!("count coupling nodes: {e}")))?;
Ok(u64::try_from(count).unwrap_or(0))
}
#[must_use]
#[allow(clippy::cast_precision_loss)] pub fn density(node_count: u64, edge_count: usize) -> f64 {
if node_count < 2 {
return 0.0;
}
let max_edges = node_count.saturating_mul(node_count - 1) / 2;
if max_edges == 0 {
return 0.0;
}
let e = edge_count as f64;
let max = max_edges as f64;
(e / max).clamp(0.0, 1.0)
}
#[cfg(test)]
mod density_tests {
use super::density;
#[test]
fn density_zero_when_fewer_than_two_nodes() {
assert!(density(0, 0).abs() < f64::EPSILON);
assert!(density(1, 0).abs() < f64::EPSILON);
assert!(density(1, 5).abs() < f64::EPSILON);
}
#[test]
fn density_zero_on_empty_edge_set() {
assert!(density(100, 0).abs() < f64::EPSILON);
}
#[test]
fn density_one_when_complete_graph() {
assert!((density(4, 6) - 1.0).abs() < f64::EPSILON);
assert!((density(4, 99) - 1.0).abs() < f64::EPSILON);
}
#[test]
fn density_matches_empirical_codelore_repo() {
let d = density(59, 47);
assert!(
(d - 0.0275).abs() < 0.001,
"expected ~0.0275, got {d} — re-measure if the fixture has drifted"
);
}
}
#[cfg(test)]
mod fisher_two_tail_invariant_tests {
use super::fisher_two_tail;
#[test]
fn happy_path_returns_some_in_unit_interval() {
let p = fisher_two_tail(5, 10, 12, 100).expect("happy-path inputs must produce Some");
assert!((0.0..=1.0).contains(&p), "p={p} should be in [0,1]");
}
#[test]
fn shared_exceeds_revs_a_returns_none() {
assert_eq!(fisher_two_tail(11, 10, 12, 100), None);
}
#[test]
fn shared_exceeds_revs_b_returns_none() {
assert_eq!(fisher_two_tail(13, 20, 12, 100), None);
}
#[test]
fn union_exceeds_total_returns_none() {
assert_eq!(fisher_two_tail(5, 60, 50, 100), None);
}
#[test]
fn union_equals_total_is_consistent() {
let p = fisher_two_tail(5, 60, 45, 100);
assert!(
p.is_some(),
"|A∪B| == total is the boundary of consistency, must accept"
);
}
#[test]
fn perfect_coupling_is_consistent() {
let p = fisher_two_tail(5, 5, 5, 100).expect("perfect coupling must accept");
assert!(
p < 0.01,
"perfect coupling on 5/100 should be highly significant: p={p}"
);
}
#[test]
fn shared_zero_is_consistent() {
let p = fisher_two_tail(0, 10, 12, 100);
assert!(p.is_some());
}
#[test]
fn overflow_on_revs_sum_returns_none() {
assert_eq!(
fisher_two_tail(0, u32::MAX, u32::MAX, u32::MAX),
None,
"pathological u32::MAX inputs must not panic + must reject"
);
}
}
#[cfg(test)]
mod select_significant_tests {
use super::{CouplingRow, select_significant};
use crate::Options;
fn candidate(fisher_p: f64) -> CouplingRow {
CouplingRow {
entity_a: format!("a{fisher_p}"),
entity_b: format!("b{fisher_p}"),
shared: 1,
revs_a: 1,
revs_b: 1,
average_revs: 1,
degree: 0.0,
fisher_p,
}
}
fn pvalues(rows: &[CouplingRow]) -> Vec<f64> {
rows.iter().map(|r| r.fisher_p).collect()
}
#[test]
fn fdr_on_is_strict_subset_of_off() {
let candidates = || {
vec![
candidate(0.001),
candidate(0.03),
candidate(0.045),
candidate(0.5),
]
};
let off = select_significant(
candidates(),
&Options {
fisher_significance: 0.05,
fdr_correction: false,
..Options::default()
},
);
let on = select_significant(
candidates(),
&Options {
fisher_significance: 0.05,
fdr_correction: true,
..Options::default()
},
);
assert_eq!(pvalues(&off), vec![0.001, 0.03, 0.045]);
assert_eq!(pvalues(&on), vec![0.001]);
assert!(
on.len() < off.len(),
"FDR result must be a strict subset of the per-test result"
);
}
#[test]
fn fdr_off_is_byte_identical_per_test_gate() {
let candidates = vec![
candidate(0.01),
candidate(0.049),
candidate(0.05),
candidate(0.2),
];
let off = select_significant(
candidates,
&Options {
fisher_significance: 0.05,
fdr_correction: false,
..Options::default()
},
);
assert_eq!(pvalues(&off), vec![0.01, 0.049]);
}
#[test]
fn compat_bypasses_both_gates() {
let candidates = vec![candidate(0.01), candidate(0.9), candidate(0.99)];
let kept = select_significant(
candidates,
&Options {
fisher_significance: 0.05,
fdr_correction: true,
code_maat_compat: true,
..Options::default()
},
);
assert_eq!(pvalues(&kept), vec![0.01, 0.9, 0.99]);
}
}