use anyhow::{Context, Result};
use codelore_lib::cli_api::facts::FactsDb;
use codelore_lib::cli_api::repo::{GixRepo, Repo as _};
use codelore_lib::cli_api::{CodeLoreError, Options};
use crate::args::CalibrateDefectsArgs;
pub(crate) fn run_calibrate_defects_cmd(args: &CalibrateDefectsArgs) -> Result<()> {
use codelore_lib::cli_api::analyses::code_health::run_code_health;
use codelore_lib::cli_api::quality_gates::ledger::now_utc_ts;
use codelore_lib::defect_calibration::validate::{
band_history, capture_intensities, default_weights, tune_weights, validate,
};
use codelore_lib::defect_calibration::{self, DefectArtifact, OracleConfig};
let repo =
GixRepo::open(&args.repo).with_context(|| format!("open repo {}", args.repo.display()))?;
ensure_mining_tree_clean(&repo, args.allow_dirty)?;
eprintln!(
"calibrate-defects: mining full history of {}",
args.repo.display()
);
let opts = Options {
repo_path: args.repo.clone(),
include_merges: true,
temp_dir: args.temp_dir.clone(),
..Options::default()
};
opts.validate().context("validate options")?;
let db = FactsDb::new_in_memory_with_temp_dir(args.temp_dir.as_deref())
.context("open in-memory mining fact store")?;
db.ingest(&repo, &opts).context("ingest full history")?;
let oracle_cfg = OracleConfig::default();
let (links, mining_stats, commit_dates) = mine_fix_links(&db, &repo, args, &oracle_cfg)?;
eprintln!("calibrate-defects: scanning historical code-health bands");
let bands = band_history(&db, &repo, &opts).context("historical band scan")?;
eprintln!("calibrate-defects: scanning HEAD code-health");
let head_health = run_code_health(&db, &opts).context("HEAD code-health scan")?;
let intensities = capture_intensities(&db).context("capture biomarker intensities")?;
eprintln!("calibrate-defects: validating structural risk against mined defects");
let validation = validate(&links, &commit_dates, &bands, &head_health);
eprintln!("calibrate-defects: tuning smell weights");
let (train, validation_split) =
build_train_validation_split(&links, &commit_dates, &intensities);
let defaults = default_weights();
let (weights, tuning) = tune_weights(&intensities, &train, &validation_split, &defaults);
let generated_at = now_utc_ts();
let vintage = args
.vintage
.clone()
.unwrap_or_else(|| format!("defects-{}", &generated_at[..10]));
let artifact = DefectArtifact {
format_version: defect_calibration::DEFECT_FORMAT_VERSION,
repo_identity: defect_calibration::repo_identity(&args.repo),
head_at_mining: repo.head_sha().context("resolve HEAD sha")?,
vintage,
generated_at,
oracle: oracle_cfg,
mining: mining_stats,
validation,
weights,
tuning,
};
defect_calibration::save(&artifact, &args.output)
.with_context(|| format!("write artifact {}", args.output.display()))?;
eprintln!(
"calibrate-defects: wrote {} (vintage {})",
args.output.display(),
artifact.vintage,
);
for line in format_validation_evidence(&artifact) {
eprintln!("{line}");
}
Ok(())
}
fn fmt_metric(value: Option<f64>, decimals: usize) -> String {
match value {
Some(x) => format!("{x:.decimals$}"),
None => "n/a".to_string(),
}
}
fn tuning_verdict(tuning: &codelore_lib::defect_calibration::TuningDecision) -> String {
use codelore_lib::defect_calibration::TuningDecision;
match tuning {
TuningDecision::Applied {
auc_train,
auc_validation_default,
auc_validation_tuned,
} => format!(
"weights tuned to this repo (validation AUC {auc_validation_default:.3} \
-> {auc_validation_tuned:.3}, train {auc_train:.3})"
),
TuningDecision::DefaultsKept { reason, .. } => {
format!("weights left at defaults ({reason})")
}
}
}
fn format_validation_evidence(
art: &codelore_lib::defect_calibration::DefectArtifact,
) -> Vec<String> {
let v = &art.validation;
let mut lines = Vec::with_capacity(3);
match v.auc_default {
Some(auc) => lines.push(format!(
"calibrate-defects: validation - structural-risk AUC {auc:.3}, \
precision@10 {}, precision@red {}",
fmt_metric(v.precision_at_10, 2),
fmt_metric(v.precision_at_red, 2),
)),
None => lines.push(
"calibrate-defects: validation - not enough defect signal to score \
structural risk (needs both defect-implicated and clean files)"
.to_string(),
),
}
lines.push(format!(
"calibrate-defects: {} defect-implicated file(s) across {} linked defect(s)",
v.implicated_files, v.linked_defects,
));
let mining = &art.mining;
lines.push(format!(
"calibrate-defects: mining guards - {} fix commit(s) examined, {} excluded as \
tangled (>{} files or >{} changed lines), {} whole-file deletion(s) skipped as ghost",
mining.fixes_found,
mining.fixes_excluded_tangled,
codelore_lib::defect_calibration::szz::TANGLED_MAX_FILES,
codelore_lib::defect_calibration::szz::TANGLED_MAX_CHURN,
mining.ghost_files_skipped,
));
lines.push(format!(
"calibrate-defects: {}",
tuning_verdict(&art.tuning)
));
lines
}
fn ensure_mining_tree_clean(repo: &GixRepo, allow_dirty: bool) -> Result<()> {
if !repo.is_worktree_dirty() {
return Ok(());
}
if allow_dirty {
eprintln!(
"calibrate-defects: warning: working tree has uncommitted changes; \
mining reads only committed state, so the artifact describes HEAD, \
not your uncommitted edits (--allow-dirty set, continuing)"
);
return Ok(());
}
Err(CodeLoreError::InvalidOptions(
"working tree has uncommitted changes; mining reads only committed state, \
so the artifact would describe HEAD rather than your current edits — \
commit them or pass --allow-dirty to proceed"
.to_string(),
)
.into())
}
type MinedLinks = (
Vec<codelore_lib::defect_calibration::szz::SzzLink>,
codelore_lib::defect_calibration::MiningStats,
std::collections::HashMap<String, String>,
);
fn mine_fix_links(
db: &FactsDb,
repo: &GixRepo,
args: &CalibrateDefectsArgs,
oracle_cfg: &codelore_lib::defect_calibration::OracleConfig,
) -> Result<MinedLinks> {
use codelore_lib::defect_calibration::DefectOracle;
use codelore_lib::defect_calibration::szz::link_defects;
eprintln!("calibrate-defects: classifying fix commits");
let oracle = DefectOracle::new(oracle_cfg).context("build defect oracle")?;
let window_cutoff = match args.window_days {
Some(0) => {
return Err(
CodeLoreError::InvalidOptions("--window-days must be > 0".to_string()).into(),
);
}
Some(days) => window_cutoff_date(db, days).context("compute window cutoff")?,
None => None,
};
let (fixes, commit_dates, root_fixes_skipped, window_excluded) =
collect_fixes(db, &oracle, window_cutoff.as_deref()).context("collect fix commits")?;
eprintln!(
"calibrate-defects: {} fix commit(s) found ({root_fixes_skipped} root-commit fix(es) \
skipped — no parent to blame; {window_excluded} outside --window-days excluded)",
fixes.len(),
);
eprintln!("calibrate-defects: linking defects (AG-SZZ)");
let origin = GitBlameOrigin {
repo_path: args.repo.clone(),
};
let (links, mining_stats) = link_defects(db, repo, &origin, &fixes, &commit_dates)
.context("link defects to their introducing commits")?;
eprintln!(
"calibrate-defects: {} link(s) found ({} file(s) blamed, {} cosmetic line(s) \
dropped, {} blame failure(s))",
mining_stats.links_found,
mining_stats.files_blamed,
mining_stats.lines_dropped_cosmetic,
mining_stats.blame_failures,
);
Ok((links, mining_stats, commit_dates))
}
fn window_cutoff_date(db: &FactsDb, days: u32) -> Result<Option<String>> {
let now_anchor = codelore_lib::cli_api::analyses::query::clamped_now_anchor("date");
let sql =
format!("SELECT CAST((SELECT {now_anchor} FROM commits) - INTERVAL '{days} days' AS TEXT)");
db.query_row(&sql, [], |r| r.get::<_, Option<String>>(0))
.context("query window cutoff")
}
type FixCollection = (
Vec<(String, String, String)>,
std::collections::HashMap<String, String>,
u32,
u32,
);
fn collect_fixes(
db: &FactsDb,
oracle: &codelore_lib::defect_calibration::DefectOracle,
window_cutoff: Option<&str>,
) -> Result<FixCollection> {
use std::collections::HashMap;
let commit_rows: Vec<(String, String, String, bool)> = db
.prepare("SELECT rev, CAST(date AS TEXT), message, is_merge FROM commits")
.context("prepare commits query")?
.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get::<_, bool>(3)?,
))
})
.context("run commits query")?
.collect::<std::result::Result<Vec<_>, _>>()
.context("collect commits rows")?;
let first_parents: HashMap<String, String> = db
.prepare("SELECT rev, parent_rev FROM commit_parents WHERE position = 0")
.context("prepare first-parent query")?
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
.context("run first-parent query")?
.collect::<std::result::Result<HashMap<_, _>, _>>()
.context("collect first-parent rows")?;
let mut commit_dates = HashMap::with_capacity(commit_rows.len());
let mut fixes = Vec::new();
let mut root_fixes_skipped = 0u32;
let mut window_excluded = 0u32;
for (rev, date, message, is_merge) in commit_rows {
if !oracle.is_fix(&message, is_merge) {
commit_dates.insert(rev, date);
continue;
}
if let Some(cutoff) = window_cutoff
&& date.as_str() < cutoff
{
window_excluded += 1;
commit_dates.insert(rev, date);
continue;
}
match first_parents.get(&rev) {
Some(parent) => fixes.push((rev.clone(), parent.clone(), date.clone())),
None => root_fixes_skipped += 1,
}
commit_dates.insert(rev, date);
}
Ok((fixes, commit_dates, root_fixes_skipped, window_excluded))
}
type TrainValidationSplit = (Vec<(String, bool)>, Vec<(String, bool)>);
fn build_train_validation_split(
links: &[codelore_lib::defect_calibration::szz::SzzLink],
commit_dates: &std::collections::HashMap<String, String>,
intensities: &std::collections::HashMap<String, [f64; 8]>,
) -> TrainValidationSplit {
let mut incidences: std::collections::HashMap<&str, (usize, &str)> =
std::collections::HashMap::new();
let mut total_positive_rows = 0usize;
for link in links {
let Some(date) = commit_dates.get(&link.fix_rev) else {
continue;
};
let (path, date) = (link.path.as_str(), date.as_str());
incidences
.entry(path)
.and_modify(|(count, earliest)| {
*count += 1;
if date < *earliest {
*earliest = date;
}
})
.or_insert((1, date));
total_positive_rows += 1;
}
let implicated: std::collections::HashSet<&str> =
links.iter().map(|l| l.path.as_str()).collect();
let mut negatives: Vec<&str> = intensities
.keys()
.map(String::as_str)
.filter(|path| !implicated.contains(path))
.collect();
negatives.sort_unstable();
let train_share = |n: usize| n * 60 / 100;
let mut positive_paths: Vec<(&str, usize, &str)> = incidences
.into_iter()
.map(|(path, (count, earliest))| (path, count, earliest))
.collect();
positive_paths.sort_unstable_by(|a, b| a.2.cmp(b.2).then_with(|| a.0.cmp(b.0)));
let positive_train_target = train_share(total_positive_rows);
let mut train: Vec<(String, bool)> = Vec::new();
let mut validation: Vec<(String, bool)> = Vec::new();
let mut assigned = 0usize;
for (path, count, _earliest) in positive_paths {
let dest = if assigned < positive_train_target {
&mut train
} else {
&mut validation
};
dest.extend(std::iter::repeat_n((path.to_string(), true), count));
assigned += count;
}
let (neg_train, neg_val) = negatives.split_at(train_share(negatives.len()));
train.extend(neg_train.iter().map(|&path| (path.to_string(), false)));
validation.extend(neg_val.iter().map(|&path| (path.to_string(), false)));
(train, validation)
}
struct GitBlameOrigin {
repo_path: std::path::PathBuf,
}
impl codelore_lib::defect_calibration::szz::LineOriginSource for GitBlameOrigin {
fn origins(
&self,
rev: &str,
path: &str,
lines: &[u32],
) -> codelore_lib::cli_api::Result<Vec<(u32, String)>> {
if lines.is_empty() {
return Ok(Vec::new());
}
let repo_str = self
.repo_path
.to_str()
.ok_or_else(|| CodeLoreError::Analysis("non-UTF-8 repo path".to_string()))?;
let mut cmd_args: Vec<String> = vec![
"-C".to_string(),
repo_str.to_string(),
"blame".to_string(),
"-w".to_string(),
"-M".to_string(),
"--porcelain".to_string(),
];
for (start, end) in merge_line_ranges(lines) {
cmd_args.push("-L".to_string());
cmd_args.push(format!("{start},{end}"));
}
cmd_args.push(rev.to_string());
cmd_args.push("--".to_string());
cmd_args.push(path.to_string());
let out = std::process::Command::new("git")
.args(&cmd_args)
.stdin(std::process::Stdio::null())
.output()
.map_err(|e| {
CodeLoreError::Analysis(format!("spawn git blame for {path}@{rev}: {e}"))
})?;
if !out.status.success() {
return Err(CodeLoreError::Analysis(format!(
"git blame failed for {path}@{rev}: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
let stdout = String::from_utf8_lossy(&out.stdout);
codelore_lib::defect_calibration::szz::parse_blame_porcelain(&stdout)
}
}
fn merge_line_ranges(lines: &[u32]) -> Vec<(u32, u32)> {
let mut sorted: Vec<u32> = lines.to_vec();
sorted.sort_unstable();
sorted.dedup();
let mut ranges: Vec<(u32, u32)> = Vec::new();
for line in sorted {
match ranges.last_mut() {
Some((_, end)) if line == *end + 1 => *end = line,
_ => ranges.push((line, line)),
}
}
ranges
}
#[cfg(test)]
mod tests {
use super::*;
use codelore_lib::defect_calibration::DefectArtifact;
use codelore_lib::defect_calibration::OracleConfig;
use codelore_lib::defect_calibration::szz::SzzLink;
fn git_in(repo: &std::path::Path, args: &[&str], date: Option<&str>) -> std::process::Output {
let mut cmd = std::process::Command::new("git");
cmd.arg("-C")
.arg(repo)
.args(args)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t");
if let Some(d) = date {
cmd.env("GIT_AUTHOR_DATE", d).env("GIT_COMMITTER_DATE", d);
}
let out = cmd.output().expect("run git");
assert!(out.status.success(), "git {args:?}: {out:?}");
out
}
fn head_sha(repo: &std::path::Path) -> String {
let out = git_in(repo, &["rev-parse", "HEAD"], None);
String::from_utf8(out.stdout)
.expect("HEAD sha is utf-8")
.trim()
.to_string()
}
#[test]
fn szz_blame_attributes_a_relocated_line_to_its_true_introducer() {
let dir = tempfile::tempdir().expect("tempdir");
let repo = dir.path();
std::fs::create_dir_all(repo.join("src")).expect("mkdir src");
git_in(repo, &["init", "-q", "-b", "main"], None);
let line = " let checksum = computePayloadChecksum(inputPayloadBytes);\n";
std::fs::write(
repo.join("src/lib.rs"),
format!("fn handler() {{\n{line} validate();\n persist();\n respond();\n}}\n"),
)
.expect("write A");
git_in(repo, &["add", "."], None);
git_in(
repo,
&["commit", "-q", "-m", "feat: add request handler"],
Some("2026-01-01T00:00:00Z"),
);
let a = head_sha(repo);
std::fs::write(
repo.join("src/lib.rs"),
format!("fn handler() {{\n validate();\n persist();\n respond();\n{line}}}\n"),
)
.expect("write B");
git_in(repo, &["add", "."], None);
git_in(
repo,
&[
"commit",
"-q",
"-m",
"refactor: relocate checksum to end of handler",
],
Some("2026-01-02T00:00:00Z"),
);
std::fs::write(
repo.join("src/lib.rs"),
"fn handler() {\n validate();\n persist();\n respond();\n}\n",
)
.expect("write C");
git_in(repo, &["add", "."], None);
git_in(
repo,
&["commit", "-q", "-m", "fix: drop stale checksum line"],
Some("2026-01-03T00:00:00Z"),
);
let c = head_sha(repo);
let git_repo = GixRepo::open(repo).expect("open fixture repo");
let opts = Options {
repo_path: repo.to_path_buf(),
include_merges: true,
..Options::default()
};
let db = FactsDb::new_in_memory().expect("in-memory fact store");
db.ingest(&git_repo, &opts).expect("ingest fixture history");
let args = CalibrateDefectsArgs {
repo: repo.to_path_buf(),
output: repo.join("defects.calib.json"),
vintage: None,
window_days: None,
temp_dir: None,
allow_dirty: false,
};
let (links, _stats, _dates) =
mine_fix_links(&db, &git_repo, &args, &OracleConfig::default())
.expect("mine fix links");
assert_eq!(
links,
vec![SzzLink {
defect_rev: a,
fix_rev: c,
path: "src/lib.rs".to_string(),
}],
);
}
#[test]
fn train_validation_split_keeps_each_path_wholly_on_one_side() {
use std::collections::{HashMap, HashSet};
let link = |fix_rev: &str, path: &str| SzzLink {
defect_rev: format!("d-{fix_rev}-{path}"),
fix_rev: fix_rev.to_string(),
path: path.to_string(),
};
let links = vec![
link("fx1", "a.rs"),
link("fx2", "b.rs"),
link("fx3", "hot.rs"),
link("fx5", "hot.rs"),
link("fx9", "hot.rs"),
link("fx10", "z.rs"),
];
let commit_dates: HashMap<String, String> = [
("fx1", "2026-01-01"),
("fx2", "2026-01-02"),
("fx3", "2026-01-03"),
("fx5", "2026-01-05"),
("fx9", "2026-01-09"),
("fx10", "2026-01-10"),
]
.into_iter()
.map(|(rev, date)| (rev.to_string(), date.to_string()))
.collect();
let intensities: HashMap<String, [f64; 8]> = ["clean1.rs", "clean2.rs"]
.into_iter()
.map(|p| (p.to_string(), [0.0; 8]))
.collect();
let (train, validation) = build_train_validation_split(&links, &commit_dates, &intensities);
let hot_rows = train
.iter()
.chain(&validation)
.filter(|(p, label)| *label && p.as_str() == "hot.rs")
.count();
assert_eq!(hot_rows, 3, "hot.rs must keep one row per defect incidence");
let train_paths: HashSet<&str> = train.iter().map(|(p, _)| p.as_str()).collect();
let val_paths: HashSet<&str> = validation.iter().map(|(p, _)| p.as_str()).collect();
assert!(
train_paths.is_disjoint(&val_paths),
"a path appears in both splits: train={train_paths:?} val={val_paths:?}",
);
let hot_in_train = train.iter().filter(|(p, _)| p.as_str() == "hot.rs").count();
let hot_in_val = validation
.iter()
.filter(|(p, _)| p.as_str() == "hot.rs")
.count();
assert!(
(hot_in_train, hot_in_val) == (3, 0) || (hot_in_train, hot_in_val) == (0, 3),
"hot.rs incidence rows split across sides: train={hot_in_train} val={hot_in_val}",
);
}
fn artifact_with(
validation: codelore_lib::defect_calibration::ValidationMetrics,
tuning: codelore_lib::defect_calibration::TuningDecision,
) -> DefectArtifact {
DefectArtifact {
format_version: codelore_lib::defect_calibration::DEFECT_FORMAT_VERSION,
repo_identity: "0".repeat(64),
head_at_mining: "0".repeat(40),
vintage: "defects-2026-07-20".to_string(),
generated_at: "2026-07-20T00:00:00Z".to_string(),
oracle: OracleConfig::default(),
mining: codelore_lib::defect_calibration::MiningStats::default(),
validation,
weights: codelore_lib::defect_calibration::validate::default_weights(),
tuning,
}
}
#[test]
fn validation_evidence_summary_reports_auc_precision_and_tuning() {
use codelore_lib::defect_calibration::{TuningDecision, ValidationMetrics};
let art = artifact_with(
ValidationMetrics {
band_table: vec![],
auc_default: Some(0.803),
precision_at_10: Some(0.6),
precision_at_red: Some(0.75),
implicated_files: 12,
linked_defects: 20,
sample_dates: vec![],
excluded_no_data: 0,
},
TuningDecision::Applied {
auc_train: 0.812,
auc_validation_default: 0.780,
auc_validation_tuned: 0.834,
},
);
let out = format_validation_evidence(&art).join("\n");
assert!(out.contains("AUC 0.803"), "{out}");
assert!(out.contains("precision@10 0.60"), "{out}");
assert!(out.contains("precision@red 0.75"), "{out}");
assert!(out.contains("12 defect-implicated file(s)"), "{out}");
assert!(out.contains("20 linked defect(s)"), "{out}");
assert!(
out.contains("weights tuned to this repo") && out.contains("0.780 -> 0.834"),
"{out}"
);
}
#[test]
fn validation_evidence_summary_honest_absence_never_prints_zero() {
use codelore_lib::defect_calibration::{TuningDecision, ValidationMetrics};
let art = artifact_with(
ValidationMetrics {
band_table: vec![],
auc_default: None,
precision_at_10: None,
precision_at_red: None,
implicated_files: 0,
linked_defects: 0,
sample_dates: vec![],
excluded_no_data: 0,
},
TuningDecision::DefaultsKept {
reason: "too few linked defects".to_string(),
auc_validation_default: None,
auc_validation_tuned: None,
},
);
let out = format_validation_evidence(&art).join("\n");
assert!(out.contains("not enough defect signal"), "{out}");
assert!(out.contains("weights left at defaults"), "{out}");
assert!(out.contains("too few linked defects"), "{out}");
assert!(!out.contains("0.00"), "absent metric read as 0.00: {out}");
assert!(!out.contains("0.000"), "absent metric read as 0.000: {out}");
}
#[test]
fn validation_evidence_summary_discloses_mining_guards() {
use codelore_lib::defect_calibration::szz::{TANGLED_MAX_CHURN, TANGLED_MAX_FILES};
use codelore_lib::defect_calibration::{MiningStats, TuningDecision, ValidationMetrics};
let mut art = artifact_with(
ValidationMetrics::default(),
TuningDecision::DefaultsKept {
reason: "too few linked defects".to_string(),
auc_validation_default: None,
auc_validation_tuned: None,
},
);
art.mining = MiningStats {
fixes_found: 50,
fixes_excluded_tangled: 4,
ghost_files_skipped: 3,
..MiningStats::default()
};
let out = format_validation_evidence(&art).join("\n");
assert!(out.contains("50 fix commit(s) examined"), "{out}");
assert!(out.contains("4 excluded as tangled"), "{out}");
assert!(
out.contains("3 whole-file deletion(s) skipped as ghost"),
"{out}"
);
let thresholds =
format!(">{TANGLED_MAX_FILES} files or >{TANGLED_MAX_CHURN} changed lines");
assert!(out.contains(&thresholds), "{out}");
}
}