Skip to main content

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}