1use crate::error::Result;
60use crate::events::{Event, EventKind};
61use crate::gate::{GateSurface, GateVerdict};
62use crate::provenance::{DivergenceLink, ProvenanceChain};
63use crate::types::{DivergenceCandidate, MissionState};
64use serde::{Deserialize, Serialize};
65use std::path::Path;
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct GateChainRef {
74 pub seq: u64,
75 pub gate: String,
76 pub surface: GateSurface,
77 pub verdict: GateVerdict,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86#[serde(rename_all = "camelCase")]
87pub struct WorkerTraceRecord {
88 pub instruction: String,
89 pub response: String,
90 pub model: String,
91 pub quant: String,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub weight_hash: Option<String>,
94 pub mission_id: String,
95 pub feature_id: String,
96 pub run_id: String,
97 pub backend: Option<String>,
104 pub gate_chain: Vec<GateChainRef>,
109}
110
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase")]
118pub struct DivergenceResolution {
119 pub selected: Option<u32>,
120 pub reason: String,
121 pub decided_by: String,
122 pub resolved_seq: u64,
123}
124
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase")]
133pub struct DivergenceRecord {
134 pub mission_id: String,
135 pub unit: String,
136 pub noted_seq: u64,
139 pub diverged: bool,
140 pub candidates: Vec<DivergenceCandidate>,
141 pub resolution: Option<DivergenceResolution>,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "lowercase")]
149pub enum EscalationKind {
150 Grant,
151 Steer,
152 Block,
153}
154
155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163#[serde(rename_all = "camelCase")]
164pub struct EscalationRecord {
165 pub mission_id: String,
166 pub kind: EscalationKind,
167 pub milestone_id: Option<String>,
169 pub ask: String,
172 pub decision: String,
173 pub latency_ms: Option<u64>,
176 pub ask_seq: u64,
179 pub decision_seq: Option<u64>,
182}
183
184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187#[serde(tag = "source", rename_all = "kebab-case")]
188pub enum CorpusRecord {
189 WorkerTrace(WorkerTraceRecord),
190 Divergence(DivergenceRecord),
191 Escalation(EscalationRecord),
192}
193
194pub fn export_corpus(
205 mission_dir: &Path,
206 mission_id: &str,
207 events: &[Event],
208) -> Result<Vec<CorpusRecord>> {
209 let state = crate::reducer::fold(events)?;
210 let chain = crate::provenance::provenance_chain(mission_dir, mission_id, events)?;
211 let mut records = Vec::new();
212 records.extend(
213 worker_trace_records(&state, events, &chain)
214 .into_iter()
215 .map(CorpusRecord::WorkerTrace),
216 );
217 records.extend(
218 divergence_records(mission_id, &chain)
219 .into_iter()
220 .map(CorpusRecord::Divergence),
221 );
222 records.extend(
223 escalation_records(mission_id, events)
224 .into_iter()
225 .map(CorpusRecord::Escalation),
226 );
227 Ok(records)
228}
229
230fn worker_trace_records(
236 state: &MissionState,
237 events: &[Event],
238 chain: &ProvenanceChain,
239) -> Vec<WorkerTraceRecord> {
240 let gate_chain: Vec<GateChainRef> = chain
241 .gates
242 .iter()
243 .map(|gate| GateChainRef {
244 seq: gate.seq,
245 gate: gate.gate.clone(),
246 surface: gate.surface,
247 verdict: gate.verdict,
248 })
249 .collect();
250 crate::trace_export::export_validated_traces(state, events)
251 .into_iter()
252 .map(|pair| {
253 let backend = chain
254 .sessions
255 .iter()
256 .find(|session| session.run_id == pair.run_id)
257 .and_then(|session| session.backend.clone());
258 WorkerTraceRecord {
259 instruction: pair.instruction,
260 response: pair.response,
261 model: pair.model,
262 quant: pair.quant,
263 weight_hash: pair.weight_hash,
264 mission_id: pair.mission_id,
265 feature_id: pair.feature_id,
266 run_id: pair.run_id,
267 backend,
268 gate_chain: gate_chain.clone(),
269 }
270 })
271 .collect()
272}
273
274fn divergence_records(mission_id: &str, chain: &ProvenanceChain) -> Vec<DivergenceRecord> {
281 let mut used_resolutions = vec![false; chain.divergences.len()];
282 let mut records = Vec::new();
283 for link in &chain.divergences {
284 let DivergenceLink::Noted {
285 seq,
286 unit,
287 candidates,
288 diverged,
289 } = link
290 else {
291 continue;
292 };
293 let mut resolution = None;
294 for (i, candidate) in chain.divergences.iter().enumerate() {
295 if used_resolutions[i] {
296 continue;
297 }
298 if let DivergenceLink::Resolved {
299 seq: resolved_seq,
300 unit: resolved_unit,
301 selected,
302 reason,
303 decided_by,
304 } = candidate
305 {
306 if resolved_unit == unit && resolved_seq > seq {
307 used_resolutions[i] = true;
308 resolution = Some(DivergenceResolution {
309 selected: *selected,
310 reason: reason.clone(),
311 decided_by: decided_by.clone(),
312 resolved_seq: *resolved_seq,
313 });
314 break;
315 }
316 }
317 }
318 records.push(DivergenceRecord {
319 mission_id: mission_id.to_string(),
320 unit: unit.clone(),
321 noted_seq: *seq,
322 diverged: *diverged,
323 candidates: candidates.clone(),
324 resolution,
325 });
326 }
327 records
328}
329
330fn escalation_records(mission_id: &str, events: &[Event]) -> Vec<EscalationRecord> {
339 let mission_events: Vec<&Event> = events
340 .iter()
341 .filter(|e| e.mission_id == mission_id)
342 .collect();
343 let plan_approved_seq = mission_events
344 .iter()
345 .find(|e| matches!(e.kind, EventKind::PlanApproved { .. }))
346 .map(|e| e.seq);
347 let mut records = Vec::new();
348
349 for e in &mission_events {
351 if let EventKind::UserMessage { text, .. } = &e.kind {
352 if let Some(approved) = plan_approved_seq {
353 if e.seq >= approved {
354 records.push(EscalationRecord {
355 mission_id: mission_id.to_string(),
356 kind: EscalationKind::Steer,
357 milestone_id: None,
358 ask: text.clone(),
359 decision: "steered".to_string(),
360 latency_ms: None,
361 ask_seq: e.seq,
362 decision_seq: Some(e.seq),
363 });
364 }
365 }
366 }
367 }
368
369 for (req_idx, matched) in crate::escalation_metrics::pair_grant_decisions(&mission_events) {
371 let req = mission_events[req_idx];
372 let EventKind::GrantRequested {
373 milestone_id,
374 kind,
375 command,
376 } = &req.kind
377 else {
378 unreachable!("pair_grant_decisions only returns grant.requested indices")
379 };
380 let (decision, latency_ms, decision_seq) = match matched {
381 Some(i) => {
382 let decided = mission_events[i];
383 let latency = (decided.ts - req.ts).num_milliseconds();
384 let latency_ms = if latency >= 0 {
385 Some(latency as u64)
386 } else {
387 None
388 };
389 let decision = match &decided.kind {
390 EventKind::GrantApproved { .. } => "approved".to_string(),
391 EventKind::GrantDenied { reason, .. } => format!("denied: {reason}"),
392 _ => unreachable!("pair_grant_decisions only matches grant decisions"),
393 };
394 (decision, latency_ms, Some(decided.seq))
395 }
396 None => ("pending".to_string(), None, None),
397 };
398 records.push(EscalationRecord {
399 mission_id: mission_id.to_string(),
400 kind: EscalationKind::Grant,
401 milestone_id: Some(milestone_id.clone()),
402 ask: format!(
403 "{}: {command}",
404 crate::escalation_metrics::grant_kind_str(kind)
405 ),
406 decision,
407 latency_ms,
408 ask_seq: req.seq,
409 decision_seq,
410 });
411 }
412
413 let mut used_unblocks = vec![false; mission_events.len()];
415 for (block_idx, block) in mission_events.iter().enumerate() {
416 let EventKind::MilestoneBlocked {
417 milestone_id,
418 reason,
419 ..
420 } = &block.kind
421 else {
422 continue;
423 };
424 let mut matched = None;
425 for (i, candidate) in mission_events.iter().enumerate() {
426 if i <= block_idx || used_unblocks[i] {
427 continue;
428 }
429 if let EventKind::MilestoneUnblocked {
430 milestone_id: unblocked,
431 ..
432 } = &candidate.kind
433 {
434 if unblocked == milestone_id {
435 used_unblocks[i] = true;
436 matched = Some(i);
437 break;
438 }
439 }
440 }
441 let (decision, latency_ms, decision_seq) = match matched {
442 Some(i) => {
443 let decided = mission_events[i];
444 let EventKind::MilestoneUnblocked {
445 reason: unblock_reason,
446 block_context,
447 ..
448 } = &decided.kind
449 else {
450 unreachable!("matched only milestone.unblocked above")
451 };
452 if crate::escalation_metrics::is_engine_lift(unblock_reason, block_context.as_ref())
453 {
454 continue;
455 }
456 let latency = (decided.ts - block.ts).num_milliseconds();
457 let latency_ms = if latency >= 0 {
458 Some(latency as u64)
459 } else {
460 None
461 };
462 (
463 format!("unblocked: {unblock_reason}"),
464 latency_ms,
465 Some(decided.seq),
466 )
467 }
468 None => ("pending".to_string(), None, None),
469 };
470 records.push(EscalationRecord {
471 mission_id: mission_id.to_string(),
472 kind: EscalationKind::Block,
473 milestone_id: Some(milestone_id.clone()),
474 ask: reason.clone(),
475 decision,
476 latency_ms,
477 ask_seq: block.seq,
478 decision_seq,
479 });
480 }
481
482 records.sort_by_key(|record| record.ask_seq);
486 records
487}
488
489pub fn to_jsonl(records: &[CorpusRecord]) -> String {
494 let mut out = String::new();
495 for record in records {
496 out.push_str(&serde_json::to_string(record).expect("CorpusRecord always serializes"));
497 out.push('\n');
498 }
499 out
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505 use crate::events::EventKind;
506 use crate::gate::GateKind;
507 use crate::types::*;
508 use chrono::{DateTime, TimeZone, Utc};
509
510 const MISSION: &str = "m-1";
511
512 fn base_ts() -> DateTime<Utc> {
513 Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap()
514 }
515
516 fn ev(seq: u64, kind: EventKind) -> Event {
519 Event {
520 seq,
521 ts: base_ts() + chrono::Duration::seconds(seq as i64),
522 mission_id: MISSION.to_string(),
523 kind,
524 }
525 }
526
527 fn plan_feature(title: &str) -> PlanFeature {
528 PlanFeature {
529 title: title.to_string(),
530 spec: format!("spec for {title}"),
531 validation_criteria: vec![format!("{title} works")],
532 }
533 }
534
535 fn plan() -> Plan {
537 Plan {
538 goal: "build the thing".to_string(),
539 validation_contract: vec![],
540 milestones: vec![PlanMilestone {
541 title: "milestone one".to_string(),
542 features: vec![plan_feature("alpha"), plan_feature("beta")],
543 }],
544 considered_alternatives: None,
545 command_grants: vec![],
546 touch_set: vec![],
547 standards_manifest: None,
548 reviewer_independence: None,
549 }
550 }
551
552 fn gate(index: u32) -> EventKind {
553 EventKind::GateResult {
554 gate: "merge-gate-suite".to_string(),
555 surface: GateSurface::Approval,
556 kind: GateKind::Deterministic,
557 index,
558 verdict: GateVerdict::Pass,
559 artefact_ref: format!("contract gate {index}"),
560 artefact_detail: None,
561 score: None,
562 threshold: None,
563 rule_ids: Vec::new(),
564 }
565 }
566
567 fn spawn(run_id: &str, feature_id: &str, model: &str) -> EventKind {
568 EventKind::WorkerSpawned {
569 backend: None,
570 run_id: run_id.to_string(),
571 role: Role::Worker,
572 feature_id: Some(feature_id.to_string()),
573 milestone_id: None,
574 candidate: None,
575 executor_route: None,
576 sdk_session_id: format!("sess-{run_id}"),
577 model: model.to_string(),
578 quant: "n/a".to_string(),
579 weight_hash: None,
580 prompt_hash: "deadbeef".to_string(),
581 transcript_path: format!("runs/{run_id}.jsonl"),
582 }
583 }
584
585 fn completed(run_id: &str, result: RunResult, summary: &str) -> EventKind {
586 EventKind::WorkerCompleted {
587 run_id: run_id.to_string(),
588 result,
589 tokens: TokenUsage::default(),
590 cost_usd: None,
591 report: Some(WorkerReport {
592 result,
593 summary: summary.to_string(),
594 files_touched: vec![],
595 tests_added: vec![],
596 test_evidence: "cargo test: ok".to_string(),
597 dependencies_added: vec![],
598 known_gaps: vec![],
599 commits: vec!["deadbeef commit".to_string()],
600 commands_run: vec![],
601 escalation: None,
602 questions: None,
603 }),
604 }
605 }
606
607 fn candidates() -> Vec<DivergenceCandidate> {
608 vec![
609 DivergenceCandidate {
610 run_id: "r-pass".to_string(),
611 branch: format!("kranz/pool/{MISSION}/f-1-1-c0"),
612 backend: "claude".to_string(),
613 tree: "aaa".to_string(),
614 },
615 DivergenceCandidate {
616 run_id: "r-cand".to_string(),
617 branch: format!("kranz/pool/{MISSION}/f-1-1-c1"),
618 backend: "codex".to_string(),
619 tree: "bbb".to_string(),
620 },
621 ]
622 }
623
624 fn fixture_events() -> Vec<Event> {
636 vec![
637 ev(
638 1,
639 EventKind::MissionCreated {
640 goal: "build the thing".to_string(),
641 base_branch: "main".to_string(),
642 mission_branch: format!("kranz/mission-{MISSION}"),
643 config: MissionConfig::default(),
644 },
645 ),
646 ev(
647 2,
648 EventKind::PlanApproved {
649 plan: plan(),
650 base_sha: Some("deadbeef".to_string()),
651 },
652 ),
653 ev(3, gate(0)),
654 ev(4, gate(1)),
655 ev(
656 5,
657 EventKind::MilestoneStarted {
658 milestone_id: "ms-1".to_string(),
659 start_sha: "abc123".to_string(),
660 },
661 ),
662 ev(
663 6,
664 EventKind::FeatureStarted {
665 feature_id: "f-1-1".to_string(),
666 },
667 ),
668 ev(7, spawn("r-pass", "f-1-1", "sonnet")),
669 ev(
670 8,
671 completed("r-pass", RunResult::Pass, "did the alpha thing"),
672 ),
673 ev(
674 9,
675 EventKind::FeatureCompleted {
676 feature_id: "f-1-1".to_string(),
677 commits: vec!["deadbeef".to_string()],
678 },
679 ),
680 ev(10, spawn("r-cand", "f-1-1", "gpt-5")),
682 ev(
683 11,
684 EventKind::DivergenceNoted {
685 unit: "f-1-1".to_string(),
686 candidates: candidates(),
687 diverged: true,
688 },
689 ),
690 ev(
691 12,
692 EventKind::MilestoneBlocked {
693 block_context: None,
694 milestone_id: "ms-1".to_string(),
695 reason: "divergence on f-1-1: candidates disagree".to_string(),
696 },
697 ),
698 ev(
699 13,
700 EventKind::MilestoneUnblocked {
701 block_context: None,
702 milestone_id: "ms-1".to_string(),
703 reason: "kept candidate 0".to_string(),
704 validator_guidance: None,
705 },
706 ),
707 ev(
708 14,
709 EventKind::DivergenceResolved {
710 unit: "f-1-1".to_string(),
711 selected: Some(0),
712 reason: "kept candidate 0".to_string(),
713 decided_by: "operator".to_string(),
714 },
715 ),
716 ev(
717 15,
718 EventKind::GrantRequested {
719 milestone_id: "ms-1".to_string(),
720 kind: GrantKind::Command,
721 command: "cargo test".to_string(),
722 },
723 ),
724 ev(
725 16,
726 EventKind::GrantApproved {
727 kind: GrantKind::Command,
728 command: "cargo test".to_string(),
729 },
730 ),
731 ev(
732 17,
733 EventKind::UserMessage {
734 text: "ship it as-is".to_string(),
735 interrupt: false,
736 },
737 ),
738 ev(
739 18,
740 EventKind::FeatureStarted {
741 feature_id: "f-1-2".to_string(),
742 },
743 ),
744 ev(19, spawn("r-fail", "f-1-2", "sonnet")),
745 ev(
746 20,
747 completed("r-fail", RunResult::Fail, "could not do the beta thing"),
748 ),
749 ev(
750 21,
751 EventKind::FeatureFailed {
752 feature_id: "f-1-2".to_string(),
753 reason: "gave up".to_string(),
754 commits: Vec::new(),
755 },
756 ),
757 ev(
759 22,
760 EventKind::MilestoneBlocked {
761 block_context: None,
762 milestone_id: "ms-1".to_string(),
763 reason: "workspace gate: bootstrap failed".to_string(),
764 },
765 ),
766 ev(
767 23,
768 EventKind::MilestoneUnblocked {
769 block_context: None,
770 milestone_id: "ms-1".to_string(),
771 reason: crate::workspace_gate::GATE_LIFT_REASON.to_string(),
772 validator_guidance: None,
773 },
774 ),
775 ev(
777 24,
778 EventKind::GrantRequested {
779 milestone_id: "ms-1".to_string(),
780 kind: GrantKind::Egress,
781 command: "example.com:443".to_string(),
782 },
783 ),
784 ev(
786 25,
787 EventKind::DivergenceNoted {
788 unit: "f-1-1".to_string(),
789 candidates: candidates(),
790 diverged: false,
791 },
792 ),
793 ev(
794 26,
795 EventKind::MilestoneCompleted {
796 milestone_id: "ms-1".to_string(),
797 tag: None,
798 },
799 ),
800 ev(27, EventKind::MissionCompleted {}),
801 ]
802 }
803
804 fn export(dir: &std::path::Path, events: &[Event]) -> Vec<CorpusRecord> {
805 export_corpus(dir, MISSION, events).unwrap()
806 }
807
808 #[test]
809 fn corpus_export_emits_traces_divergences_and_escalations_with_provenance() {
810 let tmp = tempfile::TempDir::new().unwrap();
811 let events = fixture_events();
812 let records = export(tmp.path(), &events);
813
814 assert_eq!(records.len(), 7, "record count: {records:?}");
816
817 let CorpusRecord::WorkerTrace(trace) = &records[0] else {
819 panic!("records[0] must be the worker trace: {records:?}")
820 };
821 assert_eq!(trace.run_id, "r-pass");
822 assert_eq!(trace.mission_id, MISSION);
823 assert_eq!(trace.feature_id, "f-1-1");
824 assert_eq!(trace.model, "sonnet");
825 assert!(trace.instruction.contains("spec for alpha"));
826 assert!(trace.response.contains("did the alpha thing"));
827 assert_eq!(trace.backend.as_deref(), Some("claude"));
830 let ladder: Vec<(u64, GateSurface, GateVerdict)> = trace
831 .gate_chain
832 .iter()
833 .map(|r| (r.seq, r.surface, r.verdict))
834 .collect();
835 assert_eq!(
836 ladder,
837 vec![
838 (3, GateSurface::Approval, GateVerdict::Pass),
839 (4, GateSurface::Approval, GateVerdict::Pass),
840 ]
841 );
842
843 let CorpusRecord::Divergence(resolved) = &records[1] else {
846 panic!("records[1] must be the resolved divergence: {records:?}")
847 };
848 assert_eq!(resolved.unit, "f-1-1");
849 assert_eq!(resolved.noted_seq, 11);
850 assert!(resolved.diverged);
851 let candidate_refs: Vec<(&str, &str, &str)> = resolved
852 .candidates
853 .iter()
854 .map(|c| (c.run_id.as_str(), c.branch.as_str(), c.backend.as_str()))
855 .collect();
856 assert_eq!(
857 candidate_refs,
858 vec![
859 ("r-pass", "kranz/pool/m-1/f-1-1-c0", "claude"),
860 ("r-cand", "kranz/pool/m-1/f-1-1-c1", "codex"),
861 ]
862 );
863 let resolution = resolved.resolution.as_ref().expect("resolved at 14");
864 assert_eq!(resolution.selected, Some(0));
865 assert_eq!(resolution.reason, "kept candidate 0");
866 assert_eq!(resolution.decided_by, "operator");
867 assert_eq!(resolution.resolved_seq, 14);
868
869 let CorpusRecord::Divergence(pending) = &records[2] else {
870 panic!("records[2] must be the pending divergence: {records:?}")
871 };
872 assert_eq!(pending.noted_seq, 25);
873 assert!(!pending.diverged, "the agreement record exports verbatim");
874 assert_eq!(pending.resolution, None);
875
876 let escalations: Vec<&EscalationRecord> = records[3..]
879 .iter()
880 .map(|r| match r {
881 CorpusRecord::Escalation(e) => e,
882 other => panic!("expected escalation, got {other:?}"),
883 })
884 .collect();
885 let lane: Vec<(u64, EscalationKind, &str, &str)> = escalations
886 .iter()
887 .map(|e| (e.ask_seq, e.kind, e.ask.as_str(), e.decision.as_str()))
888 .collect();
889 assert_eq!(
890 lane,
891 vec![
892 (
893 12,
894 EscalationKind::Block,
895 "divergence on f-1-1: candidates disagree",
896 "unblocked: kept candidate 0"
897 ),
898 (15, EscalationKind::Grant, "command: cargo test", "approved"),
899 (17, EscalationKind::Steer, "ship it as-is", "steered"),
900 (
901 24,
902 EscalationKind::Grant,
903 "egress: example.com:443",
904 "pending"
905 ),
906 ]
907 );
908 assert_eq!(escalations[0].latency_ms, Some(1000));
909 assert_eq!(escalations[0].decision_seq, Some(13));
910 assert_eq!(escalations[0].milestone_id.as_deref(), Some("ms-1"));
911 assert_eq!(escalations[1].latency_ms, Some(1000));
912 assert_eq!(escalations[1].decision_seq, Some(16));
913 assert_eq!(escalations[2].milestone_id, None);
914 assert_eq!(escalations[2].decision_seq, Some(17));
915 assert_eq!(escalations[3].latency_ms, None);
916 assert_eq!(escalations[3].decision_seq, None);
917
918 let jsonl = to_jsonl(&records);
920 let lines: Vec<&str> = jsonl.lines().collect();
921 assert_eq!(lines.len(), 7);
922 let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
923 assert_eq!(first["source"], "worker-trace");
924 assert!(first["gateChain"].is_array());
925 assert_eq!(first["gateChain"][0]["surface"], "approval");
926 let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
927 assert_eq!(second["source"], "divergence");
928 assert_eq!(second["notedSeq"], 11);
929 assert_eq!(second["resolution"]["decidedBy"], "operator");
930 let fourth: serde_json::Value = serde_json::from_str(lines[3]).unwrap();
931 assert_eq!(fourth["source"], "escalation");
932 assert_eq!(fourth["kind"], "block");
933 assert_eq!(fourth["askSeq"], 12);
934 }
935
936 #[test]
937 fn corpus_export_excludes_failed_and_unvalidated_sessions() {
938 let tmp = tempfile::TempDir::new().unwrap();
939 let events = fixture_events();
940 let records = export(tmp.path(), &events);
941
942 let traces: Vec<&WorkerTraceRecord> = records
943 .iter()
944 .filter_map(|r| match r {
945 CorpusRecord::WorkerTrace(t) => Some(t),
946 _ => None,
947 })
948 .collect();
949 assert_eq!(
950 traces.len(),
951 1,
952 "only the validation-PASSED run qualifies: {traces:?}"
953 );
954 assert_eq!(traces[0].run_id, "r-pass");
955 let jsonl = to_jsonl(&records);
959 assert!(!jsonl.contains("r-fail"), "failed run leaked: {jsonl}");
960 assert!(traces.iter().all(|t| t.run_id != "r-cand"));
961 }
962
963 #[test]
964 fn corpus_export_is_byte_identical_across_regeneration() {
965 let tmp = tempfile::TempDir::new().unwrap();
966 let events = fixture_events();
967
968 let first = to_jsonl(&export(tmp.path(), &events));
969 let second = to_jsonl(&export(tmp.path(), &events));
970 assert_eq!(first, second, "same log must yield byte-identical JSONL");
971 assert!(!first.is_empty());
972 for tag in ["worker-trace", "divergence", "escalation"] {
973 assert!(
974 first.contains(&format!("\"source\":\"{tag}\"")),
975 "missing {tag} records: {first}"
976 );
977 }
978 }
979
980 #[test]
981 fn corpus_export_provenance_refs_resolve_via_the_replay() {
982 let tmp = tempfile::TempDir::new().unwrap();
983 let events = fixture_events();
984 let records = export(tmp.path(), &events);
985 let chain = crate::provenance::provenance_chain(tmp.path(), MISSION, &events).unwrap();
988
989 for record in &records {
990 match record {
991 CorpusRecord::WorkerTrace(trace) => {
992 let session = chain
993 .sessions
994 .iter()
995 .find(|s| s.run_id == trace.run_id)
996 .expect("trace run id must resolve to a replayed session");
997 assert_eq!(session.backend, trace.backend);
998 assert_eq!(session.model, trace.model);
999 for gate_ref in &trace.gate_chain {
1000 let gate = chain
1001 .gates
1002 .iter()
1003 .find(|g| g.seq == gate_ref.seq)
1004 .expect("gate-chain seq must resolve to a ladder link");
1005 assert_eq!(gate.gate, gate_ref.gate);
1006 assert_eq!(gate.surface, gate_ref.surface);
1007 assert_eq!(gate.verdict, gate_ref.verdict);
1008 }
1009 }
1010 CorpusRecord::Divergence(divergence) => {
1011 assert!(chain.divergences.iter().any(|link| matches!(
1012 link,
1013 DivergenceLink::Noted { seq, unit, .. }
1014 if *seq == divergence.noted_seq && *unit == divergence.unit
1015 )));
1016 if let Some(resolution) = &divergence.resolution {
1017 assert!(chain.divergences.iter().any(|link| matches!(
1018 link,
1019 DivergenceLink::Resolved { seq, unit, .. }
1020 if *seq == resolution.resolved_seq && *unit == divergence.unit
1021 )));
1022 if let Some(selected) = resolution.selected {
1025 assert!((selected as usize) < divergence.candidates.len());
1026 }
1027 }
1028 }
1029 CorpusRecord::Escalation(escalation) => {
1030 if let Some(decision_seq) = escalation.decision_seq {
1031 assert!(
1032 chain.decisions.iter().any(|d| d.seq == decision_seq),
1033 "decision seq {decision_seq} of {escalation:?} must resolve \
1034 to a replayed human decision"
1035 );
1036 }
1037 }
1038 }
1039 }
1040 }
1041
1042 #[test]
1043 fn corpus_export_grant_and_steer_rows_match_the_ledger_fold() {
1044 let tmp = tempfile::TempDir::new().unwrap();
1045 let events = fixture_events();
1046 let records = export(tmp.path(), &events);
1047
1048 let ledger =
1052 crate::escalation_metrics::aggregate(&[(MISSION.to_string(), events.clone())], &[])
1053 .ledger;
1054 let mut from_ledger: Vec<(String, String, Option<u64>)> = ledger
1055 .iter()
1056 .map(|row| (row.ask.clone(), row.decision.clone(), row.latency_ms))
1057 .collect();
1058 let mut from_corpus: Vec<(String, String, Option<u64>)> = records
1059 .iter()
1060 .filter_map(|r| match r {
1061 CorpusRecord::Escalation(e)
1062 if matches!(e.kind, EscalationKind::Grant | EscalationKind::Steer) =>
1063 {
1064 Some((e.ask.clone(), e.decision.clone(), e.latency_ms))
1065 }
1066 _ => None,
1067 })
1068 .collect();
1069 from_ledger.sort();
1070 from_corpus.sort();
1071 assert_eq!(from_corpus, from_ledger);
1072 assert_eq!(from_corpus.len(), 3, "two grants + one steer");
1073 }
1074}