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}