use crate::escalation_metrics::traced_defects_from_tickets;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
const ASSISTED_CHANGE_SHARE_DEFINITION: &str = "Merged mission changes \
closed in the window (missions COMPLETED whose branch tip is an ancestor of \
the live base tip — agent-involved by construction, a mission being an agent \
run) as a share of all first-parent commits landed on the windowed missions' \
base branch in the window (by commit date). Non-mission commits are invisible \
to the event log, so the denominator is the total the git probe can see; \
missions landed out-of-band (fast-forward, squash, or outside their \
completion window) read as unattributed — a stated under-read, never an \
inflation.";
const DEFECT_DENSITY_DEFINITION: &str = "Defect tickets traced to missions \
merged in the window (traced-from-mission frontmatter — recorded data entry, \
never inference) per merged change in the same window (missions COMPLETED \
whose branch tip is an ancestor of the live base tip). Both sides are \
mission-scoped: defects without a traced mission and missions that never \
merged enter neither side.";
const DEFECT_RESOLUTION_TIME_DEFINITION: &str = "Mean wall-clock time from \
defect open to defect close over traced defect tickets. Uncomputable today: \
the defect record (ticket frontmatter with state and the traced-from-mission \
link) carries no lifecycle timestamps, so neither an open nor a close \
instant exists — the slot stays empty rather than approximating.";
const DEFECT_RESOLUTION_TIME_DEPENDENCY: &str = "ticket open/close timestamps \
— defect tickets record lifecycle state but no lifecycle time";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComparisonReport {
pub window_days: u64,
pub assisted_change_share: AssistedChangeShare,
pub defect_density: DefectDensity,
pub defect_resolution_time: DefectResolutionTime,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AssistedChangeShare {
pub definition: String,
pub agent_changes: u64,
pub total_changes: Option<u64>,
pub base_branch: Option<String>,
pub share: Option<f64>,
pub dependency: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DefectDensity {
pub definition: String,
pub traced_defects: u64,
pub merged_changes: u64,
pub defects_per_merged_change: Option<f64>,
pub dependency: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DefectResolutionTime {
pub definition: String,
pub dependency: Option<String>,
}
pub fn compute_comparison_report(
repo_root: &std::path::Path,
window_days: u64,
now: DateTime<Utc>,
) -> anyhow::Result<ComparisonReport> {
let index_contents = std::fs::read_to_string(
crate::paths::MissionPaths::new(repo_root, "_")
.missions_dir()
.join("index.md"),
)
.unwrap_or_default();
let mut ids = crate::paths::MissionPaths::list_missions(repo_root);
for id in crate::mission_catalog::mission_index_ids(&index_contents) {
if !ids.contains(&id) {
ids.push(id);
}
}
ids.sort();
let mut inputs: Vec<(String, crate::outcomes::ComparisonInputs)> = Vec::new();
for id in ids {
let paths = crate::paths::MissionPaths::new(repo_root, &id);
let events_path = paths.events_file();
if !events_path.is_file() {
continue;
}
if paths.require_no_follow().is_err() {
continue;
}
let Some(out) = crate::outcomes::cached_mission_outcomes(&id, &events_path) else {
continue; };
inputs.push((id, out.comparison));
}
comparison_report_from_inputs(repo_root, &inputs, window_days, now)
}
pub(crate) fn comparison_report_from_inputs(
repo_root: &std::path::Path,
inputs: &[(String, crate::outcomes::ComparisonInputs)],
window_days: u64,
now: DateTime<Utc>,
) -> anyhow::Result<ComparisonReport> {
if window_days > crate::outcomes::MAX_MERGED_CHANGE_WINDOW_DAYS {
return Err(crate::error::EngineError::InvalidState(format!(
"window_days {window_days} exceeds the maximum {} days",
crate::outcomes::MAX_MERGED_CHANGE_WINDOW_DAYS
))
.into());
}
let days = i64::try_from(window_days).map_err(|_| {
crate::error::EngineError::InvalidState(format!(
"window_days {window_days} is out of range"
))
})?;
let window = chrono::Duration::try_days(days).ok_or_else(|| {
crate::error::EngineError::InvalidState(format!(
"window_days {window_days} is out of range"
))
})?;
let cutoff = now - window;
let repo = crate::git_ops::GitRepo::open(repo_root).ok();
let mut merged_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut base_counts: std::collections::BTreeMap<String, u64> =
std::collections::BTreeMap::new();
for (id, input) in inputs {
let Some(terminal_ts) = input.terminal_ts else {
continue; };
if terminal_ts < cutoff || terminal_ts > now {
continue;
}
if let Some(base) = &input.base_branch {
*base_counts.entry(base.clone()).or_insert(0) += 1;
}
if let (Some(repo), Some(folded)) = (repo.as_ref(), input.folded.as_ref()) {
if folded.status == crate::types::MissionStatus::Complete
&& crate::merged::merged_bit_for_branches(
repo,
&folded.mission_branch,
&folded.base_branch,
) == Some(true)
{
merged_ids.insert(id.clone());
}
}
}
let base_branch = base_counts
.iter()
.max_by(|a, b| a.1.cmp(b.1))
.map(|(branch, _)| branch.clone());
let (total_changes, share_dependency) = match (&base_branch, repo.as_ref()) {
(None, _) => (
None,
Some(
"a base-branch anchor from windowed mission data — no missions \
closed in the window"
.to_string(),
),
),
(Some(base), Some(repo)) => match repo.count_first_parent_commits(base, &cutoff, &now) {
Ok(count) => (Some(count), None),
Err(_) => (None, Some(git_denominator_dependency())),
},
(Some(_), None) => (None, Some(git_denominator_dependency())),
};
let agent_changes = merged_ids.len() as u64;
let share = total_changes
.filter(|total| *total > 0)
.map(|total| agent_changes as f64 / total as f64);
let traced_defects = traced_defects_from_tickets(repo_root)
.iter()
.filter(|defect| merged_ids.contains(&defect.mission_id))
.count() as u64;
let merged_changes = agent_changes;
let (defects_per_merged_change, density_dependency) = if merged_changes > 0 {
(Some(traced_defects as f64 / merged_changes as f64), None)
} else {
(
None,
Some("merged changes in the window — the density denominator".to_string()),
)
};
Ok(ComparisonReport {
window_days,
assisted_change_share: AssistedChangeShare {
definition: ASSISTED_CHANGE_SHARE_DEFINITION.to_string(),
agent_changes,
total_changes,
base_branch,
share,
dependency: share_dependency,
},
defect_density: DefectDensity {
definition: DEFECT_DENSITY_DEFINITION.to_string(),
traced_defects,
merged_changes,
defects_per_merged_change,
dependency: density_dependency,
},
defect_resolution_time: DefectResolutionTime {
definition: DEFECT_RESOLUTION_TIME_DEFINITION.to_string(),
dependency: Some(DEFECT_RESOLUTION_TIME_DEPENDENCY.to_string()),
},
})
}
fn git_denominator_dependency() -> String {
"a git probe for the landed-changes denominator — the repository or the \
base ref is unavailable"
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::events::{Event, EventKind};
use crate::types::MissionConfig;
const NOW_MS: i64 = 1_754_000_000_000;
const DAY_MS: i64 = 86_400_000;
fn now() -> DateTime<Utc> {
DateTime::from_timestamp_millis(NOW_MS).unwrap()
}
fn ev(seq: u64, mission_id: &str, ts_ms: i64, kind: EventKind) -> Event {
Event {
seq,
ts: DateTime::from_timestamp_millis(ts_ms).unwrap(),
mission_id: mission_id.to_string(),
kind,
}
}
fn completed_mission_events(mission_id: &str, terminal_ms: i64) -> Vec<Event> {
vec![
ev(
1,
mission_id,
terminal_ms - 1_000,
EventKind::MissionCreated {
goal: "fixture mission".into(),
base_branch: "main".into(),
mission_branch: format!("kranz/{mission_id}"),
config: MissionConfig::default(),
},
),
ev(2, mission_id, terminal_ms, EventKind::MissionCompleted {}),
]
}
fn write_timed_events(repo_root: &std::path::Path, mission_id: &str, events: Vec<Event>) {
let dir = repo_root.join(".kranz").join("missions").join(mission_id);
std::fs::create_dir_all(&dir).unwrap();
let lines: Vec<String> = events
.iter()
.map(|e| serde_json::to_string(e).unwrap())
.collect();
std::fs::write(dir.join("events.jsonl"), lines.join("\n") + "\n").unwrap();
}
#[test]
fn comparison_metrics_empty_repo_yields_absent_slots_and_named_dependencies() {
let tmp = tempfile::TempDir::new().unwrap();
let report = compute_comparison_report(tmp.path(), 30, now()).unwrap();
assert_eq!(report.window_days, 30);
let share = &report.assisted_change_share;
assert_eq!(share.agent_changes, 0);
assert_eq!(share.total_changes, None);
assert_eq!(share.base_branch, None);
assert_eq!(share.share, None);
assert!(
share
.dependency
.as_deref()
.is_some_and(|d| d.contains("no missions closed in the window")),
"the empty slot names its dependency: {share:?}"
);
let density = &report.defect_density;
assert_eq!(density.traced_defects, 0);
assert_eq!(density.merged_changes, 0);
assert_eq!(density.defects_per_merged_change, None);
assert!(
density
.dependency
.as_deref()
.is_some_and(|d| d.contains("the density denominator")),
"{density:?}"
);
let resolution = &report.defect_resolution_time;
assert!(
resolution
.dependency
.as_deref()
.is_some_and(|d| d.contains("open/close timestamps")),
"{resolution:?}"
);
assert!(share.definition.contains("agent-involved by construction"));
assert!(density
.definition
.contains("traced-from-mission frontmatter"));
assert!(resolution.definition.contains("no lifecycle timestamps"));
}
#[test]
fn comparison_metrics_no_git_repo_degrades_the_git_derived_denominator() {
let tmp = tempfile::TempDir::new().unwrap();
write_timed_events(
tmp.path(),
"m-1",
completed_mission_events("m-1", NOW_MS - DAY_MS),
);
let report = compute_comparison_report(tmp.path(), 30, now()).unwrap();
let share = &report.assisted_change_share;
assert_eq!(share.agent_changes, 0, "no merged probe without git");
assert_eq!(share.base_branch.as_deref(), Some("main"));
assert_eq!(share.total_changes, None);
assert_eq!(share.share, None);
assert!(
share
.dependency
.as_deref()
.is_some_and(|d| d.contains("a git probe")),
"{share:?}"
);
}
#[test]
fn comparison_metrics_window_over_max_is_an_honest_error() {
let tmp = tempfile::TempDir::new().unwrap();
let result = compute_comparison_report(
tmp.path(),
crate::outcomes::MAX_MERGED_CHANGE_WINDOW_DAYS + 1,
now(),
);
assert!(result.is_err(), "an over-bound window errors, never wraps");
}
}