use codelore_lib::Options;
use codelore_lib::analyses::coupling::run_coupling;
use codelore_lib::facts::FactsDb;
use codelore_lib::repo::GixRepo;
#[test]
fn coupling_for_tiny_repo() {
let tiny = codelore_lib::test_support::tiny_repo::build();
let repo = GixRepo::open(tiny.dir.path()).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: tiny.dir.path().to_path_buf(),
min_revs: 1,
min_shared_revs: 1,
min_coupling_pct: 0,
fisher_significance: 1.0, ..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
let rows = run_coupling(&db, &opts).expect("run");
assert!(
rows.is_empty() || rows.iter().all(|r| r.shared >= 1),
"any coupling row must have at least 1 shared commit"
);
}
fn build_trio_repo(n_shared: usize) -> tempfile::TempDir {
use std::process::Command;
fn git(path: &std::path::Path, date: &str, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.env("GIT_AUTHOR_DATE", date)
.env("GIT_COMMITTER_DATE", date)
.status()
.expect("git");
assert!(status.success());
}
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path();
git(
path,
"2026-01-01T00:00:00Z",
&["init", "-b", "main", "--quiet"],
);
git(
path,
"2026-01-01T00:00:00Z",
&["config", "user.email", "t@t"],
);
git(
path,
"2026-01-01T00:00:00Z",
&["config", "user.name", "Trio"],
);
let mut day = 1u32;
let mut commit = |files: &[&str], tag: &str| {
let date = format!("2026-01-{day:02}T12:00:00Z");
for name in files {
std::fs::write(path.join(name), format!("{name}-{tag}")).unwrap();
}
git(path, &date, &["add", "."]);
git(path, &date, &["commit", "-m", tag, "--quiet"]);
day += 1;
};
for i in 1..=n_shared {
commit(&["a.txt", "b.txt", "c.txt"], &format!("trio{i}"));
}
for i in 1..=2 {
commit(&["a.txt"], &format!("soloA{i}"));
}
for i in 1..=2 {
commit(&["b.txt"], &format!("soloB{i}"));
}
for i in 1..=2 {
commit(&["c.txt"], &format!("soloC{i}"));
}
for i in 1..=3 {
commit(&["x.txt"], &format!("x{i}"));
}
dir
}
#[test]
fn coupling_memo_is_transparent_and_keyed() {
let dir = build_trio_repo(8);
let repo = GixRepo::open(dir.path()).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: dir.path().to_path_buf(),
min_revs: 1,
min_shared_revs: 1,
min_coupling_pct: 0,
max_coupling_pct: 100,
fisher_significance: 1.0,
use_canonical_lineage: false,
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
let first = run_coupling(&db, &opts).expect("first");
let second = run_coupling(&db, &opts).expect("second (memo hit)");
assert_eq!(first.len(), 3, "trio repo should yield (a,b),(a,c),(b,c)");
assert_eq!(first.len(), second.len(), "memo hit changed row count");
for (a, b) in first.iter().zip(second.iter()) {
assert_eq!(a.entity_a, b.entity_a);
assert_eq!(a.entity_b, b.entity_b);
assert_eq!(a.shared, b.shared);
assert_eq!(a.revs_a, b.revs_a);
assert_eq!(a.revs_b, b.revs_b);
assert_eq!(a.average_revs, b.average_revs);
assert!((a.degree - b.degree).abs() < f64::EPSILON);
assert!((a.fisher_p - b.fisher_p).abs() < f64::EPSILON);
}
let opts_no_sig = Options {
fisher_significance: 0.0,
..opts.clone()
};
let no_sig = run_coupling(&db, &opts_no_sig).expect("no-sig");
assert!(
no_sig.is_empty(),
"fisher_significance=0.0 is a distinct key and must recompute to empty, \
not serve the cached 3-row baseline; got {} rows",
no_sig.len()
);
let third = run_coupling(&db, &opts).expect("third (original key)");
assert_eq!(
third.len(),
first.len(),
"original key's entry was clobbered by the distinct-key call"
);
}
#[test]
fn coupling_memo_row_limit_does_not_poison_full_result() {
let dir = build_trio_repo(8);
let repo = GixRepo::open(dir.path()).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let base = Options {
repo_path: dir.path().to_path_buf(),
min_revs: 1,
min_shared_revs: 1,
min_coupling_pct: 0,
max_coupling_pct: 100,
fisher_significance: 1.0,
use_canonical_lineage: false,
..Options::default()
};
db.ingest(&repo, &base).expect("ingest");
let full = run_coupling(&db, &base).expect("full");
assert_eq!(full.len(), 3, "trio repo should yield 3 pairs");
let capped = run_coupling(
&db,
&Options {
rows_limit: Some(1),
..base.clone()
},
)
.expect("capped");
assert_eq!(capped.len(), 1, "rows_limit=1 must truncate to 1");
assert_eq!(capped[0].entity_a, full[0].entity_a);
assert_eq!(capped[0].entity_b, full[0].entity_b);
let full_again = run_coupling(&db, &base).expect("full again");
assert_eq!(
full_again.len(),
full.len(),
"rows_limit poisoned the shared memo entry"
);
}
#[test]
fn coupling_struct_shape() {
use codelore_lib::analyses::coupling::CouplingRow;
let row = CouplingRow {
entity_a: "a.rs".into(),
entity_b: "b.rs".into(),
shared: 4,
revs_a: 5,
revs_b: 5,
average_revs: 5,
degree: 80.0,
fisher_p: 0.01,
};
assert_eq!(row.shared, 4);
assert!(row.degree > 70.0);
assert!(row.fisher_p < 0.05);
assert_eq!(row.entity_a, "a.rs");
assert_eq!(row.entity_b, "b.rs");
}
#[test]
fn coupling_respects_min_shared_revs() {
let tiny = codelore_lib::test_support::tiny_repo::build();
let repo = GixRepo::open(tiny.dir.path()).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: tiny.dir.path().to_path_buf(),
min_revs: 1,
min_shared_revs: 2, min_coupling_pct: 0,
fisher_significance: 1.0,
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
let rows = run_coupling(&db, &opts).expect("run");
for row in &rows {
assert!(
row.shared >= 2,
"min_shared_revs=2 violated: shared={} for {}<->{}",
row.shared,
row.entity_a,
row.entity_b
);
}
}
#[test]
fn coupling_respects_max_coupling_pct() {
let diff_repo = codelore_lib::test_support::differential_repo::build();
let repo = GixRepo::open(diff_repo.dir.path()).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let opts_all = Options {
repo_path: diff_repo.dir.path().to_path_buf(),
min_revs: 1,
min_shared_revs: 1,
min_coupling_pct: 0,
max_coupling_pct: 100,
fisher_significance: 1.0,
..Options::default()
};
db.ingest(&repo, &opts_all).expect("ingest");
let baseline = run_coupling(&db, &opts_all).expect("baseline");
let max_observed = baseline.iter().map(|r| r.degree).fold(0.0_f64, f64::max);
assert!(
max_observed > 0.0,
"differential_repo should produce at least one coupled pair with degree > 0; \
got {} rows max degree = {max_observed}",
baseline.len()
);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let cap_pct = (max_observed / 2.0).floor() as u8;
let opts_capped = Options {
max_coupling_pct: cap_pct,
..opts_all.clone()
};
let capped = run_coupling(&db, &opts_capped).expect("capped");
assert!(
capped.len() < baseline.len(),
"max_coupling_pct={cap_pct} should drop ≥1 pair; baseline={}, capped={}",
baseline.len(),
capped.len()
);
for row in &capped {
assert!(
row.degree <= f64::from(cap_pct),
"row degree={} exceeds cap={cap_pct} for {}<->{}",
row.degree,
row.entity_a,
row.entity_b
);
}
}
#[test]
fn coupling_fisher_significance_filter() {
let tiny = codelore_lib::test_support::tiny_repo::build();
let repo = GixRepo::open(tiny.dir.path()).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: tiny.dir.path().to_path_buf(),
min_revs: 1,
min_shared_revs: 1,
min_coupling_pct: 0,
fisher_significance: 0.0,
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
let rows = run_coupling(&db, &opts).expect("run");
assert!(
rows.is_empty(),
"fisher_significance=0.0 should reject all pairs, got {} rows",
rows.len()
);
}
#[test]
#[allow(
clippy::too_many_lines,
clippy::similar_names,
clippy::uninlined_format_args
)]
fn par6_min_revs_pivot_differs_under_code_maat_compat() {
use std::process::Command;
fn run_git_at(path: &std::path::Path, date: &str, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.env("GIT_AUTHOR_DATE", date)
.env("GIT_COMMITTER_DATE", date)
.status()
.expect("git");
assert!(status.success());
}
fn commit(path: &std::path::Path, day: usize, files: &[(&str, &str)]) {
let date = format!("2026-01-{day:02}T12:00:00Z");
for (name, content) in files {
std::fs::write(path.join(name), content).unwrap();
}
run_git_at(path, &date, &["add", "."]);
run_git_at(
path,
&date,
&["commit", "-m", &format!("d{day}"), "--quiet"],
);
}
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path();
run_git_at(
path,
"2026-01-01T00:00:00Z",
&["init", "-b", "main", "--quiet"],
);
run_git_at(
path,
"2026-01-01T00:00:00Z",
&["config", "user.email", "t@t"],
);
run_git_at(
path,
"2026-01-01T00:00:00Z",
&["config", "user.name", "Tiny"],
);
for i in 1..=4 {
commit(
path,
i,
&[
("a.txt", &format!("a{i}")),
("b.txt", &format!("b{i}")),
("c.txt", &format!("c{i}")),
],
);
}
for i in 5..=8 {
commit(
path,
i,
&[("a.txt", &format!("a{i}")), ("b.txt", &format!("b{i}"))],
);
}
for i in 9..=11 {
commit(
path,
i,
&[("a.txt", &format!("a{i}")), ("x.txt", &format!("x{i}"))],
);
}
for i in 12..=13 {
commit(path, i, &[("x.txt", &format!("x{i}"))]);
}
let repo = GixRepo::open(path).expect("open");
{
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: path.to_path_buf(),
min_revs: 5,
min_shared_revs: 1,
min_coupling_pct: 0,
fisher_significance: 2.0,
use_canonical_lineage: false,
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
let rows = run_coupling(&db, &opts).expect("run default");
let has_ab = rows
.iter()
.any(|r| r.entity_a == "a.txt" && r.entity_b == "b.txt");
let has_ac = rows
.iter()
.any(|r| r.entity_a == "a.txt" && r.entity_b == "c.txt");
assert!(has_ab, "default: (a, b) pair must surface; got {rows:?}");
assert!(
!has_ac,
"default per-file gate: (a, c) must be dropped (c has 4 revs < 5); got {rows:?}",
);
}
{
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: path.to_path_buf(),
min_revs: 5,
min_shared_revs: 1,
min_coupling_pct: 0,
fisher_significance: 2.0,
code_maat_compat: true,
use_canonical_lineage: false,
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
let rows = run_coupling(&db, &opts).expect("run compat");
let has_ab = rows
.iter()
.any(|r| r.entity_a == "a.txt" && r.entity_b == "b.txt");
let has_ac = rows
.iter()
.any(|r| r.entity_a == "a.txt" && r.entity_b == "c.txt");
assert!(has_ab, "compat: (a, b) pair must surface; got {rows:?}");
assert!(
has_ac,
"compat per-pair-average gate: (a, c) must surface (avg = 7 ≥ 5); got {rows:?}",
);
}
}
#[test]
fn coupling_bypasses_fisher_gate_under_compat() {
let dir = build_trio_repo(8);
let repo = GixRepo::open(dir.path()).expect("open");
let base = Options {
repo_path: dir.path().to_path_buf(),
min_revs: 1,
min_shared_revs: 1,
min_coupling_pct: 0,
max_coupling_pct: 100,
fisher_significance: 0.0,
use_canonical_lineage: false,
..Options::default()
};
let db_m = FactsDb::new_in_memory().expect("db");
db_m.ingest(&repo, &base).expect("ingest");
assert!(
run_coupling(&db_m, &base).expect("modern").is_empty(),
"modern: fisher_significance=0.0 must drop all pairs"
);
let db_c = FactsDb::new_in_memory().expect("db");
let compat = Options {
code_maat_compat: true,
..base.clone()
};
db_c.ingest(&repo, &compat).expect("ingest");
assert_eq!(
run_coupling(&db_c, &compat).expect("compat").len(),
3,
"compat: all three trio pairs survive despite fisher_significance=0.0"
);
}
#[test]
fn run_coupling_applies_fdr_when_enabled() {
use std::collections::HashSet;
let dir = build_trio_repo(8);
let repo = GixRepo::open(dir.path()).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let family_opts = Options {
repo_path: dir.path().to_path_buf(),
min_revs: 1,
min_shared_revs: 1,
min_coupling_pct: 0,
max_coupling_pct: 100,
fisher_significance: 2.0,
use_canonical_lineage: false,
..Options::default()
};
db.ingest(&repo, &family_opts).expect("ingest");
let family = run_coupling(&db, &family_opts).expect("family");
assert!(!family.is_empty(), "trio repo must produce tested pairs");
let pair_set = |rows: &[codelore_lib::analyses::coupling::CouplingRow]| {
rows.iter()
.map(|r| (r.entity_a.clone(), r.entity_b.clone()))
.collect::<HashSet<_>>()
};
let off = run_coupling(
&db,
&Options {
fisher_significance: 0.05,
fdr_correction: false,
..family_opts.clone()
},
)
.expect("fdr off");
let on = run_coupling(
&db,
&Options {
fisher_significance: 0.05,
fdr_correction: true,
..family_opts.clone()
},
)
.expect("fdr on");
let pvalues: Vec<f64> = family.iter().map(|r| r.fisher_p).collect();
let cutoff = codelore_lib::stats::bh_fdr_threshold(&pvalues, 0.05);
let expected: HashSet<(String, String)> = family
.iter()
.filter(|r| r.fisher_p <= cutoff)
.map(|r| (r.entity_a.clone(), r.entity_b.clone()))
.collect();
let on_set = pair_set(&on);
let off_set = pair_set(&off);
assert_eq!(
on_set, expected,
"FDR survivors must match the BH cutoff set"
);
assert!(
on_set.is_subset(&off_set),
"FDR-on set {on_set:?} must be a subset of the per-test set {off_set:?}"
);
assert!(
on.len() <= off.len(),
"FDR is at least as strict as the per-test gate"
);
}