use duckdb::params;
use crate::facts::FactsDb;
use crate::{Options, Result};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DeliveryFrictionRow {
pub path: String,
pub revisions: u32,
pub cognitive: f64,
pub median_lead_time_days: f64,
pub p95_lead_time_days: f64,
pub wip_age_days: f64,
pub friction_score: f64,
}
const SQL: &str = "
WITH file_lead_times AS (
-- Per-file aggregation. The inline subquery computes the per-
-- commit lead-time ONCE so MEDIAN and QUANTILE_CONT both
-- aggregate over the same precomputed `lead_secs` column —
-- avoids the two EXTRACT(EPOCH) calls per row the prior shape
-- carried. `lead_secs` is NULL (not row-excluded) when
-- `committer_date <= date`: MEDIAN/QUANTILE_CONT skip NULLs per
-- standard SQL aggregate semantics, so clock-skew/rebase commits
-- drop out of the lead-time stats without shrinking `revisions`
-- or `last_touched` — those must stay the true per-file commit
-- count/last-touch regardless of any one commit's lead-time sign.
SELECT
path,
COUNT(rev) AS revisions,
COALESCE(MEDIAN(lead_secs), 0.0) / 86400.0 AS median_lead_time_days,
COALESCE(QUANTILE_CONT(lead_secs, 0.95), 0.0) / 86400.0 AS p95_lead_time_days,
MAX(committer_date) AS last_touched
FROM (
SELECT
ch.path,
c.rev,
c.committer_date,
CASE WHEN c.committer_date > c.date
THEN EXTRACT(EPOCH FROM c.committer_date)
- EXTRACT(EPOCH FROM c.date)
END AS lead_secs
FROM changes ch
INNER JOIN commits c ON c.rev = ch.rev
WHERE c.is_merge = FALSE
AND c.date IS NOT NULL
AND c.committer_date IS NOT NULL
)
GROUP BY path
HAVING revisions >= ?
),
file_complexity AS (
SELECT path, MAX(cognitive)::DOUBLE AS cognitive
FROM {cm_src}
WHERE cognitive IS NOT NULL
GROUP BY path
),
-- The previous shape had a pass-through `joined` CTE feeding a
-- `ranked` CTE with the window functions. Collapse: compute the
-- LEFT JOIN, wip_age_days, and the three PERCENT_RANK windows in
-- one CTE — one less SQL hop for the planner to materialise.
ranked AS (
SELECT
flt.path,
flt.revisions,
flt.median_lead_time_days,
flt.p95_lead_time_days,
COALESCE(fc.cognitive, 0.0) AS cognitive,
EXTRACT(EPOCH FROM (CAST(? AS TIMESTAMP) - flt.last_touched))
/ 86400.0 AS wip_age_days,
PERCENT_RANK() OVER (ORDER BY flt.revisions) AS pr_rev,
PERCENT_RANK() OVER (ORDER BY flt.median_lead_time_days) AS pr_lt,
PERCENT_RANK() OVER (ORDER BY COALESCE(fc.cognitive, 0.0)) AS pr_cx
FROM file_lead_times flt
LEFT JOIN file_complexity fc ON fc.path = flt.path
)
SELECT
path,
revisions,
cognitive,
median_lead_time_days,
p95_lead_time_days,
wip_age_days,
pr_rev * pr_lt * pr_cx * 100.0 AS friction_score
FROM ranked
ORDER BY friction_score DESC, path ASC
LIMIT ?
";
#[tracing::instrument(name = "delivery-friction", skip_all, fields(min_revs = opts.min_revs))]
pub fn run_delivery_friction(db: &FactsDb, opts: &Options) -> Result<Vec<DeliveryFrictionRow>> {
let row_limit: i64 = opts.rows_limit.map_or(i64::MAX, i64::from);
let anchor = anchor_str(db, opts)?;
let cm_src = crate::analyses::grouped_complexity::source_table(opts);
let sql = SQL.replace("{cm_src}", cm_src);
super::query::explain_if_requested(
db,
&sql,
params![opts.min_revs, anchor, row_limit],
"delivery-friction",
opts,
)?;
super::query::query_map_collect(
db,
&sql,
params![opts.min_revs, anchor, row_limit],
"delivery-friction",
|r| {
Ok(DeliveryFrictionRow {
path: r.get::<_, String>(0)?,
revisions: u32::try_from(r.get::<_, i64>(1)?).unwrap_or(u32::MAX),
cognitive: r.get::<_, f64>(2)?,
median_lead_time_days: r.get::<_, f64>(3)?,
p95_lead_time_days: r.get::<_, f64>(4)?,
wip_age_days: r.get::<_, f64>(5)?,
friction_score: r.get::<_, f64>(6)?,
})
},
)
}
fn anchor_str(db: &FactsDb, opts: &Options) -> Result<String> {
if let Some(d) = opts.age_time_now {
return Ok(format!(
"{:04}-{:02}-{:02} 23:59:59",
d.year(),
u8::from(d.month()),
d.day()
));
}
db.query_row(
&format!(
"SELECT COALESCE(CAST({now_anchor} AS TEXT), '1970-01-01 00:00:00') FROM commits",
now_anchor = crate::analyses::query::clamped_now_anchor("committer_date")
),
[],
|r| r.get::<_, String>(0),
)
}