codelore-lib 0.27.3

CodeLore — Behavioral Code Analyzer library
Documentation
//! 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)?,
            })
        },
    )
}