use crate::events::{Event, EventKind};
use crate::gate::{GateSurface, GateVerdict};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GateScorePoint {
pub mission_id: String,
pub seq: u64,
pub ts: DateTime<Utc>,
pub surface: GateSurface,
pub verdict: GateVerdict,
pub score: Option<f64>,
pub threshold: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GateScoreSeries {
pub gate: String,
pub evaluations: Vec<GateScorePoint>,
}
pub fn gate_score_series(gate: &str, missions: &[(String, Vec<Event>)]) -> GateScoreSeries {
let mut evaluations = Vec::new();
for (mission_id, events) in missions {
for event in events {
if event.mission_id != *mission_id {
continue;
}
let EventKind::GateResult {
gate: event_gate,
surface,
verdict,
score,
threshold,
..
} = &event.kind
else {
continue;
};
if event_gate != gate {
continue;
}
evaluations.push(GateScorePoint {
mission_id: mission_id.clone(),
seq: event.seq,
ts: event.ts,
surface: *surface,
verdict: *verdict,
score: *score,
threshold: *threshold,
});
}
}
evaluations.sort_by(|a, b| {
a.ts.cmp(&b.ts)
.then_with(|| a.mission_id.cmp(&b.mission_id))
.then_with(|| a.seq.cmp(&b.seq))
});
GateScoreSeries {
gate: gate.to_string(),
evaluations,
}
}
pub fn compute_gate_score_series(
repo_root: &std::path::Path,
gate: &str,
) -> anyhow::Result<GateScoreSeries> {
let index_contents = std::fs::read_to_string(
crate::paths::MissionPaths::new(repo_root, "_")
.missions_dir()
.join("index.md"),
)
.unwrap_or_default();
let mut ids = crate::paths::MissionPaths::list_missions(repo_root);
for id in crate::mission_catalog::mission_index_ids(&index_contents) {
if !ids.contains(&id) {
ids.push(id);
}
}
ids.sort();
let mut missions = Vec::new();
for id in ids {
let paths = crate::paths::MissionPaths::new(repo_root, &id);
let events_path = paths.events_file();
if !events_path.is_file() {
continue;
}
if paths.require_no_follow().is_err() {
continue;
}
let events = match crate::event_log::EventLog::read_events(&events_path) {
Ok(events) => events,
Err(_) => continue, };
missions.push((id, events));
}
Ok(gate_score_series(gate, &missions))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event_log::{EventLog, LockForce};
use crate::gate::GateKind;
use crate::paths::MissionPaths;
use std::time::Duration;
use tempfile::TempDir;
fn ev(seq: u64, mission_id: &str, ts_ms: i64, kind: EventKind) -> Event {
Event {
seq,
ts: DateTime::from_timestamp_millis(ts_ms).unwrap(),
mission_id: mission_id.to_string(),
kind,
}
}
fn gate_result(
gate: &str,
surface: GateSurface,
verdict: GateVerdict,
score: Option<(f64, f64)>,
) -> EventKind {
EventKind::GateResult {
gate: gate.to_string(),
surface,
kind: GateKind::Deterministic,
index: 0,
verdict,
artefact_ref: format!("contract gate {gate}"),
artefact_detail: None,
score: score.map(|(score, _)| score),
threshold: score.map(|(_, threshold)| threshold),
rule_ids: Vec::new(),
}
}
#[test]
fn gate_score_series_replays_verdict_score_threshold_verbatim() {
let events = vec![ev(
7,
"m-1",
1_000,
gate_result(
"vacuous-filter",
GateSurface::FinalGate,
GateVerdict::Fail,
Some((0.99, 0.5)),
),
)];
let series = gate_score_series("vacuous-filter", &[("m-1".to_string(), events)]);
assert_eq!(series.gate, "vacuous-filter");
assert_eq!(series.evaluations.len(), 1);
let point = &series.evaluations[0];
assert_eq!(point.mission_id, "m-1");
assert_eq!(point.seq, 7);
assert_eq!(point.surface, GateSurface::FinalGate);
assert_eq!(
point.verdict,
GateVerdict::Fail,
"verdict is stated, never derived"
);
assert_eq!(point.score, Some(0.99));
assert_eq!(point.threshold, Some(0.5));
}
#[test]
fn gate_score_series_score_absent_event_replays_clean() {
let events = vec![ev(
3,
"m-1",
1_000,
gate_result(
"env-sensitive",
GateSurface::Approval,
GateVerdict::Pass,
None,
),
)];
let series = gate_score_series("env-sensitive", &[("m-1".to_string(), events)]);
assert_eq!(series.evaluations.len(), 1);
let point = &series.evaluations[0];
assert_eq!(point.verdict, GateVerdict::Pass);
assert_eq!(point.score, None, "absence is never a zero");
assert_eq!(point.threshold, None);
}
#[test]
fn gate_score_series_old_log_lines_without_score_fields_fold() {
let old_line = r#"{
"seq": 5,
"ts": "2026-01-02T03:04:05Z",
"missionId": "m-1",
"type": "gate.result",
"payload": {
"gate": "vacuous-filter",
"surface": "approval",
"kind": "deterministic",
"index": 0,
"verdict": "pass",
"artefactRef": "contract gate vacuous-filter"
}
}"#;
let event: Event = serde_json::from_str(old_line).unwrap();
let series = gate_score_series("vacuous-filter", &[("m-1".to_string(), vec![event])]);
assert_eq!(series.evaluations.len(), 1);
let point = &series.evaluations[0];
assert_eq!(point.verdict, GateVerdict::Pass);
assert_eq!(point.score, None);
assert_eq!(point.threshold, None);
}
#[test]
fn gate_score_series_cross_mission_query_orders_by_log_time() {
let m1 = vec![
ev(
4,
"m-1",
2_000,
gate_result(
"vacuous-filter",
GateSurface::Approval,
GateVerdict::Pass,
Some((1.0, 1.0)),
),
),
ev(
9,
"m-1",
3_000,
gate_result(
"vacuous-filter",
GateSurface::FinalGate,
GateVerdict::Pass,
Some((1.0, 1.0)),
),
),
ev(
5,
"m-1",
2_500,
gate_result(
"env-sensitive",
GateSurface::Approval,
GateVerdict::Pass,
None,
),
),
ev(
6,
"m-elsewhere",
2_600,
gate_result(
"vacuous-filter",
GateSurface::Approval,
GateVerdict::Fail,
Some((0.1, 1.0)),
),
),
];
let m2 = vec![
ev(
2,
"m-2",
2_000,
gate_result(
"vacuous-filter",
GateSurface::Approval,
GateVerdict::Pass,
Some((0.5, 1.0)),
),
),
ev(3, "m-2", 2_100, EventKind::MissionCompleted {}),
];
let series = gate_score_series(
"vacuous-filter",
&[("m-2".to_string(), m2), ("m-1".to_string(), m1)],
);
let shape: Vec<(&str, u64, GateSurface, Option<f64>)> = series
.evaluations
.iter()
.map(|p| (p.mission_id.as_str(), p.seq, p.surface, p.score))
.collect();
assert_eq!(
shape,
vec![
("m-1", 4, GateSurface::Approval, Some(1.0)),
("m-2", 2, GateSurface::Approval, Some(0.5)),
("m-1", 9, GateSurface::FinalGate, Some(1.0)),
],
"ordered by ts with the mission-id tie-break at the 2_000ms tie"
);
}
#[test]
fn gate_score_series_unknown_gate_is_empty() {
let events = vec![ev(
1,
"m-1",
0,
gate_result(
"env-sensitive",
GateSurface::Approval,
GateVerdict::Pass,
None,
),
)];
let series = gate_score_series("no-such-gate", &[("m-1".to_string(), events)]);
assert_eq!(series.gate, "no-such-gate");
assert!(series.evaluations.is_empty());
let empty = gate_score_series("vacuous-filter", &[]);
assert!(empty.evaluations.is_empty());
}
fn seed_mission(repo_root: &std::path::Path, id: &str, kinds: Vec<EventKind>) {
let paths = MissionPaths::new(repo_root, id);
let mut log = EventLog::acquire(&paths, id, Duration::ZERO, LockForce::No).unwrap();
for kind in kinds {
log.append(kind).unwrap();
}
}
#[test]
fn gate_score_series_compute_end_to_end_over_fixture_repo() {
let tmp = TempDir::new().unwrap();
seed_mission(
tmp.path(),
"m-1",
vec![
gate_result(
"vacuous-filter",
GateSurface::Approval,
GateVerdict::Pass,
Some((1.0, 1.0)),
),
gate_result(
"env-sensitive",
GateSurface::Approval,
GateVerdict::Pass,
None,
),
EventKind::MissionCompleted {},
],
);
seed_mission(
tmp.path(),
"m-2",
vec![
gate_result(
"vacuous-filter",
GateSurface::FinalGate,
GateVerdict::Fail,
Some((0.5, 1.0)),
),
EventKind::MissionCompleted {},
],
);
let corrupt = MissionPaths::new(tmp.path(), "m-corrupt");
std::fs::create_dir_all(corrupt.mission_dir()).unwrap();
std::fs::write(corrupt.events_file(), b"{not json}\n").unwrap();
let series = compute_gate_score_series(tmp.path(), "vacuous-filter").unwrap();
assert_eq!(series.gate, "vacuous-filter");
assert_eq!(series.evaluations.len(), 2);
let first = &series.evaluations[0];
assert_eq!(first.mission_id, "m-1");
assert_eq!(first.surface, GateSurface::Approval);
assert_eq!(first.verdict, GateVerdict::Pass);
assert_eq!(first.score, Some(1.0));
assert_eq!(first.threshold, Some(1.0));
let second = &series.evaluations[1];
assert_eq!(second.mission_id, "m-2");
assert_eq!(second.surface, GateSurface::FinalGate);
assert_eq!(second.verdict, GateVerdict::Fail);
assert_eq!(second.score, Some(0.5));
assert_eq!(second.threshold, Some(1.0));
let series = compute_gate_score_series(tmp.path(), "env-sensitive").unwrap();
assert_eq!(series.evaluations.len(), 1);
assert_eq!(series.evaluations[0].score, None);
assert_eq!(series.evaluations[0].threshold, None);
let series = compute_gate_score_series(tmp.path(), "no-such-gate").unwrap();
assert!(series.evaluations.is_empty());
}
}