src/mimir_lens/risk.rs
//! **Lens C — change-risk scoring** (Tornhill's behavioural code analysis model).
//!
//! `git_heat_facts` × the code room (call sites per item, the test-reachable
//! set) × `coverage_fn`.
//!
//! The model, the "never a composite score alone" discipline and the
//! attribution are dwarves' (`dwarves/crates/dwarves/src/mimir/risk.rs`).
//! `risk = downstream_callers × churn_90d × (1 − coverage)` — a node-level
//! reduce per file, a SORT KEY carried beside its three verbatim inputs, never
//! a lone verdict.
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use super::sources::{Feed, Sources, evidence_note};
/// One unit of code, scored — with every input kept beside the score.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RiskUnit {
pub repo: String,
pub file: String,
/// **Input 1** — call sites landing on the functions defined in this file.
pub downstream_callers: u32,
/// **Input 2** — commits touching this file in the last 90 days.
pub churn_90d: u64,
/// **Input 3** — fraction of this file's functions a test can reach, `0.0..=1.0`.
pub coverage_frac: f64,
/// The composite. A SORT KEY, never a verdict — read the three inputs.
pub risk: f64,
pub functions: u32,
pub breaks_published_api: Option<bool>,
/// What `coverage_frac` measures: `line` (`coverage_fn`, a test EXECUTED the
/// function) or `reach` (the room's CALLS walk from a test — more generous).
pub coverage_basis: String,
}
impl RiskUnit {
/// The arithmetic, in words — so the number is never unexplained.
pub fn explain(&self) -> String {
format!(
"{} callers × {} commits/90d × (1 − {:.2} covered) = {:.0}",
self.downstream_callers, self.churn_90d, self.coverage_frac, self.risk
)
}
}
/// The whole reading.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct RiskReport {
pub units: Vec<RiskUnit>,
pub files_scored: usize,
pub feeds: Vec<Feed>,
pub coverage_basis: String,
pub semver_available: bool,
pub evidence_note: String,
pub model_attribution: String,
}
/// The attribution string, kept in one place and carried on every report.
pub const ATTRIBUTION: &str = "Behavioural code analysis model: Adam Tornhill, \
`Your Code as a Crime Scene` (2015) / `Software Design X-Rays` (2018), as \
implemented by CodeScene. Static substrate: Code Property Graph, Yamaguchi et al., \
IEEE S&P 2014. Original here: only the cross-repo join over nornir's Mimir.";
impl RiskReport {
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"kind": "risk",
"files_scored": self.files_scored,
"units": self.units.len(),
"coverage_basis": self.coverage_basis,
"semver_available": self.semver_available,
"evidence_note": self.evidence_note,
"model_attribution": self.model_attribution,
"feeds": self.feeds,
"worst": self.units.iter().map(|u| serde_json::json!({
"repo": u.repo, "file": u.file, "risk": u.risk,
"downstream_callers": u.downstream_callers,
"churn_90d": u.churn_90d,
"coverage_frac": u.coverage_frac,
"coverage_basis": u.coverage_basis,
"explain": u.explain(),
})).collect::<Vec<_>>(),
})
}
}
/// What the code room knows about one file: its callable items, how many of
/// them a test reaches, and the call sites arriving at them.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileFacts {
pub repo: String,
pub file: String,
/// `fn` + `method` items defined in the file (tests excluded).
pub functions: u32,
/// Of those, the ones in the test-reachable set.
pub covered: u32,
/// CALLS sites arriving at them, from anywhere in the workspace.
pub callers: u32,
/// `covered` counts functions a test EXECUTED (`coverage_fn` of the repo's
/// newest coverage run), not the ones a test can reach.
pub line_measured: bool,
}
/// A function as the code room places it — what a `coverage_fn` row joins to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpanFn {
pub ordinal: u64,
pub repo: String,
pub file: String,
pub line: u32,
pub end_line: u32,
}
/// What [`join_line_coverage`] made of a coverage run.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LineJoin {
/// Room functions a test executed.
pub covered: BTreeSet<u64>,
/// Room functions the coverage run measured at all (executed or not).
pub measured: BTreeSet<u64>,
/// The repos a coverage run covers.
pub repos: BTreeSet<String>,
/// Rows no room function's span holds (another build of the file, a
/// macro-only function, a row without a line).
pub unjoined: usize,
}
/// **Join `coverage_fn` rows to the room's functions** by `(repo, file, line)`:
/// a measured function belongs to the INNERMOST room function whose span holds
/// its first line (a closure's record lands on the closure when the room has it,
/// else on the function around it). A room function is covered when any record
/// that lands on it was executed — generic instances are one record each.
pub fn join_line_coverage(fns: &[SpanFn], rows: &[super::rows::CoverageFnFact]) -> LineJoin {
let mut by_file: BTreeMap<(&str, &str), Vec<&SpanFn>> = BTreeMap::new();
for f in fns {
by_file.entry((f.repo.as_str(), f.file.as_str())).or_default().push(f);
}
let mut out = LineJoin::default();
for r in rows {
out.repos.insert(r.repo.clone());
let hit = by_file.get(&(r.repo.as_str(), r.file.as_str())).and_then(|cands| {
cands
.iter()
.filter(|f| r.line > 0 && f.line <= r.line && r.line <= f.end_line.max(f.line))
.max_by(|a, b| a.line.cmp(&b.line).then(b.end_line.cmp(&a.end_line)))
});
match hit {
Some(f) => {
out.measured.insert(f.ordinal);
if r.exercised() {
out.covered.insert(f.ordinal);
}
}
None => out.unjoined += 1,
}
}
out
}
/// **Score change risk per file.** `sources` carries `git_heat_facts` (latest
/// snapshot per repo) and `coverage_fn`; `feeds` is the record of every input.
pub fn analyze(files: &[FileFacts], sources: &Sources, feeds: Vec<Feed>, top_n: usize) -> RiskReport {
let line_cov = !sources.get("coverage_fn").is_empty();
// `semver_diffs` has no producer (the coderoom ingest is gone): the
// published-API column is not evaluated.
let semver_available = false;
let coverage_basis = if line_cov {
"coverage_fn (source-line, sense C)".to_string()
} else {
"test-reachability (sense B, from the code room's CALLS / TESTS edges) — `coverage_fn` is EMPTY. \
Reachability is MORE generous than line coverage, so risk is UNDER-reported."
.to_string()
};
let heat = sources.get("git_heat_facts");
let hc = heat.cursor(&["repo", "file", "commits_90d"]);
let mut churn: BTreeMap<(String, String), u64> = BTreeMap::new();
for r in hc.rows() {
churn.insert(
(hc.get(r, 0).to_string(), hc.get(r, 1).to_string()),
hc.get(r, 2).trim().parse().unwrap_or(0),
);
}
let mut units: Vec<RiskUnit> = files
.iter()
.map(|f| {
let churn_90d = churn.get(&(f.repo.clone(), f.file.clone())).copied().unwrap_or(0);
let coverage_frac = if f.functions == 0 { 0.0 } else { f.covered.min(f.functions) as f64 / f.functions as f64 };
RiskUnit {
repo: f.repo.clone(),
file: f.file.clone(),
downstream_callers: f.callers,
churn_90d,
coverage_frac,
risk: f.callers as f64 * churn_90d as f64 * (1.0 - coverage_frac),
functions: f.functions,
breaks_published_api: semver_available.then_some(false),
coverage_basis: if f.line_measured { "line" } else { "reach" }.to_string(),
}
})
.collect();
let files_scored = units.len();
units.retain(|u| u.risk > 0.0);
units.sort_by(|a, b| {
b.risk
.partial_cmp(&a.risk)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.file.cmp(&b.file))
});
units.truncate(top_n);
let evidence_note = evidence_note(&feeds, "change risk");
RiskReport {
units,
files_scored,
feeds,
coverage_basis,
semver_available,
evidence_note,
model_attribution: ATTRIBUTION.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::super::rows::Table;
use super::*;
fn heat(rows: &[&[&str]]) -> Sources {
let t = Table::new(
["repo", "file", "commits_90d"].iter().map(|s| s.to_string()).collect(),
rows.iter().map(|r| r.iter().map(|s| s.to_string()).collect()).collect(),
);
let mut s = Sources::default();
s.add_root("/t", [("git_heat_facts".to_string(), t)].into());
s
}
fn file(functions: u32, covered: u32, callers: u32) -> FileFacts {
FileFacts { repo: "korp".into(), file: "src/hot.rs".into(), functions, covered, callers, line_measured: false }
}
fn span(o: u64, line: u32, end_line: u32) -> SpanFn {
SpanFn { ordinal: o, repo: "shop".into(), file: "src/lib.rs".into(), line, end_line }
}
fn cov(line: u32, covered: i64) -> super::super::rows::CoverageFnFact {
super::super::rows::CoverageFnFact { repo: "shop".into(), file: "src/lib.rs".into(), line, end_line: line + 1, regions: 2, regions_covered: covered, ..Default::default() }
}
#[test]
fn a_coverage_row_lands_on_the_innermost_room_function_its_line_lies_in() {
// f 10–30 holds g 12–20; h 40–44 is never executed; one row lies nowhere
let fns = [span(1, 10, 30), span(2, 12, 20), span(3, 40, 44)];
let j = join_line_coverage(&fns, &[cov(12, 1), cov(10, 0), cov(40, 0), cov(99, 1), cov(0, 1)]);
assert_eq!(j.covered, [2].into(), "g ran; f did not: {j:?}");
assert_eq!(j.measured, [1, 2, 3].into());
assert_eq!(j.unjoined, 2, "line 99 lies in no function, a row with no line joins nothing");
assert_eq!(j.repos, ["shop".to_string()].into());
}
#[test]
fn risk_is_callers_times_churn_times_uncovered_and_every_input_moves_it() {
let s = heat(&[&["korp", "src/hot.rs", "20"]]);
let feeds = s.feeds(&["git_heat_facts"]);
let uncovered = analyze(&[file(1, 0, 10)], &s, feeds.clone(), 10);
let u = &uncovered.units[0];
assert_eq!(u.downstream_callers, 10);
assert_eq!(u.churn_90d, 20);
assert_eq!(u.risk, 200.0);
// half covered halves it
assert_eq!(analyze(&[file(2, 1, 10)], &s, feeds.clone(), 10).units[0].risk, 100.0);
// fully covered: scored, not a finding
let now = analyze(&[file(1, 1, 10)], &s, feeds.clone(), 10);
assert!(now.units.is_empty());
assert_eq!(now.files_scored, 1);
// no caller, or no churn: no risk
assert!(analyze(&[file(1, 0, 0)], &s, feeds.clone(), 10).units.is_empty());
assert!(analyze(&[file(1, 0, 10)], &heat(&[]), feeds, 10).units.is_empty());
}
#[test]
fn the_missing_line_coverage_table_is_declared_with_its_bias() {
let s = heat(&[]);
let r = analyze(&[], &s, s.feeds(&["git_heat_facts", "coverage_fn"]), 5);
assert!(r.coverage_basis.contains("EMPTY"), "{}", r.coverage_basis);
assert!(r.coverage_basis.contains("UNDER-reported"), "{}", r.coverage_basis);
assert!(r.evidence_note.contains("MISSING FEEDS"), "{}", r.evidence_note);
}
}