Skip to main content

kranz_engine/
gate_scores.rs

1//! Gate score series (ticket `.kranz/tickets/gate-confidence-score.md`,
2//! KRZ-315 — the persistence-and-query half of scored gates): the recorded
3//! evaluation series for ONE gate identity, folded from every mission log
4//! in the repo and surfaced as `kranz gate-scores <gate>` (text or
5//! `--json`). The `gate-score-distribution-flags` slice
6//! ([`crate::gate_score_flags`], KRZ-316) folds the distribution flags over
7//! the same `gate.result` events, sharing this module's extraction
8//! discipline and verbatim-score contract.
9//!
10//! The ticket's contract rules, and where this module stands on them:
11//!
12//! - **kranz RECORDS, never normalizes.** Score and threshold travel from
13//!   the `gate.result` event into the series verbatim — nothing here scales,
14//!   buckets, clamps, or reinterprets them; the scale is the gate's to
15//!   define ([`crate::gate::GateScore`]). What the gate reported is exactly
16//!   what a reader gets back.
17//! - **The verdict stays authoritative.** Every point carries `verdict`
18//!   verbatim beside the score pair; nothing derives one from the other. A
19//!   gate may pass with a low score or fail with a high one — both land in
20//!   the series exactly as stated (gate.rs enforces this structurally:
21//!   there is no constructor that computes a verdict from a score).
22//! - **Absence is the normal case.** Boolean-only gates emit no score, and
23//!   the series carries `None` for them — never a zero. A `0.0` would
24//!   invent a worst-possible reading the gate never stated; a gate that
25//!   says nothing about confidence says NOTHING, and the query surface
26//!   renders that honestly (no score column, not a column of zeros).
27//! - **Clean-room boundary.** The series vocabulary is the substrate's own
28//!   (gate, verdict, score, threshold) — no consumer-specific naming; that
29//!   lives in packs and downstream slices (positioning ADR).
30//!
31//! Pure-fold idiom, mirroring [`crate::escalation_metrics`] and
32//! [`crate::provenance`]: [`gate_score_series`] is a pure function over
33//! `(mission id, events)` pairs, [`compute_gate_score_series`] the thin
34//! read wrapper that enumerates the repo's mission logs. No persisted
35//! state, no reads outside the logs, no clock: the series is ordered by
36//! event timestamp with (mission id, seq) tie-breaks — a total order folded
37//! from the logs alone, so identical logs always yield an identical series.
38
39use crate::events::{Event, EventKind};
40use crate::gate::{GateSurface, GateVerdict};
41use chrono::{DateTime, Utc};
42use serde::{Deserialize, Serialize};
43
44/// One gate evaluation in the series: the replayable identity of one
45/// `gate.result` event carrying the queried gate identity — which mission,
46/// which event seq, which surface, the stated verdict, and the
47/// gate-supplied score pair verbatim (absent for boolean-only gates).
48/// `ts` rides along so the series' chronological order is inspectable and
49/// the text surface can say WHEN, like the escalation ledger does.
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub struct GateScorePoint {
53    pub mission_id: String,
54    pub seq: u64,
55    pub ts: DateTime<Utc>,
56    /// Which evaluation surface ran the pipeline (approval / final-gate) —
57    /// the same gate id is evaluated more than once per mission (events.rs),
58    /// so the surface is part of the evaluation's identity.
59    pub surface: GateSurface,
60    /// The verdict the gate stated — never derived from `score`.
61    pub verdict: GateVerdict,
62    /// Gate-supplied confidence score, recorded verbatim. `None` for
63    /// boolean-only gates — never a zero.
64    pub score: Option<f64>,
65    /// The threshold the gate judged `score` against; present exactly when
66    /// `score` is (the pair travels together from the event).
67    pub threshold: Option<f64>,
68}
69
70/// The ordered evaluation series for one gate identity across every folded
71/// mission log. The gate identity is echoed back so the machine form
72/// self-describes (the escalation-metrics/provenance JSON idiom).
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74#[serde(rename_all = "camelCase")]
75pub struct GateScoreSeries {
76    pub gate: String,
77    pub evaluations: Vec<GateScorePoint>,
78}
79
80/// The pure fold: every `gate.result` event whose gate identity matches
81/// `gate`, across all given mission logs, as one ordered series.
82///
83/// `missions` is `(mission_id, events)` pairs in any order, each mission's
84/// events in log (seq) order; events recorded under a DIFFERENT mission id
85/// than the log they sit in are filtered out (the
86/// [`crate::escalation_metrics::mission_escalation`] discipline — the
87/// enumerated mission is the truth, a stray event folds nowhere).
88///
89/// Ordering: ascending event `ts`, ties broken by (mission id, seq). WHY
90/// not seq alone: `seq` is per-mission, so it cannot order a cross-mission
91/// series — and WHY the tie-break matters: two missions can append within
92/// the same millisecond, and a distribution consumer must never see the
93/// order drift with input enumeration. The order is a total function of
94/// the logs; no clock, no hash iteration.
95pub fn gate_score_series(gate: &str, missions: &[(String, Vec<Event>)]) -> GateScoreSeries {
96    let mut evaluations = Vec::new();
97    for (mission_id, events) in missions {
98        for event in events {
99            if event.mission_id != *mission_id {
100                continue;
101            }
102            let EventKind::GateResult {
103                gate: event_gate,
104                surface,
105                verdict,
106                score,
107                threshold,
108                ..
109            } = &event.kind
110            else {
111                continue;
112            };
113            if event_gate != gate {
114                continue;
115            }
116            evaluations.push(GateScorePoint {
117                mission_id: mission_id.clone(),
118                seq: event.seq,
119                ts: event.ts,
120                surface: *surface,
121                verdict: *verdict,
122                score: *score,
123                threshold: *threshold,
124            });
125        }
126    }
127    evaluations.sort_by(|a, b| {
128        a.ts.cmp(&b.ts)
129            .then_with(|| a.mission_id.cmp(&b.mission_id))
130            .then_with(|| a.seq.cmp(&b.seq))
131    });
132    GateScoreSeries {
133        gate: gate.to_string(),
134        evaluations,
135    }
136}
137
138/// Enumerate every mission under `repo_root` exactly as
139/// [`crate::escalation_metrics::compute_escalation_metrics`] does (union of
140/// [`crate::paths::MissionPaths::list_missions`] and the ids in
141/// `.kranz/missions/index.md`), read each log, and fold the series for
142/// `gate`. A mission with no `events.jsonl` or an unreadable/corrupt log is
143/// skipped (degrade per-row); this never panics or fails the whole query.
144pub fn compute_gate_score_series(
145    repo_root: &std::path::Path,
146    gate: &str,
147) -> anyhow::Result<GateScoreSeries> {
148    let index_contents = std::fs::read_to_string(
149        crate::paths::MissionPaths::new(repo_root, "_")
150            .missions_dir()
151            .join("index.md"),
152    )
153    .unwrap_or_default();
154
155    let mut ids = crate::paths::MissionPaths::list_missions(repo_root);
156    for id in crate::mission_catalog::mission_index_ids(&index_contents) {
157        if !ids.contains(&id) {
158            ids.push(id);
159        }
160    }
161    ids.sort();
162
163    let mut missions = Vec::new();
164    for id in ids {
165        let paths = crate::paths::MissionPaths::new(repo_root, &id);
166        let events_path = paths.events_file();
167        if !events_path.is_file() {
168            continue;
169        }
170        // Never fold a mission reached through a symlinked path component
171        // (P1 mission-path-no-follow).
172        if paths.require_no_follow().is_err() {
173            continue;
174        }
175        let events = match crate::event_log::EventLog::read_events(&events_path) {
176            Ok(events) => events,
177            Err(_) => continue, // corrupt log degrades per-mission, never fails
178        };
179        missions.push((id, events));
180    }
181
182    Ok(gate_score_series(gate, &missions))
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::event_log::{EventLog, LockForce};
189    use crate::gate::GateKind;
190    use crate::paths::MissionPaths;
191    use std::time::Duration;
192    use tempfile::TempDir;
193
194    fn ev(seq: u64, mission_id: &str, ts_ms: i64, kind: EventKind) -> Event {
195        Event {
196            seq,
197            ts: DateTime::from_timestamp_millis(ts_ms).unwrap(),
198            mission_id: mission_id.to_string(),
199            kind,
200        }
201    }
202
203    /// A `gate.result` payload; `score` is the (score, threshold) pair a
204    /// scored gate reports, `None` for a boolean-only gate.
205    fn gate_result(
206        gate: &str,
207        surface: GateSurface,
208        verdict: GateVerdict,
209        score: Option<(f64, f64)>,
210    ) -> EventKind {
211        EventKind::GateResult {
212            gate: gate.to_string(),
213            surface,
214            kind: GateKind::Deterministic,
215            index: 0,
216            verdict,
217            artefact_ref: format!("contract gate {gate}"),
218            artefact_detail: None,
219            score: score.map(|(score, _)| score),
220            threshold: score.map(|(_, threshold)| threshold),
221            rule_ids: Vec::new(),
222        }
223    }
224
225    /// Replay: a scored gate event folds to the verdict AND the score pair
226    /// EXACTLY as stated — kranz records, never normalizes. The verdict is
227    /// Fail with a HIGH score (0.99): the series must carry Fail verbatim,
228    /// proving nothing derives the verdict from the score at query time
229    /// either.
230    #[test]
231    fn gate_score_series_replays_verdict_score_threshold_verbatim() {
232        let events = vec![ev(
233            7,
234            "m-1",
235            1_000,
236            gate_result(
237                "vacuous-filter",
238                GateSurface::FinalGate,
239                GateVerdict::Fail,
240                Some((0.99, 0.5)),
241            ),
242        )];
243        let series = gate_score_series("vacuous-filter", &[("m-1".to_string(), events)]);
244        assert_eq!(series.gate, "vacuous-filter");
245        assert_eq!(series.evaluations.len(), 1);
246        let point = &series.evaluations[0];
247        assert_eq!(point.mission_id, "m-1");
248        assert_eq!(point.seq, 7);
249        assert_eq!(point.surface, GateSurface::FinalGate);
250        assert_eq!(
251            point.verdict,
252            GateVerdict::Fail,
253            "verdict is stated, never derived"
254        );
255        assert_eq!(point.score, Some(0.99));
256        assert_eq!(point.threshold, Some(0.5));
257    }
258
259    /// A score-absent event (a boolean-only gate) replays cleanly: the
260    /// point carries `None`, never a zero — and the threshold is absent
261    /// exactly when the score is (the pair travels together).
262    #[test]
263    fn gate_score_series_score_absent_event_replays_clean() {
264        let events = vec![ev(
265            3,
266            "m-1",
267            1_000,
268            gate_result(
269                "env-sensitive",
270                GateSurface::Approval,
271                GateVerdict::Pass,
272                None,
273            ),
274        )];
275        let series = gate_score_series("env-sensitive", &[("m-1".to_string(), events)]);
276        assert_eq!(series.evaluations.len(), 1);
277        let point = &series.evaluations[0];
278        assert_eq!(point.verdict, GateVerdict::Pass);
279        assert_eq!(point.score, None, "absence is never a zero");
280        assert_eq!(point.threshold, None);
281    }
282
283    /// Old logs fold: a `gate.result` line written before the score fields
284    /// existed (no `score`/`threshold` keys on the wire) deserializes with
285    /// serde defaults and folds to `None` — the additive-fields contract
286    /// (events.rs) exercised through the series fold, on the wire bytes.
287    #[test]
288    fn gate_score_series_old_log_lines_without_score_fields_fold() {
289        let old_line = r#"{
290            "seq": 5,
291            "ts": "2026-01-02T03:04:05Z",
292            "missionId": "m-1",
293            "type": "gate.result",
294            "payload": {
295                "gate": "vacuous-filter",
296                "surface": "approval",
297                "kind": "deterministic",
298                "index": 0,
299                "verdict": "pass",
300                "artefactRef": "contract gate vacuous-filter"
301            }
302        }"#;
303        let event: Event = serde_json::from_str(old_line).unwrap();
304        let series = gate_score_series("vacuous-filter", &[("m-1".to_string(), vec![event])]);
305        assert_eq!(series.evaluations.len(), 1);
306        let point = &series.evaluations[0];
307        assert_eq!(point.verdict, GateVerdict::Pass);
308        assert_eq!(point.score, None);
309        assert_eq!(point.threshold, None);
310    }
311
312    /// The cross-mission query: two missions' logs fold into ONE series for
313    /// the queried gate identity — other gates' events and non-gate events
314    /// are ignored, an event recorded under a foreign mission id is
315    /// filtered out, and the series is ordered by (ts, mission id, seq)
316    /// regardless of the order the missions were handed in (determinism is
317    /// the replay contract).
318    #[test]
319    fn gate_score_series_cross_mission_query_orders_by_log_time() {
320        let m1 = vec![
321            ev(
322                4,
323                "m-1",
324                2_000,
325                gate_result(
326                    "vacuous-filter",
327                    GateSurface::Approval,
328                    GateVerdict::Pass,
329                    Some((1.0, 1.0)),
330                ),
331            ),
332            ev(
333                9,
334                "m-1",
335                3_000,
336                gate_result(
337                    "vacuous-filter",
338                    GateSurface::FinalGate,
339                    GateVerdict::Pass,
340                    Some((1.0, 1.0)),
341                ),
342            ),
343            // Another gate's event in the same log: ignored.
344            ev(
345                5,
346                "m-1",
347                2_500,
348                gate_result(
349                    "env-sensitive",
350                    GateSurface::Approval,
351                    GateVerdict::Pass,
352                    None,
353                ),
354            ),
355            // A stray event recorded under a different mission id inside
356            // this log: filtered out (the enumerated mission is the truth).
357            ev(
358                6,
359                "m-elsewhere",
360                2_600,
361                gate_result(
362                    "vacuous-filter",
363                    GateSurface::Approval,
364                    GateVerdict::Fail,
365                    Some((0.1, 1.0)),
366                ),
367            ),
368        ];
369        let m2 = vec![
370            // Same ms as m-1's seq 4: the mission-id tie-break orders it.
371            ev(
372                2,
373                "m-2",
374                2_000,
375                gate_result(
376                    "vacuous-filter",
377                    GateSurface::Approval,
378                    GateVerdict::Pass,
379                    Some((0.5, 1.0)),
380                ),
381            ),
382            // A non-gate event: ignored.
383            ev(3, "m-2", 2_100, EventKind::MissionCompleted {}),
384        ];
385        // Handed in reverse mission order; the series must not care.
386        let series = gate_score_series(
387            "vacuous-filter",
388            &[("m-2".to_string(), m2), ("m-1".to_string(), m1)],
389        );
390        let shape: Vec<(&str, u64, GateSurface, Option<f64>)> = series
391            .evaluations
392            .iter()
393            .map(|p| (p.mission_id.as_str(), p.seq, p.surface, p.score))
394            .collect();
395        assert_eq!(
396            shape,
397            vec![
398                ("m-1", 4, GateSurface::Approval, Some(1.0)),
399                ("m-2", 2, GateSurface::Approval, Some(0.5)),
400                ("m-1", 9, GateSurface::FinalGate, Some(1.0)),
401            ],
402            "ordered by ts with the mission-id tie-break at the 2_000ms tie"
403        );
404    }
405
406    /// A gate identity with no recorded evaluations yields an empty series,
407    /// not an error (the outcomes empty-history rule: a query over no
408    /// history is not a failure).
409    #[test]
410    fn gate_score_series_unknown_gate_is_empty() {
411        let events = vec![ev(
412            1,
413            "m-1",
414            0,
415            gate_result(
416                "env-sensitive",
417                GateSurface::Approval,
418                GateVerdict::Pass,
419                None,
420            ),
421        )];
422        let series = gate_score_series("no-such-gate", &[("m-1".to_string(), events)]);
423        assert_eq!(series.gate, "no-such-gate");
424        assert!(series.evaluations.is_empty());
425        let empty = gate_score_series("vacuous-filter", &[]);
426        assert!(empty.evaluations.is_empty());
427    }
428
429    // -- compute_gate_score_series over a fixture repo ---------------------
430
431    /// Seed a mission's `events.jsonl` with the given kinds, in order (the
432    /// escalation_metrics fixture idiom).
433    fn seed_mission(repo_root: &std::path::Path, id: &str, kinds: Vec<EventKind>) {
434        let paths = MissionPaths::new(repo_root, id);
435        let mut log = EventLog::acquire(&paths, id, Duration::ZERO, LockForce::No).unwrap();
436        for kind in kinds {
437            log.append(kind).unwrap();
438        }
439    }
440
441    /// End to end over real logs: two missions' `events.jsonl` files fold
442    /// into the queried gate's series, and a mission with a corrupt log
443    /// degrades per-row instead of failing the query.
444    #[test]
445    fn gate_score_series_compute_end_to_end_over_fixture_repo() {
446        let tmp = TempDir::new().unwrap();
447        seed_mission(
448            tmp.path(),
449            "m-1",
450            vec![
451                gate_result(
452                    "vacuous-filter",
453                    GateSurface::Approval,
454                    GateVerdict::Pass,
455                    Some((1.0, 1.0)),
456                ),
457                gate_result(
458                    "env-sensitive",
459                    GateSurface::Approval,
460                    GateVerdict::Pass,
461                    None,
462                ),
463                EventKind::MissionCompleted {},
464            ],
465        );
466        seed_mission(
467            tmp.path(),
468            "m-2",
469            vec![
470                gate_result(
471                    "vacuous-filter",
472                    GateSurface::FinalGate,
473                    GateVerdict::Fail,
474                    Some((0.5, 1.0)),
475                ),
476                EventKind::MissionCompleted {},
477            ],
478        );
479        // A mission whose log is corrupt: skipped, never fatal.
480        let corrupt = MissionPaths::new(tmp.path(), "m-corrupt");
481        std::fs::create_dir_all(corrupt.mission_dir()).unwrap();
482        std::fs::write(corrupt.events_file(), b"{not json}\n").unwrap();
483
484        let series = compute_gate_score_series(tmp.path(), "vacuous-filter").unwrap();
485        assert_eq!(series.gate, "vacuous-filter");
486        // m-1 was appended before m-2, so ts orders the series; identical
487        // ms would tie-break on the mission id — either way m-1 leads.
488        assert_eq!(series.evaluations.len(), 2);
489        let first = &series.evaluations[0];
490        assert_eq!(first.mission_id, "m-1");
491        assert_eq!(first.surface, GateSurface::Approval);
492        assert_eq!(first.verdict, GateVerdict::Pass);
493        assert_eq!(first.score, Some(1.0));
494        assert_eq!(first.threshold, Some(1.0));
495        let second = &series.evaluations[1];
496        assert_eq!(second.mission_id, "m-2");
497        assert_eq!(second.surface, GateSurface::FinalGate);
498        assert_eq!(second.verdict, GateVerdict::Fail);
499        assert_eq!(second.score, Some(0.5));
500        assert_eq!(second.threshold, Some(1.0));
501
502        // A boolean-only gate's series: recorded, with the score pair absent.
503        let series = compute_gate_score_series(tmp.path(), "env-sensitive").unwrap();
504        assert_eq!(series.evaluations.len(), 1);
505        assert_eq!(series.evaluations[0].score, None);
506        assert_eq!(series.evaluations[0].threshold, None);
507
508        // A gate nothing recorded: empty, not an error.
509        let series = compute_gate_score_series(tmp.path(), "no-such-gate").unwrap();
510        assert!(series.evaluations.is_empty());
511    }
512}