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
//! Communication analysis per spec §1.1 — Conway's law shared-work author pairs.
//!
//! For each pair of authors who co-edit the same files, compute:
//! - shared: distinct paths they both edited
//! - average: mean of their individual total commits
//! - strength: 100 × shared / average (percentage)
//!
//! Self-pairs are excluded.
//!
//! Research basis: see `docs/research-foundations.md` entry
//! "communication" (Conway, *Datamation* 1968 — original Conway's-law
//! essay; Bird et al., *Comm. ACM* 2009 — empirical follow-up on
//! Windows Vista development showing organisational structure shapes
//! defect density).
use duckdb::params;
use crate::facts::FactsDb;
use crate::{Options, Result};
#[derive(Debug, Clone, serde::Serialize)]
pub struct CommunicationRow {
pub author_a: String,
pub author_b: String,
pub shared: u32, // distinct paths both authors touched
pub average: u32, // mean of authors' total commits
pub strength: f64, // 100 * shared / average
}
// communication has two numeric divergences vs code-maat's
// `communication.clj with-commit-stats`:
//
// - `average`: code-maat uses `(math/ceil (m/average my peer))` — ceiling
// rounding. CodeLore's modern default uses integer-div floor `(a+b)/2`
// which is more conservative ("you've communicated at least this often").
// - `strength`: code-maat uses `(int (m/as-percentage ...))` — truncated
// integer. CodeLore's modern default emits a float `XX.XX` for precision.
//
// Under `--code-maat-compat`, we honour both code-maat numeric semantics so
// downstream tools parsing the legacy CSV see identical values. Bot authors
// are excluded in compat mode too, as in every other social analysis:
// human↔human rows stay byte-identical, but a pair involving a bot is dropped
// even under compat — where upstream code-maat, which has no bot concept,
// would emit it. Uniform bot exclusion is preferred over reproducing bot noise.
fn build_communication_sql(code_maat_compat: bool) -> String {
let avg_expr = if code_maat_compat {
// CEIL of mean — matches code-maat's `(math/ceil (m/average …))`.
"CAST(CEIL((ta.commits + tb.commits) / 2.0) AS UINTEGER)"
} else {
// Integer floor via DuckDB integer division.
"(ta.commits + tb.commits) / 2"
};
let strength_expr = if code_maat_compat {
// Truncate toward zero via FLOOR (matches Clojure `(int)`; strength >= 0),
// and divide by the CEIL'd average — code-maat divides shared by
// `average-commits` (the ceil'd mean in the `average` column), not the raw
// mean. Re-cast to DOUBLE so the row type stays uniform; the Rust
// orchestrator formats as `XX`.
"CAST(FLOOR(100.0 * p.shared / NULLIF(CEIL((ta.commits + tb.commits) / 2.0), 0)) \
AS DOUBLE)"
} else {
// Float with two-decimal CSV formatting.
"100.0 * p.shared / NULLIF((ta.commits + tb.commits) / 2.0, 0)"
};
let human_aliases = crate::analyses::query::HUMAN_ALIASES_CTE;
format!(
"
WITH {human_aliases},
author_files AS (
-- Pair-granular: joins on the exact (raw_name, raw_email) that
-- made the commit, so a human sharing a canonical with a bot keeps
-- their own file touches counted while the bot pair's are dropped
-- row-wise.
SELECT DISTINCT
changes.path,
commits.canonical_author AS author
FROM commits
INNER JOIN changes ON changes.rev = commits.rev
INNER JOIN human_aliases ha
ON ha.raw_name = commits.author_name AND ha.raw_email = commits.author_email
),
pairs AS (
SELECT
a.author AS author_a,
b.author AS author_b,
-- `author_files` is upstream `SELECT DISTINCT path, author`,
-- so each (author, path) row is unique. The self-join on
-- `a.path = b.path AND a.author < b.author` then produces
-- at most one row per (path, author_a, author_b) triple,
-- making `a.path` unique within each (author_a, author_b)
-- group. Plain COUNT skips DuckDB's distinct-tracking
-- overhead.
COUNT(a.path) AS shared
FROM author_files a
INNER JOIN author_files b ON a.path = b.path AND a.author < b.author
GROUP BY a.author, b.author
HAVING shared >= ?
),
totals AS (
SELECT
canonical_author AS author,
-- `commits.rev` is the PRIMARY KEY of the commits table, so
-- COUNT(rev) == COUNT(DISTINCT rev) per author. Plain COUNT
-- skips DuckDB's distinct-tracking overhead.
COUNT(rev) AS commits
FROM commits
INNER JOIN human_aliases ha
ON ha.raw_name = commits.author_name AND ha.raw_email = commits.author_email
GROUP BY canonical_author
)
SELECT
p.author_a,
p.author_b,
p.shared,
{avg_expr} AS average,
{strength_expr} AS strength
FROM pairs p
INNER JOIN totals ta ON ta.author = p.author_a
INNER JOIN totals tb ON tb.author = p.author_b
ORDER BY strength DESC, p.author_a ASC, p.author_b ASC
LIMIT ?
"
)
}
#[tracing::instrument(name = "communication", skip_all, fields(min_revs = opts.min_revs))]
pub fn run_communication(db: &FactsDb, opts: &Options) -> Result<Vec<CommunicationRow>> {
// Route through `changes_lineage` when canonical lineage is enabled so
// two authors who co-edited the SAME logical file across a rename still
// count as having shared work. Without this rewrite, Conway's-law output
// underreported team coupling on any history with renames. Mirrors the
// pattern already in `entity_effort`, `messages`, `ownership`, etc.
crate::analyses::lineage::materialize_if_needed(db, opts)?;
let base_sql = build_communication_sql(opts.code_maat_compat);
let sql = crate::analyses::lineage::rewrite(&base_sql, opts);
let row_limit: i64 = opts.rows_limit.map_or(i64::MAX, i64::from);
crate::analyses::query::explain_if_requested(
db,
&sql,
params![opts.min_shared_revs, row_limit],
"communication",
opts,
)?;
crate::analyses::query::query_map_collect(
db,
&sql,
params![opts.min_shared_revs, row_limit],
"communication",
|r| {
Ok(CommunicationRow {
author_a: r.get::<_, String>(0)?,
author_b: r.get::<_, String>(1)?,
shared: u32::try_from(r.get::<_, i64>(2)?).unwrap_or(u32::MAX),
average: u32::try_from(r.get::<_, i64>(3)?).unwrap_or(u32::MAX),
strength: r.get::<_, f64>(4)?,
})
},
)
}