Skip to main content

kranz_engine/
escalation_metrics.rs

1//! Flight-surgeon console (ticket `.kranz/tickets/flight-surgeon-dashboard.md`):
2//! escalation health metrics computed per-request from the data kranz already
3//! writes — per-mission `events.jsonl` plus the optional `traced-from-mission`
4//! ticket frontmatter field. Pure-fold style, mirroring [`crate::outcomes`] /
5//! [`crate::contract_health`]: no second persisted source of truth, only
6//! functions over event slices and the ticket list.
7//!
8//! The four metrics:
9//!
10//! 1. **Autonomy ratio** — closed missions (COMPLETED or FAILED honestly; an
11//!    ABANDONED mission was operator-retired, so it is neither and stays out
12//!    of the denominator) with zero operator interventions / all closed
13//!    missions, split by completion outcome. The intervention set:
14//!    - `grant.approved` / `grant.denied` — any grant decision means the run
15//!      parked at the consent boundary (a deny-default timeout is still a
16//!      park that needed the operator, so denials count too);
17//!    - `user.message` at/after `plan.approved` — control-command steers
18//!      (pre-approval messages are drafting, not steers; same rule as
19//!      [`crate::outcomes`]);
20//!    - `plan.revised` / `plan.revision.rejected` — the operator deciding a
21//!      plan revision;
22//!    - `milestone.unblocked` whose reason is an operator decision — i.e.
23//!      every unblock EXCEPT the workspace-gate lift
24//!      ([`crate::workspace_gate::GATE_LIFT_REASON`], which is engine-owned).
25//! 2. **Rubber-stamp signal** — the park→grant latency distribution (p50/p90,
26//!    nearest-rank; and the count under 10s) over decided grant requests.
27//! 3. **False greens** — missions that closed COMPLETED with ≥1 defect ticket
28//!    tracing back via `traced-from-mission` frontmatter; rate over all
29//!    completed missions, split by whether the mission had interventions.
30//! 4. **Escalation ledger** — every grant park (paired with its decision and
31//!    latency) and every steer, newest first: mission, milestone, what was
32//!    asked, what the operator decided.
33
34use crate::events::{Event, EventKind};
35use chrono::{DateTime, Utc};
36use serde::{Deserialize, Serialize};
37
38/// Latency under which a grant decision reads as rubber-stamped (the
39/// tight-boundary failure made visible): 10 seconds.
40const RUBBER_STAMP_THRESHOLD_MS: u64 = 10_000;
41
42/// A defect→mission link from a ticket's `traced-from-mission` frontmatter.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase")]
45pub struct TracedDefect {
46    /// The defect ticket's slug (file stem under `.kranz/tickets/`).
47    pub ticket: String,
48    /// The mission the defect was traced back to.
49    pub mission_id: String,
50}
51
52/// Autonomy ratio over all closed missions, plus the per-outcome split.
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct AutonomyMetric {
56    pub closed_missions: u64,
57    pub zero_intervention_missions: u64,
58    /// None when nothing closed (the share is meaningless, not zero).
59    pub zero_intervention_share: Option<f64>,
60    pub completed: AutonomyOutcomeSplit,
61    pub failed: AutonomyOutcomeSplit,
62}
63
64/// One outcome arm of the autonomy split.
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct AutonomyOutcomeSplit {
68    pub missions: u64,
69    pub zero_intervention: u64,
70    /// None when the arm has no missions.
71    pub zero_intervention_share: Option<f64>,
72}
73
74/// Park→grant latency distribution — the rubber-stamp signal.
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct RubberStamp {
78    /// Grant requests with a matching decision (the percentile population).
79    pub decided_grants: u64,
80    /// Nearest-rank percentiles in ms; None when nothing was decided.
81    pub p50_ms: Option<u64>,
82    pub p90_ms: Option<u64>,
83    /// Decisions under [`RUBBER_STAMP_THRESHOLD_MS`] — the wall of
84    /// sub-ten-second approvals, counted.
85    pub under_ten_seconds: u64,
86}
87
88/// One intervention arm of the false-green split.
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase")]
91pub struct FalseGreenSplit {
92    pub completed_missions: u64,
93    pub false_greens: u64,
94    /// None when the arm has no completed missions.
95    pub rate: Option<f64>,
96}
97
98/// Missions that closed green and later produced a traced defect.
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100#[serde(rename_all = "camelCase")]
101pub struct FalseGreens {
102    pub completed_missions: u64,
103    pub false_greens: u64,
104    /// None when nothing completed.
105    pub false_green_rate: Option<f64>,
106    /// The autonomy-quality test: does an intervention-free completion
107    /// produce fewer defects than an operator-steered one?
108    pub with_interventions: FalseGreenSplit,
109    pub zero_intervention: FalseGreenSplit,
110    /// Every defect→mission link that joined (auditable, never inferred —
111    /// only frontmatter-traced links appear).
112    pub traced_defects: Vec<TracedDefect>,
113}
114
115/// Ledger row kind: a grant park (paired with its decision) or a steer.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "lowercase")]
118pub enum LedgerKind {
119    Grant,
120    Steer,
121}
122
123impl LedgerKind {
124    /// The wire/serde form (`grant`/`steer`) for text surfaces.
125    pub fn as_str(&self) -> &'static str {
126        match self {
127            Self::Grant => "grant",
128            Self::Steer => "steer",
129        }
130    }
131}
132
133/// One escalation-ledger row: what was asked, what was decided, how long it
134/// took. Tabular across REST/CLI/dashboard.
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase")]
137pub struct LedgerRow {
138    pub ts: DateTime<Utc>,
139    pub mission_id: String,
140    pub kind: LedgerKind,
141    /// The milestone a grant parked for; None on steers.
142    pub milestone_id: Option<String>,
143    /// Grant: `<kind>: <command>`; steer: the operator's message text.
144    pub ask: String,
145    /// Grant: `approved` / `denied: <reason>` / `pending`; steer: `steered`.
146    pub decision: String,
147    /// Park→decision latency; None while pending (and on steers).
148    pub latency_ms: Option<u64>,
149}
150
151/// The full flight-surgeon aggregate over one host's missions.
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
153#[serde(rename_all = "camelCase")]
154pub struct EscalationMetrics {
155    pub autonomy: AutonomyMetric,
156    pub rubber_stamp: RubberStamp,
157    pub false_greens: FalseGreens,
158    pub ledger: Vec<LedgerRow>,
159}
160
161/// How a mission closed, for the autonomy denominator.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163enum TerminalOutcome {
164    Completed,
165    Failed,
166    /// Operator-retired (`kranz abandon`): terminal but neither a completion
167    /// nor an honest failure, so it never enters the autonomy ratio.
168    Abandoned,
169}
170
171/// One mission's fold: terminal outcome, intervention count, grant-decision
172/// latencies, and its ledger rows.
173#[derive(Debug, Clone, PartialEq)]
174pub struct MissionEscalation {
175    terminal: Option<TerminalOutcome>,
176    interventions: u64,
177    latencies_ms: Vec<u64>,
178    ledger: Vec<LedgerRow>,
179}
180
181/// Kebab-case wire name of a [`crate::types::GrantKind`] (mirrors its serde
182/// rename) for the ledger's `ask` text. `pub(crate)` so the provenance
183/// replay's grant-decision summaries spell the kind identically.
184pub(crate) fn grant_kind_str(kind: &crate::types::GrantKind) -> &'static str {
185    match kind {
186        crate::types::GrantKind::Command => "command",
187        crate::types::GrantKind::TouchPath => "touch-path",
188        crate::types::GrantKind::WorkerDeny => "worker-deny",
189        crate::types::GrantKind::Egress => "egress",
190    }
191}
192
193/// True for an engine-owned workspace-gate lift. Typed context is authoritative;
194/// only legacy events without context use the historical exact reason. `pub(crate)` so the provenance
195/// replay excludes the same lift from its human-decision chain — one
196/// classification rule, no drift between the two folds.
197pub(crate) fn is_engine_lift(reason: &str, context: Option<&crate::types::BlockContext>) -> bool {
198    match context {
199        Some(context) => context.is_workspace_gate(),
200        None => reason == crate::workspace_gate::GATE_LIFT_REASON,
201    }
202}
203
204/// Pair each `grant.requested` (in seq order) with the `grant.approved` /
205/// `grant.denied` that answered it, returning `(request_index,
206/// Option<decision_index>)` pairs indexing `mission_events`: the earliest
207/// LATER decision for the same command, falling back to the next unconsumed
208/// decision in seq order (partial/hand-edited logs), each decision consumed
209/// at most once. `None` means the park was never answered (pending).
210///
211/// Extracted for the training-corpus export ([`crate::corpus_export`]) — the
212/// THIRD user of this rule (outcomes.rs carries the first, the ledger fold
213/// below the second) — so the ledger and the corpus pair identical logs
214/// identically and can never drift. `mission_events` must be in ascending
215/// `seq` order or "earliest later" is meaningless.
216pub(crate) fn pair_grant_decisions(mission_events: &[&Event]) -> Vec<(usize, Option<usize>)> {
217    let mut used_decisions = vec![false; mission_events.len()];
218    let mut pairs = Vec::new();
219    for (req_idx, req) in mission_events.iter().enumerate() {
220        let EventKind::GrantRequested { command, .. } = &req.kind else {
221            continue;
222        };
223
224        let mut matched: Option<usize> = None;
225        for (i, cand) in mission_events.iter().enumerate() {
226            if i <= req_idx || used_decisions[i] {
227                continue;
228            }
229            let cand_command = match &cand.kind {
230                EventKind::GrantApproved { command, .. }
231                | EventKind::GrantDenied { command, .. } => command,
232                _ => continue,
233            };
234            if cand_command == command {
235                matched = Some(i);
236                break;
237            }
238        }
239        if matched.is_none() {
240            // Fallback for partial/hand-edited logs (mirrors outcomes.rs):
241            // take the next unconsumed decision in seq order even when it
242            // answers a different command.
243            for (i, cand) in mission_events.iter().enumerate() {
244                if i <= req_idx || used_decisions[i] {
245                    continue;
246                }
247                if matches!(
248                    cand.kind,
249                    EventKind::GrantApproved { .. } | EventKind::GrantDenied { .. }
250                ) {
251                    matched = Some(i);
252                    break;
253                }
254            }
255        }
256        if let Some(i) = matched {
257            used_decisions[i] = true;
258        }
259        pairs.push((req_idx, matched));
260    }
261    pairs
262}
263
264/// Fold one mission's escalation data from its event slice. `events` may
265/// contain events for other missions too (they are filtered out) but must be
266/// in ascending `seq` order for the "earliest later" grant matching to be
267/// correct. Same discipline as [`crate::outcomes::mission_outcomes`].
268pub fn mission_escalation(mission_id: &str, events: &[Event]) -> MissionEscalation {
269    let mission_events: Vec<&Event> = events
270        .iter()
271        .filter(|e| e.mission_id == mission_id)
272        .collect();
273
274    let terminal = mission_events.iter().find_map(|e| match &e.kind {
275        EventKind::MissionCompleted {} => Some(TerminalOutcome::Completed),
276        EventKind::MissionFailed { .. } => Some(TerminalOutcome::Failed),
277        EventKind::MissionAbandoned { .. } => Some(TerminalOutcome::Abandoned),
278        _ => None,
279    });
280
281    let plan_approved_seq = mission_events
282        .iter()
283        .find(|e| matches!(e.kind, EventKind::PlanApproved { .. }))
284        .map(|e| e.seq);
285
286    let mut interventions: u64 = 0;
287    let mut ledger = Vec::new();
288
289    // Steers and decision interventions (the autonomy numerator set).
290    for e in &mission_events {
291        match &e.kind {
292            EventKind::UserMessage { text, .. } => {
293                // Classify by SEQUENCE, not wall clock (5th-pass review):
294                // the event log's seq is the order of truth — timestamps
295                // can tie or move backward across a clock step, and either
296                // would silently misclassify a steer as drafting.
297                if let Some(approved_seq) = plan_approved_seq {
298                    if e.seq >= approved_seq {
299                        interventions += 1;
300                        ledger.push(LedgerRow {
301                            ts: e.ts,
302                            mission_id: mission_id.to_string(),
303                            kind: LedgerKind::Steer,
304                            milestone_id: None,
305                            ask: text.clone(),
306                            decision: "steered".to_string(),
307                            latency_ms: None,
308                        });
309                    }
310                }
311            }
312            EventKind::GrantApproved { .. }
313            | EventKind::GrantDenied { .. }
314            | EventKind::PlanRevised { .. }
315            | EventKind::PlanRevisionRejected { .. } => {
316                interventions += 1;
317            }
318            EventKind::MilestoneUnblocked {
319                reason,
320                block_context,
321                ..
322            } if !is_engine_lift(reason, block_context.as_ref()) => {
323                interventions += 1;
324            }
325            _ => {}
326        }
327    }
328
329    // Grant parks: pair each request with its decision via the shared rule
330    // ([`pair_grant_decisions`]) so this fold, outcomes.rs, and the
331    // training-corpus export pair identical logs identically.
332    let mut latencies_ms = Vec::new();
333    for (req_idx, matched) in pair_grant_decisions(&mission_events) {
334        let req = mission_events[req_idx];
335        let EventKind::GrantRequested {
336            milestone_id,
337            kind,
338            command,
339        } = &req.kind
340        else {
341            unreachable!("pair_grant_decisions only returns grant.requested indices")
342        };
343
344        let (decision, latency_ms) = match matched {
345            Some(i) => {
346                let decided = mission_events[i];
347                let latency = (decided.ts - req.ts).num_milliseconds();
348                let latency_ms = if latency >= 0 {
349                    Some(latency as u64)
350                } else {
351                    None
352                };
353                if let Some(l) = latency_ms {
354                    latencies_ms.push(l);
355                }
356                let decision = match &decided.kind {
357                    EventKind::GrantApproved { .. } => "approved".to_string(),
358                    EventKind::GrantDenied { reason, .. } => format!("denied: {reason}"),
359                    _ => unreachable!(),
360                };
361                (decision, latency_ms)
362            }
363            None => ("pending".to_string(), None),
364        };
365
366        ledger.push(LedgerRow {
367            ts: req.ts,
368            mission_id: mission_id.to_string(),
369            kind: LedgerKind::Grant,
370            milestone_id: Some(milestone_id.clone()),
371            ask: format!("{}: {command}", grant_kind_str(kind)),
372            decision,
373            latency_ms,
374        });
375    }
376
377    MissionEscalation {
378        terminal,
379        interventions,
380        latencies_ms,
381        ledger,
382    }
383}
384
385/// Nearest-rank percentile over a sorted ascending slice: the value at rank
386/// `ceil(p/100 * n)` (1-indexed, clamped to `n`). None for an empty
387/// population. Deterministic and exact for the small n these folds see.
388fn percentile_nearest_rank(sorted: &[u64], p: u64) -> Option<u64> {
389    if sorted.is_empty() {
390        return None;
391    }
392    let n = sorted.len() as u64;
393    let rank = p.saturating_mul(n).saturating_add(99) / 100;
394    let rank = rank.clamp(1, n);
395    Some(sorted[(rank - 1) as usize])
396}
397
398/// Aggregate per-mission folds (plus the traced-defect links) into the four
399/// flight-surgeon metrics. `missions` is `(mission_id, events)` pairs in any
400/// order; the ledger comes out newest-first. Pure: no I/O, no clock.
401pub fn aggregate(
402    missions: &[(String, Vec<Event>)],
403    traced_defects: &[TracedDefect],
404) -> EscalationMetrics {
405    let mut closed_missions: u64 = 0;
406    let mut zero_intervention_missions: u64 = 0;
407    let mut completed_missions: u64 = 0;
408    let mut completed_zero_intervention: u64 = 0;
409    let mut failed_missions: u64 = 0;
410    let mut failed_zero_intervention: u64 = 0;
411    let mut all_latencies_ms = Vec::new();
412    let mut ledger = Vec::new();
413    // Completed missions by intervention arm, for the false-green join.
414    let mut completed_with_interventions: Vec<&str> = Vec::new();
415    let mut completed_clean: Vec<&str> = Vec::new();
416
417    for (mission_id, events) in missions {
418        let fold = mission_escalation(mission_id, events);
419        match fold.terminal {
420            Some(TerminalOutcome::Completed) => {
421                closed_missions += 1;
422                completed_missions += 1;
423                if fold.interventions == 0 {
424                    zero_intervention_missions += 1;
425                    completed_zero_intervention += 1;
426                    completed_clean.push(mission_id);
427                } else {
428                    completed_with_interventions.push(mission_id);
429                }
430            }
431            Some(TerminalOutcome::Failed) => {
432                closed_missions += 1;
433                failed_missions += 1;
434                if fold.interventions == 0 {
435                    zero_intervention_missions += 1;
436                    failed_zero_intervention += 1;
437                }
438            }
439            Some(TerminalOutcome::Abandoned) | None => {}
440        }
441        all_latencies_ms.extend(fold.latencies_ms);
442        ledger.extend(fold.ledger);
443    }
444
445    // False greens: a traced defect joins only against a mission that closed
446    // COMPLETED (a defect on a failed mission is not a false green; a trace
447    // to an unknown mission joins nothing). Each completed mission with ≥1
448    // traced defect counts once, however many defects trace to it.
449    let mut joined: Vec<TracedDefect> = Vec::new();
450    let mut false_green_with_interventions: u64 = 0;
451    let mut false_green_clean: u64 = 0;
452    for defect in traced_defects {
453        let id = defect.mission_id.as_str();
454        let is_completed =
455            completed_with_interventions.contains(&id) || completed_clean.contains(&id);
456        if is_completed {
457            joined.push(defect.clone());
458        }
459    }
460    let counted: std::collections::HashSet<&str> =
461        joined.iter().map(|d| d.mission_id.as_str()).collect();
462    for id in &completed_with_interventions {
463        if counted.contains(id) {
464            false_green_with_interventions += 1;
465        }
466    }
467    for id in &completed_clean {
468        if counted.contains(id) {
469            false_green_clean += 1;
470        }
471    }
472    joined.sort_by(|a, b| {
473        a.mission_id
474            .cmp(&b.mission_id)
475            .then_with(|| a.ticket.cmp(&b.ticket))
476    });
477    let false_greens_total = false_green_with_interventions + false_green_clean;
478
479    all_latencies_ms.sort_unstable();
480    let decided = all_latencies_ms.len() as u64;
481    let under_ten_seconds = all_latencies_ms
482        .iter()
483        .filter(|&&l| l < RUBBER_STAMP_THRESHOLD_MS)
484        .count() as u64;
485
486    ledger.sort_by_key(|row| std::cmp::Reverse(row.ts));
487
488    EscalationMetrics {
489        autonomy: AutonomyMetric {
490            closed_missions,
491            zero_intervention_missions,
492            zero_intervention_share: (closed_missions > 0)
493                .then(|| zero_intervention_missions as f64 / closed_missions as f64),
494            completed: AutonomyOutcomeSplit {
495                missions: completed_missions,
496                zero_intervention: completed_zero_intervention,
497                zero_intervention_share: (completed_missions > 0)
498                    .then(|| completed_zero_intervention as f64 / completed_missions as f64),
499            },
500            failed: AutonomyOutcomeSplit {
501                missions: failed_missions,
502                zero_intervention: failed_zero_intervention,
503                zero_intervention_share: (failed_missions > 0)
504                    .then(|| failed_zero_intervention as f64 / failed_missions as f64),
505            },
506        },
507        rubber_stamp: RubberStamp {
508            decided_grants: decided,
509            p50_ms: percentile_nearest_rank(&all_latencies_ms, 50),
510            p90_ms: percentile_nearest_rank(&all_latencies_ms, 90),
511            under_ten_seconds,
512        },
513        false_greens: FalseGreens {
514            completed_missions,
515            false_greens: false_greens_total,
516            false_green_rate: (completed_missions > 0)
517                .then(|| false_greens_total as f64 / completed_missions as f64),
518            with_interventions: FalseGreenSplit {
519                completed_missions: completed_with_interventions.len() as u64,
520                false_greens: false_green_with_interventions,
521                rate: (!completed_with_interventions.is_empty()).then(|| {
522                    false_green_with_interventions as f64
523                        / completed_with_interventions.len() as f64
524                }),
525            },
526            zero_intervention: FalseGreenSplit {
527                completed_missions: completed_clean.len() as u64,
528                false_greens: false_green_clean,
529                rate: (!completed_clean.is_empty())
530                    .then(|| false_green_clean as f64 / completed_clean.len() as f64),
531            },
532            traced_defects: joined,
533        },
534        ledger,
535    }
536}
537
538/// Read every ticket's `traced-from-mission` frontmatter into defect→mission
539/// links. Absent field = not a traced defect (no false positives); tickets
540/// that fail to parse are already skipped by [`crate::ticket::Ticket::list`].
541/// `pub(crate)` so the industry-comparison fold
542/// ([`crate::comparison_metrics`]) joins the SAME recorded links for its
543/// defect density — one linkage source, no drift between the two folds.
544pub(crate) fn traced_defects_from_tickets(repo_root: &std::path::Path) -> Vec<TracedDefect> {
545    let mut out: Vec<TracedDefect> = crate::ticket::Ticket::list(repo_root)
546        .into_iter()
547        .filter_map(|ticket| {
548            ticket.traced_from_mission.map(|mission_id| TracedDefect {
549                ticket: ticket.slug,
550                mission_id,
551            })
552        })
553        .collect();
554    out.sort_by(|a, b| {
555        a.mission_id
556            .cmp(&b.mission_id)
557            .then_with(|| a.ticket.cmp(&b.ticket))
558    });
559    out
560}
561
562/// Enumerate every mission under `repo_root` exactly as
563/// [`crate::outcomes::compute_outcomes`] does (union of
564/// [`crate::paths::MissionPaths::list_missions`] and the ids in
565/// `.kranz/missions/index.md`), read each log, join the tickets'
566/// `traced-from-mission` links, and aggregate. A mission with no
567/// `events.jsonl` or an unreadable/corrupt log is skipped (degrade per-row);
568/// this never panics or fails the whole aggregate.
569pub fn compute_escalation_metrics(
570    repo_root: &std::path::Path,
571) -> anyhow::Result<EscalationMetrics> {
572    let index_contents = std::fs::read_to_string(
573        crate::paths::MissionPaths::new(repo_root, "_")
574            .missions_dir()
575            .join("index.md"),
576    )
577    .unwrap_or_default();
578
579    let mut ids = crate::paths::MissionPaths::list_missions(repo_root);
580    for id in crate::mission_catalog::mission_index_ids(&index_contents) {
581        if !ids.contains(&id) {
582            ids.push(id);
583        }
584    }
585    ids.sort();
586
587    let mut missions = Vec::new();
588    for id in ids {
589        let paths = crate::paths::MissionPaths::new(repo_root, &id);
590        let events_path = paths.events_file();
591        if !events_path.is_file() {
592            continue;
593        }
594        // Never fold a mission reached through a symlinked path component
595        // (P1 mission-path-no-follow).
596        if paths.require_no_follow().is_err() {
597            continue;
598        }
599        let events = match crate::event_log::EventLog::read_events(&events_path) {
600            Ok(events) => events,
601            Err(_) => continue, // corrupt log degrades per-mission, never fails
602        };
603        missions.push((id, events));
604    }
605
606    Ok(aggregate(
607        &missions,
608        &traced_defects_from_tickets(repo_root),
609    ))
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use crate::event_log::{EventLog, LockForce};
616    use crate::paths::MissionPaths;
617    use crate::types::{GrantKind, MissionConfig, Plan};
618    use std::time::Duration;
619    use tempfile::TempDir;
620
621    fn ev(seq: u64, mission_id: &str, ts_ms: i64, kind: EventKind) -> Event {
622        Event {
623            seq,
624            ts: DateTime::from_timestamp_millis(ts_ms).unwrap(),
625            mission_id: mission_id.to_string(),
626            kind,
627        }
628    }
629
630    fn sample_plan() -> Plan {
631        Plan {
632            goal: "g".into(),
633            validation_contract: vec![],
634            milestones: vec![],
635            considered_alternatives: None,
636            command_grants: vec![],
637            touch_set: vec![],
638            standards_manifest: None,
639            reviewer_independence: None,
640        }
641    }
642
643    fn created() -> EventKind {
644        EventKind::MissionCreated {
645            goal: "g".into(),
646            base_branch: "main".into(),
647            mission_branch: "kranz/mission-x".into(),
648            config: MissionConfig::default(),
649        }
650    }
651
652    /// The anti-vacuity fixture (ticket: flight-surgeon-dashboard): three
653    /// closed missions —
654    /// - m-1: COMPLETED, zero interventions;
655    /// - m-2: COMPLETED, with grant decisions (parks at 5s and 900s);
656    /// - m-3: FAILED honestly, with a control steer.
657    ///
658    /// Every metric must land exactly: ratio 1/3, completed split 1/2,
659    /// failed split 0/1, p50 5s, p90 900s, under-10s 1.
660    fn anti_vacuity_missions() -> Vec<(String, Vec<Event>)> {
661        let m1 = vec![
662            ev(1, "m-1", 0, created()),
663            ev(2, "m-1", 1_000, EventKind::MissionCompleted {}),
664        ];
665        let m2 = vec![
666            ev(1, "m-2", 0, created()),
667            ev(
668                2,
669                "m-2",
670                1_000,
671                EventKind::PlanApproved {
672                    plan: sample_plan(),
673                    base_sha: None,
674                },
675            ),
676            ev(
677                3,
678                "m-2",
679                10_000,
680                EventKind::GrantRequested {
681                    milestone_id: "ms-1".into(),
682                    kind: GrantKind::Command,
683                    command: "cargo test".into(),
684                },
685            ),
686            ev(
687                4,
688                "m-2",
689                15_000,
690                EventKind::GrantApproved {
691                    kind: GrantKind::Command,
692                    command: "cargo test".into(),
693                },
694            ),
695            ev(
696                5,
697                "m-2",
698                20_000,
699                EventKind::GrantRequested {
700                    milestone_id: "ms-1".into(),
701                    kind: GrantKind::Egress,
702                    command: "registry.npmjs.org:443".into(),
703                },
704            ),
705            ev(
706                6,
707                "m-2",
708                920_000,
709                EventKind::GrantDenied {
710                    kind: GrantKind::Egress,
711                    command: "registry.npmjs.org:443".into(),
712                    reason: "not needed".into(),
713                },
714            ),
715            ev(7, "m-2", 921_000, EventKind::MissionCompleted {}),
716        ];
717        let m3 = vec![
718            ev(1, "m-3", 0, created()),
719            ev(
720                2,
721                "m-3",
722                1_000,
723                EventKind::PlanApproved {
724                    plan: sample_plan(),
725                    base_sha: None,
726                },
727            ),
728            ev(
729                3,
730                "m-3",
731                2_000,
732                EventKind::UserMessage {
733                    text: "skip the flaky test".into(),
734                    interrupt: false,
735                },
736            ),
737            ev(
738                4,
739                "m-3",
740                3_000,
741                EventKind::MissionFailed {
742                    reason: "honest failure".into(),
743                },
744            ),
745        ];
746        vec![
747            ("m-1".to_string(), m1),
748            ("m-2".to_string(), m2),
749            ("m-3".to_string(), m3),
750        ]
751    }
752
753    #[test]
754    fn escalation_metrics_autonomy_ratio_split_by_outcome() {
755        let metrics = aggregate(&anti_vacuity_missions(), &[]);
756        let autonomy = &metrics.autonomy;
757        assert_eq!(autonomy.closed_missions, 3);
758        assert_eq!(autonomy.zero_intervention_missions, 1);
759        assert_eq!(autonomy.zero_intervention_share, Some(1.0 / 3.0));
760        assert_eq!(autonomy.completed.missions, 2);
761        assert_eq!(autonomy.completed.zero_intervention, 1);
762        assert_eq!(autonomy.completed.zero_intervention_share, Some(0.5));
763        assert_eq!(autonomy.failed.missions, 1);
764        assert_eq!(autonomy.failed.zero_intervention, 0);
765        assert_eq!(autonomy.failed.zero_intervention_share, Some(0.0));
766    }
767
768    #[test]
769    fn escalation_metrics_rubber_stamp_percentiles_and_under_10s() {
770        let metrics = aggregate(&anti_vacuity_missions(), &[]);
771        let stamp = &metrics.rubber_stamp;
772        assert_eq!(stamp.decided_grants, 2);
773        // [5_000, 900_000] nearest-rank: p50 → rank 1 → 5s; p90 → rank 2 → 900s.
774        assert_eq!(stamp.p50_ms, Some(5_000));
775        assert_eq!(stamp.p90_ms, Some(900_000));
776        assert_eq!(stamp.under_ten_seconds, 1);
777    }
778
779    #[test]
780    fn escalation_metrics_percentiles_empty_population_is_none() {
781        let metrics = aggregate(&[], &[]);
782        assert_eq!(metrics.rubber_stamp.decided_grants, 0);
783        assert_eq!(metrics.rubber_stamp.p50_ms, None);
784        assert_eq!(metrics.rubber_stamp.p90_ms, None);
785        assert_eq!(metrics.autonomy.zero_intervention_share, None);
786        assert_eq!(metrics.false_greens.false_green_rate, None);
787    }
788
789    #[test]
790    fn escalation_metrics_false_greens_join_and_split() {
791        let traced = vec![
792            // Joins: m-1 closed COMPLETED with zero interventions.
793            TracedDefect {
794                ticket: "defect-login-regression".into(),
795                mission_id: "m-1".into(),
796            },
797            // Does NOT join: m-3 closed FAILED (not a green).
798            TracedDefect {
799                ticket: "defect-on-a-failure".into(),
800                mission_id: "m-3".into(),
801            },
802            // Does NOT join: no such mission.
803            TracedDefect {
804                ticket: "defect-unknown-mission".into(),
805                mission_id: "m-999".into(),
806            },
807        ];
808        let metrics = aggregate(&anti_vacuity_missions(), &traced);
809        let fg = &metrics.false_greens;
810        assert_eq!(fg.completed_missions, 2);
811        assert_eq!(fg.false_greens, 1);
812        assert_eq!(fg.false_green_rate, Some(0.5));
813        assert_eq!(fg.with_interventions.completed_missions, 1);
814        assert_eq!(fg.with_interventions.false_greens, 0);
815        assert_eq!(fg.with_interventions.rate, Some(0.0));
816        assert_eq!(fg.zero_intervention.completed_missions, 1);
817        assert_eq!(fg.zero_intervention.false_greens, 1);
818        assert_eq!(fg.zero_intervention.rate, Some(1.0));
819        assert_eq!(
820            fg.traced_defects,
821            vec![TracedDefect {
822                ticket: "defect-login-regression".into(),
823                mission_id: "m-1".into(),
824            }]
825        );
826    }
827
828    #[test]
829    fn escalation_metrics_two_defects_one_mission_count_once() {
830        let traced = vec![
831            TracedDefect {
832                ticket: "defect-a".into(),
833                mission_id: "m-1".into(),
834            },
835            TracedDefect {
836                ticket: "defect-b".into(),
837                mission_id: "m-1".into(),
838            },
839        ];
840        let metrics = aggregate(&anti_vacuity_missions(), &traced);
841        assert_eq!(metrics.false_greens.false_greens, 1);
842        assert_eq!(metrics.false_greens.traced_defects.len(), 2);
843    }
844
845    #[test]
846    fn escalation_metrics_ledger_rows_grants_and_steers() {
847        let metrics = aggregate(&anti_vacuity_missions(), &[]);
848        // Newest first: m-2's denied egress park (20s) leads, then the
849        // approved command park (10s), then m-3's steer (2s).
850        assert_eq!(metrics.ledger.len(), 3);
851        let egress = &metrics.ledger[0];
852        assert_eq!(egress.kind, LedgerKind::Grant);
853        assert_eq!(egress.mission_id, "m-2");
854        assert_eq!(egress.milestone_id.as_deref(), Some("ms-1"));
855        assert_eq!(egress.ask, "egress: registry.npmjs.org:443");
856        assert_eq!(egress.decision, "denied: not needed");
857        assert_eq!(egress.latency_ms, Some(900_000));
858        let command = &metrics.ledger[1];
859        assert_eq!(command.kind, LedgerKind::Grant);
860        assert_eq!(command.ask, "command: cargo test");
861        assert_eq!(command.decision, "approved");
862        assert_eq!(command.latency_ms, Some(5_000));
863        let steer = &metrics.ledger[2];
864        assert_eq!(steer.kind, LedgerKind::Steer);
865        assert_eq!(steer.mission_id, "m-3");
866        assert_eq!(steer.milestone_id, None);
867        assert_eq!(steer.ask, "skip the flaky test");
868        assert_eq!(steer.decision, "steered");
869        assert_eq!(steer.latency_ms, None);
870    }
871
872    #[test]
873    fn escalation_metrics_pending_grant_rows_and_pre_approval_messages() {
874        // A pre-approval message is drafting, not a steer; an unanswered
875        // park is a pending ledger row with no latency and no intervention.
876        let events = vec![
877            ev(
878                1,
879                "m-1",
880                0,
881                EventKind::UserMessage {
882                    text: "draft note".into(),
883                    interrupt: false,
884                },
885            ),
886            ev(
887                2,
888                "m-1",
889                1_000,
890                EventKind::PlanApproved {
891                    plan: sample_plan(),
892                    base_sha: None,
893                },
894            ),
895            ev(
896                3,
897                "m-1",
898                2_000,
899                EventKind::GrantRequested {
900                    milestone_id: "ms-1".into(),
901                    kind: GrantKind::TouchPath,
902                    command: "docs/**".into(),
903                },
904            ),
905        ];
906        let fold = mission_escalation("m-1", &events);
907        assert_eq!(fold.interventions, 0);
908        assert!(fold.latencies_ms.is_empty());
909        assert_eq!(fold.ledger.len(), 1);
910        assert_eq!(fold.ledger[0].decision, "pending");
911        assert_eq!(fold.ledger[0].latency_ms, None);
912        assert_eq!(fold.ledger[0].ask, "touch-path: docs/**");
913    }
914
915    #[test]
916    fn escalation_metrics_operator_unblocks_count_engine_lifts_do_not() {
917        let events = vec![
918            ev(
919                1,
920                "m-1",
921                0,
922                EventKind::MilestoneBlocked {
923                    block_context: None,
924                    milestone_id: "ms-1".into(),
925                    reason: "workspace gate: bootstrap failed".into(),
926                },
927            ),
928            // Engine-owned lift (workspace gate passing) — NOT an operator
929            // decision, so it must not break the mission's clean record.
930            ev(
931                2,
932                "m-1",
933                1_000,
934                EventKind::MilestoneUnblocked {
935                    block_context: None,
936                    milestone_id: "ms-1".into(),
937                    reason: crate::workspace_gate::GATE_LIFT_REASON.to_string(),
938                    validator_guidance: None,
939                },
940            ),
941            ev(
942                3,
943                "m-1",
944                2_000,
945                EventKind::MilestoneBlocked {
946                    block_context: None,
947                    milestone_id: "ms-1".into(),
948                    reason: "fix-cycle cap".into(),
949                },
950            ),
951            // Operator decision — counts.
952            ev(
953                4,
954                "m-1",
955                3_000,
956                EventKind::MilestoneUnblocked {
957                    block_context: None,
958                    milestone_id: "ms-1".into(),
959                    reason: "user skipped findings".into(),
960                    validator_guidance: None,
961                },
962            ),
963        ];
964        let fold = mission_escalation("m-1", &events);
965        assert_eq!(fold.interventions, 1);
966    }
967
968    #[test]
969    fn escalation_metrics_revision_decisions_count_as_interventions() {
970        let events = vec![
971            ev(
972                1,
973                "m-1",
974                0,
975                EventKind::PlanRevised {
976                    revision: 1,
977                    plan: sample_plan(),
978                },
979            ),
980            ev(
981                2,
982                "m-1",
983                1_000,
984                EventKind::PlanRevisionRejected {
985                    revision: 2,
986                    reason: "too risky".into(),
987                },
988            ),
989            ev(3, "m-1", 2_000, EventKind::MissionCompleted {}),
990        ];
991        let fold = mission_escalation("m-1", &events);
992        assert_eq!(fold.interventions, 2);
993        let metrics = aggregate(&[("m-1".to_string(), events)], &[]);
994        assert_eq!(metrics.autonomy.zero_intervention_missions, 0);
995        assert_eq!(metrics.autonomy.completed.zero_intervention, 0);
996    }
997
998    #[test]
999    fn escalation_metrics_abandoned_is_not_a_closed_mission() {
1000        let events = vec![
1001            ev(1, "m-1", 0, created()),
1002            ev(
1003                2,
1004                "m-1",
1005                1_000,
1006                EventKind::MissionAbandoned {
1007                    reason: "operator retired it".into(),
1008                },
1009            ),
1010        ];
1011        let metrics = aggregate(&[("m-1".to_string(), events)], &[]);
1012        assert_eq!(metrics.autonomy.closed_missions, 0);
1013        assert_eq!(metrics.autonomy.zero_intervention_share, None);
1014        assert_eq!(metrics.false_greens.completed_missions, 0);
1015    }
1016
1017    #[test]
1018    fn escalation_metrics_other_missions_events_are_filtered_out() {
1019        let events = vec![
1020            ev(1, "m-1", 0, created()),
1021            ev(
1022                2,
1023                "m-2",
1024                500,
1025                EventKind::GrantApproved {
1026                    kind: GrantKind::Command,
1027                    command: "other".into(),
1028                },
1029            ),
1030            ev(3, "m-1", 1_000, EventKind::MissionCompleted {}),
1031        ];
1032        let fold = mission_escalation("m-1", &events);
1033        assert_eq!(fold.interventions, 0);
1034        assert!(fold.terminal == Some(TerminalOutcome::Completed));
1035    }
1036
1037    // -- compute_escalation_metrics over a fixture repo ----------------------
1038
1039    /// Seed a mission's `events.jsonl` with the given kinds, in order.
1040    fn seed_mission(repo_root: &std::path::Path, id: &str, kinds: Vec<EventKind>) {
1041        let paths = MissionPaths::new(repo_root, id);
1042        let mut log = EventLog::acquire(&paths, id, Duration::ZERO, LockForce::No).unwrap();
1043        for kind in kinds {
1044            log.append(kind).unwrap();
1045        }
1046    }
1047
1048    fn seed_ticket(repo_root: &std::path::Path, slug: &str, frontmatter: &str) {
1049        let dir = crate::ticket::Ticket::tickets_dir(repo_root);
1050        std::fs::create_dir_all(&dir).unwrap();
1051        std::fs::write(
1052            dir.join(format!("{slug}.md")),
1053            format!("{frontmatter}\n## Goal\nfix it\n"),
1054        )
1055        .unwrap();
1056    }
1057
1058    #[test]
1059    fn escalation_metrics_compute_end_to_end_over_fixture_repo() {
1060        let tmp = TempDir::new().unwrap();
1061        seed_mission(
1062            tmp.path(),
1063            "m-1",
1064            vec![created(), EventKind::MissionCompleted {}],
1065        );
1066        seed_mission(
1067            tmp.path(),
1068            "m-2",
1069            vec![
1070                created(),
1071                EventKind::GrantRequested {
1072                    milestone_id: "ms-1".into(),
1073                    kind: GrantKind::Command,
1074                    command: "cargo test".into(),
1075                },
1076                EventKind::GrantApproved {
1077                    kind: GrantKind::Command,
1078                    command: "cargo test".into(),
1079                },
1080                EventKind::MissionCompleted {},
1081            ],
1082        );
1083        // Hand-edited traced-from-mission frontmatter joins m-1; a ticket
1084        // without the field joins nothing.
1085        seed_ticket(
1086            tmp.path(),
1087            "defect-regression",
1088            "---\ntitle: Login regression\ntraced-from-mission: m-1\n---\n",
1089        );
1090        seed_ticket(
1091            tmp.path(),
1092            "ordinary-task",
1093            "---\ntitle: Ordinary task\n---\n",
1094        );
1095
1096        let metrics = compute_escalation_metrics(tmp.path()).unwrap();
1097        assert_eq!(metrics.autonomy.closed_missions, 2);
1098        assert_eq!(metrics.autonomy.zero_intervention_missions, 1);
1099        assert_eq!(metrics.false_greens.false_greens, 1);
1100        assert_eq!(metrics.false_greens.false_green_rate, Some(0.5));
1101        assert_eq!(
1102            metrics.false_greens.traced_defects,
1103            vec![TracedDefect {
1104                ticket: "defect-regression".into(),
1105                mission_id: "m-1".into(),
1106            }]
1107        );
1108        // m-2's grant decision landed within the same ms (appended back to
1109        // back) — a decided sub-10s grant.
1110        assert_eq!(metrics.rubber_stamp.decided_grants, 1);
1111        assert_eq!(metrics.rubber_stamp.under_ten_seconds, 1);
1112        assert_eq!(metrics.ledger.len(), 1);
1113        assert_eq!(metrics.ledger[0].decision, "approved");
1114    }
1115}