kranz_engine/comparison_metrics.rs
1//! The industry-comparison set beside the kranz-native outcomes (ticket
2//! `outcomes-comparison-metrics`, KRZ-333; design:
3//! `docs/scoping/governance-evidence-layer.md`, scored-gates addendum).
4//!
5//! WHY this section exists: the kranz-native metrics (autonomy ratio, cost
6//! per change, false-green rate, the escalation ledger — defined in
7//! `docs/metrics.md`) answer "should you have let it?", but an outside
8//! reader's frame is the industry-legible volume proxies: how much of the
9//! landed work involved an agent, how many defects each unit of change
10//! produced, how fast defects closed. Their absence makes the report
11//! unreadable rather than rigorous — and publishing both sets shows that
12//! volume metrics can improve while false greens go unmeasured, which makes
13//! the case better than either set alone. The natives stay PRIMARY: this
14//! section renders after them, clearly separated, and every metric states
15//! its definition INLINE in the output because these metrics are
16//! self-defined across the industry — the definition is the whole argument.
17//!
18//! Pure-fold style, mirroring [`crate::outcomes::compute_cost_per_merged_change`]:
19//! a function over (the event logs, the ticket list, live git refs, and a
20//! caller-pinned window), derived per request and never stored — there is no
21//! second persisted source of truth. The honesty rule is inherited from the
22//! native fold and strengthened here: a slot whose data the fold cannot see
23//! renders EMPTY and names its dependency, never an approximation
24//! (absent > approximated, always).
25//!
26//! The three dispositions, and why:
27//!
28//! 1. **Assisted-change share** — RENDERED. The numerator is the native
29//! merged-change derivation (missions COMPLETED in the window whose
30//! branch tip is an ancestor of the live base tip); every merged mission
31//! change is agent-involved by construction, a mission being an agent
32//! run. The denominator is the total change count the fold can see:
33//! first-parent commits landed on the windowed missions' modal base
34//! branch inside the window (git's committer dates). The event log
35//! cannot see non-mission commits — only the git probe can — and a
36//! mission landed out-of-band (fast-forward, squash, or outside its
37//! completion window) reads as unattributed; the inline definition says
38//! exactly this, so the share is a stated reading, never an inflated
39//! one.
40//! 2. **Defect density per merged change** — RENDERED from the landed
41//! false-green↔defect linkage (flight surgeon): defect tickets naming a
42//! mission via `traced-from-mission` frontmatter (recorded data entry,
43//! never inference) joined to missions merged in the window, over the
44//! window's merged changes. Both sides are mission-scoped by
45//! construction — the only defect links the fold can join are
46//! mission-traced ones, and unmerged missions ship no change.
47//! 3. **Defect resolution time** — EMPTY, naming its dependency. The defect
48//! record is ticket frontmatter (title/priority/`state`/
49//! `traced-from-mission`): it carries lifecycle STATE but no lifecycle
50//! TIME — no open instant, no close instant, nothing to subtract. The
51//! ticket's own acceptance rule applies: the slot stays visibly empty
52//! rather than approximating from unrelated timestamps.
53
54use crate::escalation_metrics::traced_defects_from_tickets;
55use chrono::{DateTime, Utc};
56use serde::{Deserialize, Serialize};
57
58/// The inline definition of the assisted-change share, rendered verbatim in
59/// text and JSON — the definition is the whole argument for an
60/// industry-legible metric, so it travels with the data.
61const ASSISTED_CHANGE_SHARE_DEFINITION: &str = "Merged mission changes \
62closed in the window (missions COMPLETED whose branch tip is an ancestor of \
63the live base tip — agent-involved by construction, a mission being an agent \
64run) as a share of all first-parent commits landed on the windowed missions' \
65base branch in the window (by commit date). Non-mission commits are invisible \
66to the event log, so the denominator is the total the git probe can see; \
67missions landed out-of-band (fast-forward, squash, or outside their \
68completion window) read as unattributed — a stated under-read, never an \
69inflation.";
70
71/// The inline definition of defect density per merged change.
72const DEFECT_DENSITY_DEFINITION: &str = "Defect tickets traced to missions \
73merged in the window (traced-from-mission frontmatter — recorded data entry, \
74never inference) per merged change in the same window (missions COMPLETED \
75whose branch tip is an ancestor of the live base tip). Both sides are \
76mission-scoped: defects without a traced mission and missions that never \
77merged enter neither side.";
78
79/// The inline definition of defect resolution time — including WHY it is
80/// empty today: the honest output is an empty slot, not an approximation.
81const DEFECT_RESOLUTION_TIME_DEFINITION: &str = "Mean wall-clock time from \
82defect open to defect close over traced defect tickets. Uncomputable today: \
83the defect record (ticket frontmatter with state and the traced-from-mission \
84link) carries no lifecycle timestamps, so neither an open nor a close \
85instant exists — the slot stays empty rather than approximating.";
86
87/// Why the defect-resolution-time slot is empty (the named dependency).
88const DEFECT_RESOLUTION_TIME_DEPENDENCY: &str = "ticket open/close timestamps \
89— defect tickets record lifecycle state but no lifecycle time";
90
91/// The industry-comparison set (KRZ-333), folded over one window. Carried on
92/// [`crate::outcomes::Outcomes::comparison`] when the fold options pin a
93/// window; rendered as a clearly-separated secondary section after the
94/// kranz-native metrics.
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96#[serde(rename_all = "camelCase")]
97pub struct ComparisonReport {
98 /// The window in effect (days, ending at the caller-pinned `now`).
99 pub window_days: u64,
100 pub assisted_change_share: AssistedChangeShare,
101 pub defect_density: DefectDensity,
102 pub defect_resolution_time: DefectResolutionTime,
103}
104
105/// Assisted-change share: merged mission changes over all landed changes the
106/// fold can see, in one window. Every absent piece names its dependency via
107/// [`AssistedChangeShare::dependency`] — never an approximation.
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase")]
110pub struct AssistedChangeShare {
111 /// [`ASSISTED_CHANGE_SHARE_DEFINITION`], carried inline in the output.
112 pub definition: String,
113 /// Merged mission changes in the window (the native merged-change
114 /// derivation) — every one agent-involved by construction.
115 pub agent_changes: u64,
116 /// First-parent commits landed on `base_branch` in the window (by
117 /// committer date) — the total change count the fold can see. `None`
118 /// when there is no base anchor (nothing closed in the window) or the
119 /// git probe failed.
120 pub total_changes: Option<u64>,
121 /// The modal base branch of the window's closed missions (ties broken
122 /// lexicographically) — the branch `total_changes` counts. Recorded even
123 /// when the count itself is unavailable, so the report shows WHAT could
124 /// not be counted.
125 pub base_branch: Option<String>,
126 /// agent_changes / total_changes — `None` when the denominator is
127 /// unavailable or zero (absent, never a fabricated percentage).
128 pub share: Option<f64>,
129 /// What the slot lacks when `share` is `None` (the named dependency);
130 /// `None` when the share computed.
131 pub dependency: Option<String>,
132}
133
134/// Defect density per merged change: traced defects joined to missions
135/// merged in the window, over those merged changes.
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct DefectDensity {
139 /// [`DEFECT_DENSITY_DEFINITION`], carried inline in the output.
140 pub definition: String,
141 /// Defect tickets whose `traced-from-mission` link joins a mission
142 /// merged in the window (each ticket counts once — density counts
143 /// defects, unlike the false-green rate which counts missions once).
144 pub traced_defects: u64,
145 /// Merged changes in the window (same derivation as
146 /// [`AssistedChangeShare::agent_changes`]).
147 pub merged_changes: u64,
148 /// traced_defects / merged_changes — `None` when nothing merged in the
149 /// window (the denominator is absent, never zero-filled).
150 pub defects_per_merged_change: Option<f64>,
151 /// What the slot lacks when the density is `None`; `None` when computed.
152 pub dependency: Option<String>,
153}
154
155/// Defect resolution time (defect open → close). EMPTY today and naming its
156/// dependency: the traced defect record carries lifecycle state but no
157/// lifecycle timestamps. Value fields arrive additively when defect tickets
158/// record open/close instants — the wire shape below is the stable part.
159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
160#[serde(rename_all = "camelCase")]
161pub struct DefectResolutionTime {
162 /// [`DEFECT_RESOLUTION_TIME_DEFINITION`], carried inline in the output.
163 pub definition: String,
164 /// What the slot lacks (`Some` while the data does not exist — always
165 /// today); `None` once the metric computes.
166 pub dependency: Option<String>,
167}
168
169/// Fold the industry-comparison set for one repo over `window_days` ending
170/// at `now`. Pure over (event logs, ticket list, live git refs, the pinned
171/// window): the same inputs always yield identical data, and nothing is
172/// persisted. Degrades per-row exactly like
173/// [`crate::outcomes::compute_cost_per_merged_change`]: a mission with an
174/// unreadable/corrupt log is skipped; a log the strict reducer rejects
175/// still anchors its base branch (recovered from `mission.created`
176/// directly) but yields no merged change; and a repo git fails to open
177/// simply yields no merged changes and no landed-changes count (the slots
178/// read absent and name why, never zero). A `window_days` over
179/// [`crate::outcomes::MAX_MERGED_CHANGE_WINDOW_DAYS`] is an honest error,
180/// never a wrapped computation.
181///
182/// The per-mission log reads ride the native fold's memoized per-mission
183/// struct ([`crate::outcomes::cached_mission_outcomes`], 14th-pass review —
184/// this path used to re-read every events.jsonl `compute_outcomes` had just
185/// parsed, scanning each log twice per request); only the git probes run
186/// live here, since branch tips move independently of the logs.
187pub fn compute_comparison_report(
188 repo_root: &std::path::Path,
189 window_days: u64,
190 now: DateTime<Utc>,
191) -> anyhow::Result<ComparisonReport> {
192 let index_contents = std::fs::read_to_string(
193 crate::paths::MissionPaths::new(repo_root, "_")
194 .missions_dir()
195 .join("index.md"),
196 )
197 .unwrap_or_default();
198
199 let mut ids = crate::paths::MissionPaths::list_missions(repo_root);
200 for id in crate::mission_catalog::mission_index_ids(&index_contents) {
201 if !ids.contains(&id) {
202 ids.push(id);
203 }
204 }
205 ids.sort();
206
207 let mut inputs: Vec<(String, crate::outcomes::ComparisonInputs)> = Vec::new();
208 for id in ids {
209 let paths = crate::paths::MissionPaths::new(repo_root, &id);
210 let events_path = paths.events_file();
211 if !events_path.is_file() {
212 continue;
213 }
214 if paths.require_no_follow().is_err() {
215 continue;
216 }
217 let Some(out) = crate::outcomes::cached_mission_outcomes(&id, &events_path) else {
218 continue; // corrupt log degrades per-mission, never fails
219 };
220 inputs.push((id, out.comparison));
221 }
222 comparison_report_from_inputs(repo_root, &inputs, window_days, now)
223}
224
225/// The comparison fold over PRE-FOLDED per-mission inputs — shared by
226/// [`compute_comparison_report`] and the [`crate::outcomes`] report path, so
227/// a request that already folded every log never scans one again. All
228/// log-derived data comes in via `inputs`; only the git probes
229/// (landed-changes denominator, per-mission merged bits) run here, live.
230pub(crate) fn comparison_report_from_inputs(
231 repo_root: &std::path::Path,
232 inputs: &[(String, crate::outcomes::ComparisonInputs)],
233 window_days: u64,
234 now: DateTime<Utc>,
235) -> anyhow::Result<ComparisonReport> {
236 // Bound the window BEFORE any arithmetic — the same guard and rationale
237 // as compute_cost_per_merged_change (the `u64 → i64` conversion and the
238 // chrono subtraction must never wrap, for ANY caller).
239 if window_days > crate::outcomes::MAX_MERGED_CHANGE_WINDOW_DAYS {
240 return Err(crate::error::EngineError::InvalidState(format!(
241 "window_days {window_days} exceeds the maximum {} days",
242 crate::outcomes::MAX_MERGED_CHANGE_WINDOW_DAYS
243 ))
244 .into());
245 }
246 let days = i64::try_from(window_days).map_err(|_| {
247 crate::error::EngineError::InvalidState(format!(
248 "window_days {window_days} is out of range"
249 ))
250 })?;
251 let window = chrono::Duration::try_days(days).ok_or_else(|| {
252 crate::error::EngineError::InvalidState(format!(
253 "window_days {window_days} is out of range"
254 ))
255 })?;
256 let cutoff = now - window;
257 let repo = crate::git_ops::GitRepo::open(repo_root).ok();
258
259 // Merged mission changes in the window (the native derivation: closed
260 // COMPLETE with the branch landed), plus every closed mission's base
261 // branch — the anchor pool for the landed-changes denominator.
262 let mut merged_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
263 let mut base_counts: std::collections::BTreeMap<String, u64> =
264 std::collections::BTreeMap::new();
265
266 for (id, input) in inputs {
267 // The window keys on the terminal event's own timestamp, inclusive at
268 // both ends (the same rule as the sibling fold).
269 let Some(terminal_ts) = input.terminal_ts else {
270 continue; // still open — in no closed window
271 };
272 if terminal_ts < cutoff || terminal_ts > now {
273 continue;
274 }
275 // The base-branch anchor comes from `mission.created` DIRECTLY (the
276 // same recovery mission_outcomes uses for the config and goal): a
277 // log the strict reducer rejects — hand-edited, or carrying an
278 // event the reducer rules out — still anchors the denominator.
279 if let Some(base) = &input.base_branch {
280 *base_counts.entry(base.clone()).or_insert(0) += 1;
281 }
282 // Merged change: closed COMPLETE and the mission branch landed on the
283 // live base (merged.rs's probe at fold time, never stored) — the
284 // reducer-backed derivation exactly as compute_cost_per_merged_change
285 // runs it; a log the reducer rejects simply yields no merged change
286 // (degrade per-mission, never fail the fold).
287 if let (Some(repo), Some(folded)) = (repo.as_ref(), input.folded.as_ref()) {
288 if folded.status == crate::types::MissionStatus::Complete
289 && crate::merged::merged_bit_for_branches(
290 repo,
291 &folded.mission_branch,
292 &folded.base_branch,
293 ) == Some(true)
294 {
295 merged_ids.insert(id.clone());
296 }
297 }
298 }
299
300 // The landed-changes denominator: first-parent commits on the windowed
301 // missions' modal base branch (most closed missions; ties resolve to the
302 // lexicographically largest name — `max_by` keeps the last maximum over
303 // the BTreeMap's ascending order — deterministic either way).
304 // git's --since is exclusive and --until inclusive — the mission side of
305 // the window is inclusive at both ends, a one-instant asymmetry at the
306 // cutoff that the inline definition's "by commit date" phrasing covers.
307 let base_branch = base_counts
308 .iter()
309 .max_by(|a, b| a.1.cmp(b.1))
310 .map(|(branch, _)| branch.clone());
311 let (total_changes, share_dependency) = match (&base_branch, repo.as_ref()) {
312 (None, _) => (
313 None,
314 Some(
315 "a base-branch anchor from windowed mission data — no missions \
316 closed in the window"
317 .to_string(),
318 ),
319 ),
320 (Some(base), Some(repo)) => match repo.count_first_parent_commits(base, &cutoff, &now) {
321 Ok(count) => (Some(count), None),
322 Err(_) => (None, Some(git_denominator_dependency())),
323 },
324 (Some(_), None) => (None, Some(git_denominator_dependency())),
325 };
326
327 let agent_changes = merged_ids.len() as u64;
328 let share = total_changes
329 .filter(|total| *total > 0)
330 .map(|total| agent_changes as f64 / total as f64);
331
332 // Defect density: the landed false-green↔defect linkage
333 // (escalation_metrics::traced_defects_from_tickets) joined to the
334 // window's merged missions, over the merged changes themselves.
335 let traced_defects = traced_defects_from_tickets(repo_root)
336 .iter()
337 .filter(|defect| merged_ids.contains(&defect.mission_id))
338 .count() as u64;
339 let merged_changes = agent_changes;
340 let (defects_per_merged_change, density_dependency) = if merged_changes > 0 {
341 (Some(traced_defects as f64 / merged_changes as f64), None)
342 } else {
343 (
344 None,
345 Some("merged changes in the window — the density denominator".to_string()),
346 )
347 };
348
349 Ok(ComparisonReport {
350 window_days,
351 assisted_change_share: AssistedChangeShare {
352 definition: ASSISTED_CHANGE_SHARE_DEFINITION.to_string(),
353 agent_changes,
354 total_changes,
355 base_branch,
356 share,
357 dependency: share_dependency,
358 },
359 defect_density: DefectDensity {
360 definition: DEFECT_DENSITY_DEFINITION.to_string(),
361 traced_defects,
362 merged_changes,
363 defects_per_merged_change,
364 dependency: density_dependency,
365 },
366 // EMPTY, naming its dependency: traced defect records carry no
367 // lifecycle timestamps (see the module doc) — never approximated.
368 defect_resolution_time: DefectResolutionTime {
369 definition: DEFECT_RESOLUTION_TIME_DEFINITION.to_string(),
370 dependency: Some(DEFECT_RESOLUTION_TIME_DEPENDENCY.to_string()),
371 },
372 })
373}
374
375/// The named dependency when the landed-changes denominator cannot be
376/// produced: it is git-derived, so an unopenable repo or a missing base ref
377/// leaves the slot empty with this reason.
378fn git_denominator_dependency() -> String {
379 "a git probe for the landed-changes denominator — the repository or the \
380 base ref is unavailable"
381 .to_string()
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387 use crate::events::{Event, EventKind};
388 use crate::types::MissionConfig;
389
390 /// The fixed "now" every window assertion keys on (ms since epoch) — the
391 /// window is an input, so the fold stays deterministic under test.
392 const NOW_MS: i64 = 1_754_000_000_000;
393 const DAY_MS: i64 = 86_400_000;
394
395 fn now() -> DateTime<Utc> {
396 DateTime::from_timestamp_millis(NOW_MS).unwrap()
397 }
398
399 fn ev(seq: u64, mission_id: &str, ts_ms: i64, kind: EventKind) -> Event {
400 Event {
401 seq,
402 ts: DateTime::from_timestamp_millis(ts_ms).unwrap(),
403 mission_id: mission_id.to_string(),
404 kind,
405 }
406 }
407
408 /// A mission created and closed COMPLETED at `terminal_ms` (the minimal
409 /// log the reducer will fold).
410 fn completed_mission_events(mission_id: &str, terminal_ms: i64) -> Vec<Event> {
411 vec![
412 ev(
413 1,
414 mission_id,
415 terminal_ms - 1_000,
416 EventKind::MissionCreated {
417 goal: "fixture mission".into(),
418 base_branch: "main".into(),
419 mission_branch: format!("kranz/{mission_id}"),
420 config: MissionConfig::default(),
421 },
422 ),
423 ev(2, mission_id, terminal_ms, EventKind::MissionCompleted {}),
424 ]
425 }
426
427 fn write_timed_events(repo_root: &std::path::Path, mission_id: &str, events: Vec<Event>) {
428 let dir = repo_root.join(".kranz").join("missions").join(mission_id);
429 std::fs::create_dir_all(&dir).unwrap();
430 let lines: Vec<String> = events
431 .iter()
432 .map(|e| serde_json::to_string(e).unwrap())
433 .collect();
434 std::fs::write(dir.join("events.jsonl"), lines.join("\n") + "\n").unwrap();
435 }
436
437 #[test]
438 fn comparison_metrics_empty_repo_yields_absent_slots_and_named_dependencies() {
439 let tmp = tempfile::TempDir::new().unwrap();
440 let report = compute_comparison_report(tmp.path(), 30, now()).unwrap();
441
442 assert_eq!(report.window_days, 30);
443 // Assisted-change share: no missions closed in the window, so there
444 // is no base anchor — the slot is absent and names why.
445 let share = &report.assisted_change_share;
446 assert_eq!(share.agent_changes, 0);
447 assert_eq!(share.total_changes, None);
448 assert_eq!(share.base_branch, None);
449 assert_eq!(share.share, None);
450 assert!(
451 share
452 .dependency
453 .as_deref()
454 .is_some_and(|d| d.contains("no missions closed in the window")),
455 "the empty slot names its dependency: {share:?}"
456 );
457 // Defect density: no merged changes — the denominator is absent,
458 // never zero-filled.
459 let density = &report.defect_density;
460 assert_eq!(density.traced_defects, 0);
461 assert_eq!(density.merged_changes, 0);
462 assert_eq!(density.defects_per_merged_change, None);
463 assert!(
464 density
465 .dependency
466 .as_deref()
467 .is_some_and(|d| d.contains("the density denominator")),
468 "{density:?}"
469 );
470 // Defect resolution time: EMPTY, naming the missing lifecycle
471 // timestamps — never approximated.
472 let resolution = &report.defect_resolution_time;
473 assert!(
474 resolution
475 .dependency
476 .as_deref()
477 .is_some_and(|d| d.contains("open/close timestamps")),
478 "{resolution:?}"
479 );
480 // Each metric carries its inline definition (tested as content).
481 assert!(share.definition.contains("agent-involved by construction"));
482 assert!(density
483 .definition
484 .contains("traced-from-mission frontmatter"));
485 assert!(resolution.definition.contains("no lifecycle timestamps"));
486 }
487
488 #[test]
489 fn comparison_metrics_no_git_repo_degrades_the_git_derived_denominator() {
490 let tmp = tempfile::TempDir::new().unwrap();
491 // A mission closed inside the window, but the tempdir is no git
492 // repository: the mission-derived anchor is recorded while the
493 // git-derived count reads absent with its dependency named.
494 write_timed_events(
495 tmp.path(),
496 "m-1",
497 completed_mission_events("m-1", NOW_MS - DAY_MS),
498 );
499
500 let report = compute_comparison_report(tmp.path(), 30, now()).unwrap();
501 let share = &report.assisted_change_share;
502 assert_eq!(share.agent_changes, 0, "no merged probe without git");
503 assert_eq!(share.base_branch.as_deref(), Some("main"));
504 assert_eq!(share.total_changes, None);
505 assert_eq!(share.share, None);
506 assert!(
507 share
508 .dependency
509 .as_deref()
510 .is_some_and(|d| d.contains("a git probe")),
511 "{share:?}"
512 );
513 }
514
515 #[test]
516 fn comparison_metrics_window_over_max_is_an_honest_error() {
517 let tmp = tempfile::TempDir::new().unwrap();
518 let result = compute_comparison_report(
519 tmp.path(),
520 crate::outcomes::MAX_MERGED_CHANGE_WINDOW_DAYS + 1,
521 now(),
522 );
523 assert!(result.is_err(), "an over-bound window errors, never wraps");
524 }
525}