Skip to main content

logicaffeine_compile/
replay.rs

1//! The `.replay` failure artifact.
2//!
3//! A failing run emits a self-contained, human-diffable JSON bundle — seed + full
4//! schedule trace + embedded source + input transcript + failure coordinate — that
5//! reproduces the execution byte-for-byte on any machine. Consumed by
6//! `largo replay --verify`, `largo debug`, the DAP adapter, and the Studio; a
7//! bundle committed under `tests/replays/` is a permanent regression.
8//!
9//! The bundle carries its OWN wire representation of scheduling choices
10//! ([`TraceChoice`]) rather than the runtime's `ChoicePoint`, so the artifact
11//! format outlives internal enum shape.
12
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15
16/// Bundle format version. Bumping it requires a decision-record amendment in the
17/// constitution and a new golden in `replay_lock.rs`.
18pub const REPLAY_FORMAT: u32 = 1;
19
20/// Why the artifact exists. `replay_lock.rs` holds a no-wildcard corpus over
21/// every variant — adding a kind without a corpus entry fails the build there.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum FailureKind {
25    /// The program raised a runtime error (division by zero, bad index, …).
26    RuntimeError,
27    /// A hard `Require that` assertion failed.
28    Assertion,
29    /// The scheduler proved every remaining task is stuck.
30    Deadlock,
31    /// A `## Test` expectation failed (producible from Wave 3's runner).
32    TestFailure,
33}
34
35impl FailureKind {
36    /// Every variant, for exhaustive corpus drills.
37    pub const ALL: [FailureKind; 4] = [
38        FailureKind::RuntimeError,
39        FailureKind::Assertion,
40        FailureKind::Deadlock,
41        FailureKind::TestFailure,
42    ];
43
44    /// Classify a runtime error message into a failure kind. `TestFailure` is
45    /// assigned by the test runner (Wave 3), never inferred from message text.
46    pub fn classify(message: &str) -> FailureKind {
47        if message.contains("deadlock") {
48            FailureKind::Deadlock
49        } else if message.contains("Assertion failed") {
50            FailureKind::Assertion
51        } else {
52            FailureKind::RuntimeError
53        }
54    }
55}
56
57/// The causal cursor (§0.4): `(epoch, task, seq)`. Wave 1 stamps `epoch` (the
58/// count of recorded scheduling decisions at failure) and `task`; the per-task
59/// event `seq` starts moving when the observation channel lands (Wave 7).
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61pub struct Cursor {
62    pub epoch: u64,
63    pub task: u64,
64    pub seq: u64,
65}
66
67/// The embedded program: content hash + entry name + every source file, so the
68/// bundle opens anywhere, forever.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct SourceSection {
71    pub hash: String,
72    pub entry: String,
73    pub files: BTreeMap<String, String>,
74}
75
76/// One recorded scheduling decision (wire form of the runtime's `ChoicePoint`).
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct TraceChoice {
79    pub kind: String,
80    pub options: u32,
81    pub chosen: u32,
82}
83
84/// The schedule: the seed alone regenerates the trace while the source is
85/// unchanged; the full trace makes drift detectable and carries explored
86/// schedules that no seed generates.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct Schedule {
89    pub seed: u64,
90    pub trace: Vec<TraceChoice>,
91}
92
93/// One recorded stdin read, cursor-stamped.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct StdinRead {
96    pub cursor: Cursor,
97    pub data_b64: String,
98}
99
100/// Everything that crossed the world boundary.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct Transcript {
103    pub args: Vec<String>,
104    pub stdin: Vec<StdinRead>,
105}
106
107/// The failing sentence's source coordinates, when the failing tier carries spans.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct FailureSpan {
110    pub file: String,
111    pub start: usize,
112    pub end: usize,
113}
114
115/// Why this artifact exists: the reproduced-or-it-didn't-happen record.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct Failure {
118    pub kind: FailureKind,
119    pub message: String,
120    pub cursor: Cursor,
121    pub span: Option<FailureSpan>,
122    pub test: Option<String>,
123}
124
125/// The `.replay` bundle (constitution §0.5).
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct ReplayBundle {
128    pub format: u32,
129    pub logos_version: String,
130    pub source: SourceSection,
131    pub schedule: Schedule,
132    pub generation: Vec<u64>,
133    pub transcript: Transcript,
134    pub failure: Failure,
135}
136
137/// FNV-1a 64: a tiny, dependency-free, platform-identical content hash. The
138/// bundle hash is a DRIFT DETECTOR (did the program change since recording?),
139/// not a cryptographic commitment.
140fn fnv1a64(bytes: &[u8], mut state: u64) -> u64 {
141    const PRIME: u64 = 0x0000_0100_0000_01B3;
142    for b in bytes {
143        state ^= u64::from(*b);
144        state = state.wrapping_mul(PRIME);
145    }
146    state
147}
148
149impl ReplayBundle {
150    /// Pretty-printed JSON — human-diffable, git-committable.
151    pub fn to_json(&self) -> String {
152        serde_json::to_string_pretty(self).expect("a replay bundle always serializes")
153    }
154
155    /// Parse a bundle, refusing unknown format versions loudly (a future format
156    /// is named in the error, never half-parsed).
157    pub fn from_json(json: &str) -> Result<ReplayBundle, String> {
158        let value: serde_json::Value =
159            serde_json::from_str(json).map_err(|e| format!("unreadable .replay bundle: {e}"))?;
160        let format = value
161            .get("format")
162            .and_then(serde_json::Value::as_u64)
163            .ok_or_else(|| "unreadable .replay bundle: no format field".to_string())?;
164        if format != u64::from(REPLAY_FORMAT) {
165            return Err(format!(
166                "unsupported .replay format {format} — this build reads format {REPLAY_FORMAT}"
167            ));
168        }
169        serde_json::from_value(value).map_err(|e| format!("malformed .replay bundle: {e}"))
170    }
171
172    /// Content hash over the embedded files — deterministic and
173    /// order-independent (the map is ordered; path and content both feed the
174    /// state, length-delimited so boundaries can't alias).
175    pub fn source_hash(files: &BTreeMap<String, String>) -> String {
176        const OFFSET: u64 = 0xCBF2_9CE4_8422_2325;
177        let mut state = OFFSET;
178        for (path, content) in files {
179            state = fnv1a64(&(path.len() as u64).to_le_bytes(), state);
180            state = fnv1a64(path.as_bytes(), state);
181            state = fnv1a64(&(content.len() as u64).to_le_bytes(), state);
182            state = fnv1a64(content.as_bytes(), state);
183        }
184        format!("fnv1a64:{state:016x}")
185    }
186}
187
188/// Run a program on the capture engine — the seeded concurrent VM driver, the
189/// one engine that serves sequential programs, concurrent programs, AND can
190/// prove a deadlock. Capture and verify use the SAME engine, so failure text is
191/// byte-identical by determinism.
192fn run_traced(source: &str, seed: u64) -> (Option<String>, Vec<TraceChoice>) {
193    let (result, trace) = crate::ui_bridge::run_vm_concurrent_traced(source, seed);
194    let choices = trace
195        .choices
196        .iter()
197        .map(|cp| TraceChoice {
198            kind: format!("{:?}", cp.kind),
199            options: cp.options as u32,
200            chosen: cp.chosen as u32,
201        })
202        .collect();
203    (result.error, choices)
204}
205
206/// Run `source` (entry file named `entry`) under `seed` with `args`; if the run
207/// fails, capture the complete artifact. `None` when the program succeeds —
208/// recording costs nothing until the moment of failure (D0.4).
209pub fn capture_failure(
210    entry: &str,
211    source: &str,
212    seed: u64,
213    args: &[String],
214) -> Option<ReplayBundle> {
215    let (error, trace) = run_traced(source, seed);
216    let message = error?;
217    let kind = FailureKind::classify(&message);
218    let epoch = trace.len() as u64;
219    let mut files = BTreeMap::new();
220    files.insert(entry.to_string(), source.to_string());
221    let hash = ReplayBundle::source_hash(&files);
222    Some(ReplayBundle {
223        format: REPLAY_FORMAT,
224        logos_version: env!("CARGO_PKG_VERSION").to_string(),
225        source: SourceSection {
226            hash,
227            entry: entry.to_string(),
228            files,
229        },
230        schedule: Schedule { seed, trace },
231        generation: Vec::new(),
232        transcript: Transcript {
233            args: args.to_vec(),
234            stdin: Vec::new(),
235        },
236        failure: Failure {
237            kind,
238            message,
239            cursor: Cursor {
240                epoch,
241                task: 0,
242                seq: 0,
243            },
244            span: None,
245            test: None,
246        },
247    })
248}
249
250/// The verdict of re-executing a bundle.
251#[derive(Debug, Clone, PartialEq, Eq)]
252pub enum Verify {
253    /// The recorded failure reproduced byte-identically.
254    Reproduced,
255    /// The re-run no longer matches the recording (source drift, schedule
256    /// divergence, different failure) — with the reason.
257    Diverged(String),
258    /// The re-run succeeded: the recorded bug no longer fires.
259    NoFailure,
260}
261
262/// Re-execute the bundle from its embedded source under its recorded seed and
263/// check the failure reproduces byte-identically. Drift is DETECTED and named,
264/// never silently misreplayed.
265pub fn verify(bundle: &ReplayBundle) -> Verify {
266    let recomputed = ReplayBundle::source_hash(&bundle.source.files);
267    if recomputed != bundle.source.hash {
268        return Verify::Diverged(format!(
269            "source hash mismatch: recorded {} but the embedded files hash to {recomputed} \
270             — the bundle was edited since recording",
271            bundle.source.hash
272        ));
273    }
274    let Some(source) = bundle.source.files.get(&bundle.source.entry) else {
275        return Verify::Diverged(format!(
276            "the entry file {} is missing from the bundle",
277            bundle.source.entry
278        ));
279    };
280    let (error, live_trace) = run_traced(source, bundle.schedule.seed);
281    let Some(message) = error else {
282        return Verify::NoFailure;
283    };
284    if live_trace != bundle.schedule.trace {
285        let at = live_trace
286            .iter()
287            .zip(bundle.schedule.trace.iter())
288            .position(|(live, recorded)| live != recorded)
289            .unwrap_or_else(|| live_trace.len().min(bundle.schedule.trace.len()));
290        return Verify::Diverged(format!(
291            "schedule diverged at decision {at}: recorded {} decisions, live run made {}",
292            bundle.schedule.trace.len(),
293            live_trace.len()
294        ));
295    }
296    if message != bundle.failure.message {
297        return Verify::Diverged(format!(
298            "failure message changed: recorded {:?}, live {message:?}",
299            bundle.failure.message
300        ));
301    }
302    let live_kind = FailureKind::classify(&message);
303    if live_kind != bundle.failure.kind {
304        return Verify::Diverged(format!(
305            "failure kind changed: recorded {:?}, live {live_kind:?}",
306            bundle.failure.kind
307        ));
308    }
309    let live_epoch = live_trace.len() as u64;
310    if live_epoch != bundle.failure.cursor.epoch {
311        return Verify::Diverged(format!(
312            "failure cursor moved: recorded epoch {}, live epoch {live_epoch}",
313            bundle.failure.cursor.epoch
314        ));
315    }
316    Verify::Reproduced
317}