1use std::collections::BTreeMap;
36
37use serde::{Deserialize, Serialize};
38
39use super::tape::{EventTape, TapePhase, TapeRecord, TapeRecordKind};
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum FidelityMode {
45 ByteIdentical,
46 Semantic,
47 Outcome,
48 PhaseAware,
49}
50
51impl FidelityMode {
52 pub fn parse(label: &str) -> Result<Self, String> {
53 match label {
54 "byte" | "byte-identical" | "byte_identical" => Ok(Self::ByteIdentical),
55 "semantic" => Ok(Self::Semantic),
56 "outcome" => Ok(Self::Outcome),
57 "phase-aware" | "phase_aware" | "byte-user-semantic-runtime" => {
58 Ok(Self::PhaseAware)
59 }
60 other => Err(format!(
61 "unknown fidelity mode `{other}` — expected `byte-identical`, `semantic`, `outcome`, or `phase-aware`"
62 )),
63 }
64 }
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
70pub struct FidelityReport {
71 pub mode: FidelityMode,
72 pub recorded_records: usize,
75 pub replay_records: usize,
77 pub divergences: Vec<Divergence>,
81 pub score: f32,
84}
85
86impl FidelityReport {
87 pub fn is_byte_identical(&self) -> bool {
88 self.divergences.is_empty()
89 }
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
96pub struct Divergence {
97 pub seq: Option<u64>,
100 pub category: String,
103 pub message: String,
106}
107
108pub fn compare(recorded: &EventTape, replay: &EventTape, mode: FidelityMode) -> FidelityReport {
110 let divergences = match mode {
111 FidelityMode::ByteIdentical => compare_record_by_record(recorded, replay, true),
112 FidelityMode::Semantic => compare_record_by_record(recorded, replay, false),
113 FidelityMode::Outcome => compare_outcome(recorded, replay),
114 FidelityMode::PhaseAware => compare_phase_aware(recorded, replay),
115 };
116 let baseline = recorded.records.len().max(replay.records.len()).max(1);
117 let score = match mode {
118 FidelityMode::ByteIdentical | FidelityMode::Semantic | FidelityMode::PhaseAware => {
119 1.0 - (divergences.len() as f32 / baseline as f32).min(1.0)
120 }
121 FidelityMode::Outcome => {
122 if divergences.is_empty() {
123 1.0
124 } else {
125 0.0
126 }
127 }
128 };
129 FidelityReport {
130 mode,
131 recorded_records: recorded.records.len(),
132 replay_records: replay.records.len(),
133 divergences,
134 score,
135 }
136}
137
138fn compare_phase_aware(recorded: &EventTape, replay: &EventTape) -> Vec<Divergence> {
139 let mut out = Vec::new();
140 out.extend(compare_record_slices(
141 &phase_records(recorded, TapePhase::UserScript, false),
142 &phase_records(replay, TapePhase::UserScript, false),
143 true,
144 ));
145 out.extend(compare_record_slices(
146 &phase_records(recorded, TapePhase::RuntimeFinalize, true),
147 &phase_records(replay, TapePhase::RuntimeFinalize, true),
148 false,
149 ));
150 out
151}
152
153fn phase_records(
154 tape: &EventTape,
155 phase: TapePhase,
156 omit_runtime_clock_reads: bool,
157) -> Vec<TapeRecord> {
158 tape.records
159 .iter()
160 .filter(|record| {
161 record.phase == phase
162 && !(omit_runtime_clock_reads
163 && matches!(record.kind, TapeRecordKind::ClockRead { .. }))
164 })
165 .cloned()
166 .collect()
167}
168
169fn compare_record_by_record(
170 recorded: &EventTape,
171 replay: &EventTape,
172 byte_strict: bool,
173) -> Vec<Divergence> {
174 compare_record_slices(&recorded.records, &replay.records, byte_strict)
175}
176
177fn compare_record_slices(
178 recorded: &[TapeRecord],
179 replay: &[TapeRecord],
180 byte_strict: bool,
181) -> Vec<Divergence> {
182 let mut out = Vec::new();
183 let max = recorded.len().max(replay.len());
184 for idx in 0..max {
185 match (recorded.get(idx), replay.get(idx)) {
186 (Some(rec), Some(rep)) => compare_pair(rec, rep, byte_strict, &mut out),
187 (Some(rec), None) => out.push(Divergence {
188 seq: Some(rec.seq),
189 category: "missing_in_replay".to_string(),
190 message: format!(
191 "replay tape ended at #{idx}; recorded had {} more record(s)",
192 recorded.len() - idx
193 ),
194 }),
195 (None, Some(rep)) => out.push(Divergence {
196 seq: Some(rep.seq),
197 category: "missing_in_recorded".to_string(),
198 message: format!(
199 "replay produced an extra record at #{idx} (kind={})",
200 record_kind_tag(&rep.kind)
201 ),
202 }),
203 (None, None) => break,
204 }
205 }
206 out
207}
208
209fn compare_pair(
210 recorded: &TapeRecord,
211 replay: &TapeRecord,
212 byte_strict: bool,
213 out: &mut Vec<Divergence>,
214) {
215 if record_kind_tag(&recorded.kind) != record_kind_tag(&replay.kind) {
216 out.push(Divergence {
217 seq: Some(recorded.seq),
218 category: "kind_mismatch".to_string(),
219 message: format!(
220 "record kind diverged: recorded={} replay={}",
221 record_kind_tag(&recorded.kind),
222 record_kind_tag(&replay.kind),
223 ),
224 });
225 return;
226 }
227 if byte_strict && recorded.phase != replay.phase {
228 out.push(Divergence {
229 seq: Some(recorded.seq),
230 category: "phase_mismatch".to_string(),
231 message: format!(
232 "record phase diverged: recorded={} replay={}",
233 recorded.phase.label(),
234 replay.phase.label(),
235 ),
236 });
237 }
238 if byte_strict && recorded.virtual_time_ms != replay.virtual_time_ms {
239 out.push(Divergence {
240 seq: Some(recorded.seq),
241 category: "virtual_time_drift".to_string(),
242 message: format!(
243 "virtual_time_ms diverged: recorded={} replay={}",
244 recorded.virtual_time_ms, replay.virtual_time_ms,
245 ),
246 });
247 }
248 if byte_strict && recorded.monotonic_ms != replay.monotonic_ms {
249 out.push(Divergence {
250 seq: Some(recorded.seq),
251 category: "monotonic_drift".to_string(),
252 message: format!(
253 "monotonic_ms diverged: recorded={} replay={}",
254 recorded.monotonic_ms, replay.monotonic_ms,
255 ),
256 });
257 }
258 compare_kind(&recorded.kind, &replay.kind, recorded.seq, byte_strict, out);
259}
260
261fn compare_kind(
262 recorded: &TapeRecordKind,
263 replay: &TapeRecordKind,
264 seq: u64,
265 byte_strict: bool,
266 out: &mut Vec<Divergence>,
267) {
268 use TapeRecordKind::*;
269 match (recorded, replay) {
270 (
271 ClockRead {
272 source: r_source,
273 value_ms: r_val,
274 },
275 ClockRead {
276 source: p_source,
277 value_ms: p_val,
278 },
279 ) => {
280 if r_source != p_source {
281 out.push(Divergence {
282 seq: Some(seq),
283 category: "clock_read_source".to_string(),
284 message: format!(
285 "clock_read source diverged: recorded={r_source:?} replay={p_source:?}"
286 ),
287 });
288 }
289 if r_val != p_val {
290 out.push(Divergence {
291 seq: Some(seq),
292 category: "clock_read_value".to_string(),
293 message: format!(
294 "clock_read value_ms diverged: recorded={r_val} replay={p_val}"
295 ),
296 });
297 }
298 }
299 (
300 ClockSleep {
301 duration_ms: recorded_dur,
302 },
303 ClockSleep {
304 duration_ms: replay_dur,
305 },
306 ) => {
307 if recorded_dur != replay_dur {
308 out.push(Divergence {
309 seq: Some(seq),
310 category: "clock_sleep_duration".to_string(),
311 message: format!(
312 "sleep duration diverged: recorded={recorded_dur}ms replay={replay_dur}ms"
313 ),
314 });
315 }
316 }
317 (
318 LlmCall {
319 request_digest: recorded_req,
320 response: recorded_res,
321 },
322 LlmCall {
323 request_digest: replay_req,
324 response: replay_res,
325 },
326 ) => {
327 if recorded_req != replay_req {
328 out.push(Divergence {
329 seq: Some(seq),
330 category: "llm_request_digest".to_string(),
331 message: format!(
332 "LLM request digest diverged: recorded={recorded_req} replay={replay_req}"
333 ),
334 });
335 }
336 if recorded_res.content_hash() != replay_res.content_hash() {
337 out.push(Divergence {
338 seq: Some(seq),
339 category: "llm_response_hash".to_string(),
340 message: format!(
341 "LLM response hash diverged: recorded={} replay={}",
342 recorded_res.content_hash(),
343 replay_res.content_hash(),
344 ),
345 });
346 }
347 }
348 (
349 FileRead {
350 path: rp,
351 content_hash: rh,
352 len_bytes: rl,
353 },
354 FileRead {
355 path: pp,
356 content_hash: ph,
357 len_bytes: pl,
358 },
359 ) => compare_file(seq, "file_read", rp, rh, *rl, pp, ph, *pl, byte_strict, out),
360 (
361 FileWrite {
362 path: rp,
363 content_hash: rh,
364 len_bytes: rl,
365 },
366 FileWrite {
367 path: pp,
368 content_hash: ph,
369 len_bytes: pl,
370 },
371 ) => compare_file(
372 seq,
373 "file_write",
374 rp,
375 rh,
376 *rl,
377 pp,
378 ph,
379 *pl,
380 byte_strict,
381 out,
382 ),
383 (FileDelete { path: rp }, FileDelete { path: pp }) => {
384 if rp != pp {
385 out.push(Divergence {
386 seq: Some(seq),
387 category: "file_delete_path".to_string(),
388 message: format!("file_delete path diverged: recorded={rp} replay={pp}"),
389 });
390 }
391 }
392 (
393 ProcessSpawn {
394 program: r_program,
395 args: r_args,
396 cwd: r_cwd,
397 exit_code: r_exit,
398 duration_ms: r_dur,
399 stdout_payload: r_stdout,
400 stderr_payload: r_stderr,
401 },
402 ProcessSpawn {
403 program: p_program,
404 args: p_args,
405 cwd: p_cwd,
406 exit_code: p_exit,
407 duration_ms: p_dur,
408 stdout_payload: p_stdout,
409 stderr_payload: p_stderr,
410 },
411 ) => {
412 if r_program != p_program {
413 out.push(Divergence {
414 seq: Some(seq),
415 category: "process_program".to_string(),
416 message: format!(
417 "subprocess program diverged: recorded={r_program} replay={p_program}"
418 ),
419 });
420 }
421 if r_args != p_args {
422 out.push(Divergence {
423 seq: Some(seq),
424 category: "process_args".to_string(),
425 message: format!(
426 "subprocess args diverged: recorded={r_args:?} replay={p_args:?}"
427 ),
428 });
429 }
430 if r_cwd != p_cwd {
431 out.push(Divergence {
432 seq: Some(seq),
433 category: "process_cwd".to_string(),
434 message: format!(
435 "subprocess cwd diverged: recorded={r_cwd:?} replay={p_cwd:?}"
436 ),
437 });
438 }
439 if r_exit != p_exit {
440 out.push(Divergence {
441 seq: Some(seq),
442 category: "process_exit_code".to_string(),
443 message: format!(
444 "subprocess exit code diverged: recorded={r_exit} replay={p_exit}"
445 ),
446 });
447 }
448 if byte_strict && r_dur != p_dur {
449 out.push(Divergence {
450 seq: Some(seq),
451 category: "process_duration".to_string(),
452 message: format!(
453 "subprocess duration diverged: recorded={r_dur}ms replay={p_dur}ms"
454 ),
455 });
456 }
457 if r_stdout.content_hash() != p_stdout.content_hash() {
458 out.push(Divergence {
459 seq: Some(seq),
460 category: "process_stdout_hash".to_string(),
461 message: format!(
462 "subprocess stdout hash diverged: recorded={} replay={}",
463 r_stdout.content_hash(),
464 p_stdout.content_hash(),
465 ),
466 });
467 }
468 if r_stderr.content_hash() != p_stderr.content_hash() {
469 out.push(Divergence {
470 seq: Some(seq),
471 category: "process_stderr_hash".to_string(),
472 message: format!(
473 "subprocess stderr hash diverged: recorded={} replay={}",
474 r_stderr.content_hash(),
475 p_stderr.content_hash(),
476 ),
477 });
478 }
479 }
480 (
481 McpJsonRpc {
482 server: r_server,
483 method: r_method,
484 request_digest: r_request_digest,
485 response_digest: r_response_digest,
486 latency_ms: r_latency,
487 request_payload: r_request_payload,
488 response_payload: r_response_payload,
489 },
490 McpJsonRpc {
491 server: p_server,
492 method: p_method,
493 request_digest: p_request_digest,
494 response_digest: p_response_digest,
495 latency_ms: p_latency,
496 request_payload: p_request_payload,
497 response_payload: p_response_payload,
498 },
499 ) => {
500 if r_server != p_server {
501 out.push(Divergence {
502 seq: Some(seq),
503 category: "mcp_server".to_string(),
504 message: format!("MCP server diverged: recorded={r_server} replay={p_server}"),
505 });
506 }
507 if r_method != p_method {
508 out.push(Divergence {
509 seq: Some(seq),
510 category: "mcp_method".to_string(),
511 message: format!("MCP method diverged: recorded={r_method} replay={p_method}"),
512 });
513 }
514 if r_request_digest != p_request_digest {
515 out.push(Divergence {
516 seq: Some(seq),
517 category: "mcp_request_digest".to_string(),
518 message: format!(
519 "MCP request digest diverged: recorded={r_request_digest} replay={p_request_digest}"
520 ),
521 });
522 }
523 if r_response_digest != p_response_digest {
524 out.push(Divergence {
525 seq: Some(seq),
526 category: "mcp_response_digest".to_string(),
527 message: format!(
528 "MCP response digest diverged: recorded={r_response_digest} replay={p_response_digest}"
529 ),
530 });
531 }
532 if byte_strict && r_latency != p_latency {
533 out.push(Divergence {
534 seq: Some(seq),
535 category: "mcp_latency".to_string(),
536 message: format!(
537 "MCP latency diverged: recorded={r_latency}ms replay={p_latency}ms"
538 ),
539 });
540 }
541 if r_request_payload.content_hash() != p_request_payload.content_hash() {
542 out.push(Divergence {
543 seq: Some(seq),
544 category: "mcp_request_hash".to_string(),
545 message: format!(
546 "MCP request hash diverged: recorded={} replay={}",
547 r_request_payload.content_hash(),
548 p_request_payload.content_hash(),
549 ),
550 });
551 }
552 if r_response_payload.content_hash() != p_response_payload.content_hash() {
553 out.push(Divergence {
554 seq: Some(seq),
555 category: "mcp_response_hash".to_string(),
556 message: format!(
557 "MCP response hash diverged: recorded={} replay={}",
558 r_response_payload.content_hash(),
559 p_response_payload.content_hash(),
560 ),
561 });
562 }
563 }
564 (Unknown, _) | (_, Unknown) => out.push(Divergence {
565 seq: Some(seq),
566 category: "unknown_kind".to_string(),
567 message: "encountered an unknown record kind — produced by a newer harn-vm version"
568 .to_string(),
569 }),
570 _ => out.push(Divergence {
575 seq: Some(seq),
576 category: "comparator_gap".to_string(),
577 message: format!(
578 "no comparator wired for record kind `{}`",
579 record_kind_tag(recorded)
580 ),
581 }),
582 }
583}
584
585#[allow(clippy::too_many_arguments)]
586fn compare_file(
587 seq: u64,
588 category: &str,
589 recorded_path: &str,
590 recorded_hash: &str,
591 recorded_len: u64,
592 replay_path: &str,
593 replay_hash: &str,
594 replay_len: u64,
595 byte_strict: bool,
596 out: &mut Vec<Divergence>,
597) {
598 if recorded_path != replay_path {
599 out.push(Divergence {
600 seq: Some(seq),
601 category: format!("{category}_path"),
602 message: format!(
603 "{category} path diverged: recorded={recorded_path} replay={replay_path}"
604 ),
605 });
606 }
607 if recorded_hash != replay_hash {
608 out.push(Divergence {
609 seq: Some(seq),
610 category: format!("{category}_hash"),
611 message: format!(
612 "{category} content hash diverged: recorded={recorded_hash} replay={replay_hash}"
613 ),
614 });
615 }
616 if byte_strict && recorded_len != replay_len {
617 out.push(Divergence {
618 seq: Some(seq),
619 category: format!("{category}_len"),
620 message: format!(
621 "{category} length diverged: recorded={recorded_len} replay={replay_len}"
622 ),
623 });
624 }
625}
626
627fn compare_outcome(recorded: &EventTape, replay: &EventTape) -> Vec<Divergence> {
628 let mut out = Vec::new();
629
630 let recorded_writes = collect_final_writes(recorded);
631 let replay_writes = collect_final_writes(replay);
632 if recorded_writes != replay_writes {
633 let recorded_paths: Vec<&String> = recorded_writes.keys().collect();
634 let replay_paths: Vec<&String> = replay_writes.keys().collect();
635 out.push(Divergence {
636 seq: None,
637 category: "outcome_fs_diff".to_string(),
638 message: format!(
639 "final FS write set diverged: recorded={recorded_paths:?} replay={replay_paths:?}"
640 ),
641 });
642 }
643
644 let recorded_exit = last_process_exit(recorded);
645 let replay_exit = last_process_exit(replay);
646 if recorded_exit != replay_exit {
647 out.push(Divergence {
648 seq: None,
649 category: "outcome_exit_code".to_string(),
650 message: format!(
651 "last subprocess exit code diverged: recorded={recorded_exit:?} replay={replay_exit:?}"
652 ),
653 });
654 }
655
656 let recorded_llm = count_llm_calls(recorded);
657 let replay_llm = count_llm_calls(replay);
658 if recorded_llm != replay_llm {
659 out.push(Divergence {
660 seq: None,
661 category: "outcome_llm_call_count".to_string(),
662 message: format!(
663 "LLM call count diverged: recorded={recorded_llm} replay={replay_llm}"
664 ),
665 });
666 }
667 out
668}
669
670fn collect_final_writes(tape: &EventTape) -> BTreeMap<String, Option<String>> {
671 let mut state: BTreeMap<String, Option<String>> = BTreeMap::new();
672 for record in &tape.records {
673 match &record.kind {
674 TapeRecordKind::FileWrite {
675 path, content_hash, ..
676 } => {
677 state.insert(path.clone(), Some(content_hash.clone()));
678 }
679 TapeRecordKind::FileDelete { path } => {
680 state.insert(path.clone(), None);
681 }
682 _ => {}
683 }
684 }
685 state
686}
687
688fn last_process_exit(tape: &EventTape) -> Option<i32> {
689 tape.records
690 .iter()
691 .rev()
692 .find_map(|record| match &record.kind {
693 TapeRecordKind::ProcessSpawn { exit_code, .. } => Some(*exit_code),
694 _ => None,
695 })
696}
697
698fn count_llm_calls(tape: &EventTape) -> usize {
699 tape.records
700 .iter()
701 .filter(|record| matches!(record.kind, TapeRecordKind::LlmCall { .. }))
702 .count()
703}
704
705fn record_kind_tag(kind: &TapeRecordKind) -> &'static str {
706 match kind {
707 TapeRecordKind::ClockRead { .. } => "clock_read",
708 TapeRecordKind::ClockSleep { .. } => "clock_sleep",
709 TapeRecordKind::LlmCall { .. } => "llm_call",
710 TapeRecordKind::FileRead { .. } => "file_read",
711 TapeRecordKind::FileWrite { .. } => "file_write",
712 TapeRecordKind::FileDelete { .. } => "file_delete",
713 TapeRecordKind::ProcessSpawn { .. } => "process_spawn",
714 TapeRecordKind::McpJsonRpc { .. } => "mcp_json_rpc",
715 TapeRecordKind::Unknown => "unknown",
716 }
717}
718
719#[cfg(test)]
720mod tests {
721 use super::*;
722 use crate::testbench::tape::{ClockSource, TapeHeader, TapePayload, TapeRecord};
723
724 fn empty_tape() -> EventTape {
725 EventTape::new(TapeHeader::current(None, None, Vec::new()))
726 }
727
728 fn record(seq: u64, kind: TapeRecordKind) -> TapeRecord {
729 TapeRecord {
730 seq,
731 phase: TapePhase::UserScript,
732 virtual_time_ms: 0,
733 monotonic_ms: 0,
734 kind,
735 }
736 }
737
738 #[test]
739 fn byte_identical_matches_when_records_align() {
740 let mut a = empty_tape();
741 let mut b = empty_tape();
742 a.records
743 .push(record(0, TapeRecordKind::ClockSleep { duration_ms: 5 }));
744 b.records
745 .push(record(0, TapeRecordKind::ClockSleep { duration_ms: 5 }));
746 let report = compare(&a, &b, FidelityMode::ByteIdentical);
747 assert!(report.is_byte_identical(), "{report:?}");
748 assert_eq!(report.score, 1.0);
749 }
750
751 #[test]
752 fn byte_identical_flags_a_drifted_clock_read() {
753 let mut a = empty_tape();
756 let mut b = empty_tape();
757 a.records
758 .push(record(0, TapeRecordKind::ClockSleep { duration_ms: 5 }));
759 b.records
760 .push(record(0, TapeRecordKind::ClockSleep { duration_ms: 7 }));
761 let report = compare(&a, &b, FidelityMode::ByteIdentical);
762 assert_eq!(report.divergences.len(), 1);
763 assert_eq!(report.divergences[0].category, "clock_sleep_duration");
764 }
765
766 #[test]
767 fn semantic_mode_ignores_pure_timing_drift() {
768 let mut a = empty_tape();
769 let mut b = empty_tape();
770 let make = |seq: u64, vt: i64| TapeRecord {
771 seq,
772 phase: TapePhase::UserScript,
773 virtual_time_ms: vt,
774 monotonic_ms: vt,
775 kind: TapeRecordKind::FileWrite {
776 path: "/tmp/out.txt".to_string(),
777 content_hash: "abc".to_string(),
778 len_bytes: 3,
779 },
780 };
781 a.records.push(make(0, 0));
782 b.records.push(make(0, 1)); let strict = compare(&a, &b, FidelityMode::ByteIdentical);
784 assert!(!strict.is_byte_identical());
785 let semantic = compare(&a, &b, FidelityMode::Semantic);
786 assert!(
787 semantic.is_byte_identical(),
788 "semantic should not flag pure timing drift, got {semantic:?}"
789 );
790 }
791
792 #[test]
793 fn mcp_json_rpc_records_compare_schema_and_response_digests() {
794 let mut a = empty_tape();
795 let mut b = empty_tape();
796 let request_payload = TapePayload::Inline {
797 content_hash: "request-hash".to_string(),
798 text: "{}".to_string(),
799 };
800 a.records.push(record(
801 0,
802 TapeRecordKind::McpJsonRpc {
803 server: "github".to_string(),
804 method: "tools/list".to_string(),
805 request_digest: "request".to_string(),
806 response_digest: "schema-a".to_string(),
807 latency_ms: 1,
808 request_payload: request_payload.clone(),
809 response_payload: TapePayload::Inline {
810 content_hash: "schema-a".to_string(),
811 text: "{}".to_string(),
812 },
813 },
814 ));
815 b.records.push(record(
816 0,
817 TapeRecordKind::McpJsonRpc {
818 server: "github".to_string(),
819 method: "tools/list".to_string(),
820 request_digest: "request".to_string(),
821 response_digest: "schema-b".to_string(),
822 latency_ms: 1,
823 request_payload,
824 response_payload: TapePayload::Inline {
825 content_hash: "schema-b".to_string(),
826 text: "{}".to_string(),
827 },
828 },
829 ));
830 let report = compare(&a, &b, FidelityMode::Semantic);
831 assert!(report
832 .divergences
833 .iter()
834 .any(|divergence| divergence.category == "mcp_response_digest"));
835 }
836
837 #[test]
838 fn outcome_mode_only_compares_final_writes_and_exit() {
839 let mut a = empty_tape();
840 let mut b = empty_tape();
841 a.records.push(record(
843 0,
844 TapeRecordKind::FileWrite {
845 path: "/tmp/a".to_string(),
846 content_hash: "h1".to_string(),
847 len_bytes: 1,
848 },
849 ));
850 a.records
851 .push(record(1, TapeRecordKind::ClockSleep { duration_ms: 1000 }));
852 b.records
853 .push(record(0, TapeRecordKind::ClockSleep { duration_ms: 50 }));
854 b.records.push(record(
855 1,
856 TapeRecordKind::FileWrite {
857 path: "/tmp/a".to_string(),
858 content_hash: "h1".to_string(),
859 len_bytes: 1,
860 },
861 ));
862 let report = compare(&a, &b, FidelityMode::Outcome);
863 assert!(
864 report.divergences.is_empty(),
865 "outcome mode should ignore intermediate diffs, got {report:?}"
866 );
867 assert_eq!(report.score, 1.0);
868 }
869
870 #[test]
871 fn outcome_mode_flags_exit_code_drift() {
872 let mut a = empty_tape();
873 let mut b = empty_tape();
874 let payload = TapePayload::Inline {
875 content_hash: "ehash".to_string(),
876 text: String::new(),
877 };
878 a.records.push(record(
879 0,
880 TapeRecordKind::ProcessSpawn {
881 program: "git".to_string(),
882 args: Vec::new(),
883 cwd: None,
884 exit_code: 0,
885 duration_ms: 1,
886 stdout_payload: payload.clone(),
887 stderr_payload: payload.clone(),
888 },
889 ));
890 b.records.push(record(
891 0,
892 TapeRecordKind::ProcessSpawn {
893 program: "git".to_string(),
894 args: Vec::new(),
895 cwd: None,
896 exit_code: 1,
897 duration_ms: 1,
898 stdout_payload: payload.clone(),
899 stderr_payload: payload,
900 },
901 ));
902 let report = compare(&a, &b, FidelityMode::Outcome);
903 assert_eq!(report.divergences.len(), 1);
904 assert_eq!(report.divergences[0].category, "outcome_exit_code");
905 }
906
907 #[test]
908 fn phase_aware_ignores_extra_runtime_finalize_clock_reads() {
909 let mut recorded = empty_tape();
910 let mut replay = empty_tape();
911 recorded
912 .records
913 .push(record(0, TapeRecordKind::ClockSleep { duration_ms: 5 }));
914 replay
915 .records
916 .push(record(0, TapeRecordKind::ClockSleep { duration_ms: 5 }));
917
918 let mut finalize_read = record(
919 1,
920 TapeRecordKind::ClockRead {
921 source: ClockSource::Wall,
922 value_ms: 123,
923 },
924 );
925 finalize_read.phase = TapePhase::RuntimeFinalize;
926 replay.records.push(finalize_read);
927
928 let strict = compare(&recorded, &replay, FidelityMode::ByteIdentical);
929 assert_eq!(strict.divergences[0].category, "missing_in_recorded");
930
931 let phase_aware = compare(&recorded, &replay, FidelityMode::PhaseAware);
932 assert!(
933 phase_aware.is_byte_identical(),
934 "runtime finalize clock reads should not force tape regeneration: {phase_aware:?}"
935 );
936 }
937
938 #[test]
939 fn phase_aware_flags_extra_user_script_clock_reads() {
940 let mut recorded = empty_tape();
941 let mut replay = empty_tape();
942 recorded
943 .records
944 .push(record(0, TapeRecordKind::ClockSleep { duration_ms: 5 }));
945 replay
946 .records
947 .push(record(0, TapeRecordKind::ClockSleep { duration_ms: 5 }));
948 replay.records.push(record(
949 1,
950 TapeRecordKind::ClockRead {
951 source: ClockSource::Wall,
952 value_ms: 123,
953 },
954 ));
955
956 let report = compare(&recorded, &replay, FidelityMode::PhaseAware);
957 assert_eq!(report.divergences.len(), 1);
958 assert_eq!(report.divergences[0].category, "missing_in_recorded");
959 }
960
961 #[test]
962 fn phase_aware_flags_runtime_finalize_effect_drift() {
963 let recorded = empty_tape();
964 let mut replay = empty_tape();
965 let mut replay_write = record(
966 0,
967 TapeRecordKind::FileWrite {
968 path: "/tmp/finalized".to_string(),
969 content_hash: "abc".to_string(),
970 len_bytes: 3,
971 },
972 );
973 replay_write.phase = TapePhase::RuntimeFinalize;
974 replay.records.push(replay_write);
975
976 let report = compare(&recorded, &replay, FidelityMode::PhaseAware);
977 assert_eq!(report.divergences.len(), 1);
978 assert_eq!(report.divergences[0].category, "missing_in_recorded");
979 }
980
981 #[test]
982 fn parse_mode_accepts_aliases() {
983 assert_eq!(
984 FidelityMode::parse("byte").unwrap(),
985 FidelityMode::ByteIdentical
986 );
987 assert_eq!(
988 FidelityMode::parse("byte-identical").unwrap(),
989 FidelityMode::ByteIdentical
990 );
991 assert_eq!(
992 FidelityMode::parse("semantic").unwrap(),
993 FidelityMode::Semantic
994 );
995 assert_eq!(
996 FidelityMode::parse("outcome").unwrap(),
997 FidelityMode::Outcome
998 );
999 assert_eq!(
1000 FidelityMode::parse("phase-aware").unwrap(),
1001 FidelityMode::PhaseAware
1002 );
1003 assert!(FidelityMode::parse("nope").is_err());
1004 }
1005}