Skip to main content

harn_vm/testbench/
fidelity.rs

1//! Replay fidelity oracle.
2//!
3//! Compares two [`EventTape`]s and emits a structured [`FidelityReport`]
4//! listing every diverging record. The oracle drives the
5//! `harn test-bench fidelity` CLI subcommand and is the artifact behind
6//! [harn-cloud#19][cloud19]'s "byte-for-byte vs. semantic" leaderboard
7//! metric.
8//!
9//! [cloud19]: https://github.com/burin-labs/harn-cloud/issues/19
10//!
11//! # Modes
12//!
13//! - **`ByteIdentical`** (strictest). Every record must match position,
14//!   kind, and content hash. The mode CI uses to gate "this PR did not
15//!   regress replay determinism".
16//!
17//! - **`Semantic`**. Ignores diffs that are non-meaningful by
18//!   construction: monotonic-only sequence numbers, virtual-time stamps
19//!   that drift only because of paused-clock rounding, and recorded
20//!   `monotonic_ms` deltas. Content hashes still gate every payload, so
21//!   anything user-visible must still match.
22//!
23//! - **`Outcome`** (loosest). Compares only the script's externally
24//!   observable result: the final FS write set (overlay diff), the exit
25//!   status of the last subprocess, and the count of LLM calls. Useful
26//!   for stochastic LLM runs where intermediate token streams legitimately
27//!   diverge between record and replay.
28//!
29//! - **`PhaseAware`**. Compares `UserScript` records byte-identically and
30//!   `RuntimeFinalize` records semantically, while ignoring
31//!   runtime-finalize clock reads. This keeps user-script replay fixtures
32//!   strict without making internal lifecycle observability changes
33//!   regenerate every golden tape.
34
35use std::collections::BTreeMap;
36
37use serde::{Deserialize, Serialize};
38
39use super::tape::{EventTape, TapePhase, TapeRecord, TapeRecordKind};
40
41/// Strictness of the comparison. The CLI surface picks one.
42#[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/// Structured comparison result. Emitted as JSON by the CLI; consumed by
68/// CI gates and by the public leaderboard.
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
70pub struct FidelityReport {
71    pub mode: FidelityMode,
72    /// Number of records compared on the recorded side. Lets a consumer
73    /// distinguish "100% match over 0 records" from "100% over 1000".
74    pub recorded_records: usize,
75    /// Number of records compared on the replay side.
76    pub replay_records: usize,
77    /// Records where one side had an event the other did not, or where
78    /// the same-position record had different content. Stable order so
79    /// CI snapshots are deterministic.
80    pub divergences: Vec<Divergence>,
81    /// 0.0–1.0. Defined as `1 - (divergences / max(records, 1))` for
82    /// strict modes; in outcome mode it's `1.0` iff zero divergences.
83    pub score: f32,
84}
85
86impl FidelityReport {
87    pub fn is_byte_identical(&self) -> bool {
88        self.divergences.is_empty()
89    }
90}
91
92/// Single divergence between two tapes. Records are paired by position
93/// for byte-identical / semantic modes; outcome mode emits one
94/// of these per outcome facet that disagreed.
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
96pub struct Divergence {
97    /// Logical sequence number of the diverging record (`None` when the
98    /// divergence is an outcome facet, e.g. exit code).
99    pub seq: Option<u64>,
100    /// Short tag used by tooling to group divergences (`clock_sleep`,
101    /// `process_stdout_hash`, `outcome_exit_code`, …).
102    pub category: String,
103    /// Human-readable explanation. Stable phrasing — CI snapshot tests
104    /// match on substrings.
105    pub message: String,
106}
107
108/// Compare two tapes under `mode`.
109pub 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        // Kind tag matched at the top of `compare_pair`; reaching here
571        // means a new variant was added without extending this match.
572        // Surface a structured divergence rather than panicking so older
573        // CI runners stay functional after a tape-format upgrade.
574        _ => 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        // Acceptance criterion: deliberately introduce a wall-clock
754        // divergence and confirm the oracle flags it.
755        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)); // virtual time drifted by 1ms
783        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        // Both end with the same final FS state but take different paths.
842        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}