1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
//! `function-coupling` analysis.
//!
//! For a single target file, identifies pairs of functions that co-change
//! (appear together in the same revision's hunk-attributed change set) and
//! ranks them by Fisher exact significance. Only pairs with `co_changes ≥ 2`
//! are emitted.
//!
//! **Algorithm**: for each revision that touched the target file, the set of
//! HEAD-alive functions whose line spans overlapped any hunk is computed via
//! the same hunk-overlap logic used by `function-xray`. Co-change counts are
//! then accumulated over all revisions. For each pair `(a, b)` the Fisher
//! 2×2 contingency table is:
//!
//! ```text
//! b touched b not touched
//! a touched co a_only
//! a not b_only neither
//! ```
//!
//! where `n = total distinct revisions touching the file` (from the `hunks`
//! table, regardless of which functions they touched) and
//! `neither = n − co − a_only − b_only`.
//!
//! **Rename limitation**: hunk attribution uses `WHERE h.path = ?` (current
//! HEAD-relative path). Pre-rename history is not attributed — see
//! `function_xray` module doc.
//!
//! **Output**: sorted by `p_value` ASC (`None` first — degenerate marginal
//! implies p → 0, i.e. perfectly coupled) then `confidence` DESC for
//! determinism. `confidence = co_changes / min(a_changes, b_changes)`.
//!
//! Research basis: Adams et al., ICSM 2006 "The Co-Change Rule"; Fisher
//! significance adapts the coupling analysis in Tornhill, "Your Code as a
//! Crime Scene" (2015) to function granularity.
use std::collections::HashSet;
use crate::analyses::function_xray::{fetch_hunks_for_path, rev_to_function_sets};
use crate::facts::FactsDb;
use crate::repo::Repo;
use crate::stats::fisher_two_tail_pvalue;
use crate::{Options, Result};
/// One row per function pair with `co_changes ≥ 2`, sorted by `p_value` ASC.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct FunctionCouplingRow {
/// First function name (deduped as `name@start-end`).
pub a: String,
/// Second function name (deduped as `name@start-end`).
pub b: String,
/// Number of revisions where both `a` and `b` were touched.
pub co_changes: u32,
/// Number of revisions where `a` was touched (including co-changes).
pub a_changes: u32,
/// Number of revisions where `b` was touched (including co-changes).
pub b_changes: u32,
/// `co_changes / min(a_changes, b_changes)`. 1.0 means perfect coupling.
pub confidence: f64,
/// Two-tailed Fisher exact p-value. `None` serialises as `null` in JSON
/// and as an empty string in CSV.
pub p_value: Option<f64>,
}
/// Run the `function-coupling` analysis.
///
/// `target` is a repo-relative path (e.g. `src/foo.rs`). Returns pairs of
/// HEAD-alive functions that co-changed in ≥ 2 revisions, sorted by
/// `p_value` ASC then `confidence` DESC. Pairs where Fisher returns `None`
/// (degenerate marginal — zero row or column sum, implying p → 0) sort
/// first as the strongest coupling signal.
///
/// # Errors
///
/// Returns [`crate::CodeLoreError::Analysis`] on database errors or if the
/// target file is not a supported Tier-1 language.
#[tracing::instrument(name = "function-coupling", skip_all, fields(target = target))]
pub fn run_function_coupling<R: Repo>(
db: &FactsDb,
repo: &R,
opts: &Options,
target: &str,
) -> Result<Vec<FunctionCouplingRow>> {
// --- 1. Count file-touching revisions and build per-function sets -----
// n = distinct revisions that touched the file in the hunks table,
// regardless of which functions (if any) they overlapped. This is the
// correct denominator for the Fisher "neither" cell: a commit that only
// edits file-level content (use statements, mod docs, consts) never
// appears in rev_sets but still belongs to n.
let hunk_rows = fetch_hunks_for_path(db, target)?;
if hunk_rows.is_empty() {
return Ok(Vec::new());
}
let n_revs: HashSet<&str> = hunk_rows
.iter()
.map(|(rev, _, _, _)| rev.as_str())
.collect();
let n = u32::try_from(n_revs.len()).unwrap_or(u32::MAX);
let rev_sets = rev_to_function_sets(db, repo, target)?;
if rev_sets.is_empty() {
return Ok(Vec::new());
}
// Collect all HEAD-alive function names that appear in any rev set.
let all_fns: HashSet<&str> = rev_sets
.values()
.flat_map(|s| s.iter().map(String::as_str))
.collect();
let mut all_fns: Vec<&str> = all_fns.into_iter().collect();
all_fns.sort_unstable();
// --- 2. Accumulate per-function change counts and co-change counts ----
// fn_changes[i] = number of revisions that touched all_fns[i]
let fn_count = all_fns.len();
let mut fn_changes: Vec<u32> = vec![0u32; fn_count];
// co_matrix[i][j] (i < j) = co-change count for pair (i, j)
// Stored as a flat upper-triangle: index(i,j) = i*fn_count + j
let mut co_matrix: Vec<u32> = vec![0u32; fn_count * fn_count];
for set in rev_sets.values() {
// Find indices of functions touched in this rev.
let touched: Vec<usize> = all_fns
.iter()
.enumerate()
.filter_map(|(i, &name)| if set.contains(name) { Some(i) } else { None })
.collect();
for &i in &touched {
fn_changes[i] += 1;
}
// Accumulate co-changes for every pair touched in this rev.
for (pos_a, &i) in touched.iter().enumerate() {
for &j in &touched[pos_a + 1..] {
co_matrix[i * fn_count + j] += 1;
}
}
}
// --- 3. Build output rows for pairs with co_changes ≥ 2 ---------------
let mut rows: Vec<FunctionCouplingRow> = Vec::new();
for i in 0..fn_count {
for j in i + 1..fn_count {
let co = co_matrix[i * fn_count + j];
if co < 2 {
continue;
}
let a_ch = fn_changes[i];
let b_ch = fn_changes[j];
// Fisher 2×2 contingency:
// a=co, b=a_only, c=b_only, d=neither
// where a_only = a_changes - co, b_only = b_changes - co,
// neither = n - co - a_only - b_only
let a_only = a_ch.saturating_sub(co);
let b_only = b_ch.saturating_sub(co);
let neither = n
.saturating_sub(co)
.saturating_sub(a_only)
.saturating_sub(b_only);
let p_value = fisher_two_tail_pvalue(co, a_only, b_only, neither);
let confidence = f64::from(co) / f64::from(a_ch.min(b_ch)).max(1.0);
rows.push(FunctionCouplingRow {
a: all_fns[i].to_string(),
b: all_fns[j].to_string(),
co_changes: co,
a_changes: a_ch,
b_changes: b_ch,
confidence,
p_value,
});
}
}
// Sort: p_value ASC (None first — degenerate marginal implies p → 0,
// i.e. perfectly coupled), then confidence DESC, then a/b ASC for
// byte-stable output when multiple pairs share the same p and confidence.
rows.sort_unstable_by(|x, y| match (x.p_value, y.p_value) {
(Some(px), Some(py)) => px
.partial_cmp(&py)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| {
y.confidence
.partial_cmp(&x.confidence)
.unwrap_or(std::cmp::Ordering::Equal)
})
.then_with(|| x.a.cmp(&y.a))
.then_with(|| x.b.cmp(&y.b)),
(None, Some(_)) => std::cmp::Ordering::Less,
(Some(_), None) => std::cmp::Ordering::Greater,
(None, None) => x.a.cmp(&y.a).then_with(|| x.b.cmp(&y.b)),
});
if let Some(limit) = opts.rows_limit {
rows.truncate(limit as usize);
}
Ok(rows)
}