Skip to main content

amberfork_attrib/
driver.rs

1//! The re-execution driver: run the patched cassette once and judge the result.
2//!
3//! Where [`patch_cassette`](crate::patch_cassette) builds the counterfactual and
4//! [`classify_recovery`](crate::oracle::classify_recovery) judges a re-run, this stage is the I/O
5//! edge between them: stand up a [`ReplayServer`] over the patched cassette, drive the agent
6//! against its loopback URL, read the re-executed tape back, normalize it, and hand it to the
7//! oracle. It is the one place in counterfactual attribution that reaches the network — and only
8//! past the patch, where the re-run has branched off the tape.
9//!
10//! ## The agent is a seam, so the test stays offline
11//!
12//! Re-driving the agent is abstracted behind [`AgentDriver`]. Production spawns the recorded
13//! `-- <cmd>` subprocess with its base-URL env var pointed at the listener — exactly how
14//! `amberfork record` drove it the first time (that impl lands with the CLI). The test substitutes
15//! an in-process agent that speaks HTTP to the same listener, so `cargo test` exercises the real
16//! server, normalize, and oracle without a provider or a subprocess. This mirrors how
17//! [`Upstream`]/`ScriptedUpstream` already keep the replay crate's tests offline.
18
19use amberfork_align::DiffParams;
20use amberfork_model::Run;
21use amberfork_record::normalize;
22use amberfork_replay::{ReplayError, ReplayServer, Upstream};
23use std::fmt;
24use std::future::Future;
25
26use crate::oracle::{RunVerdict, classify_recovery};
27
28/// Drives the agent under test once, pointed at a base URL, so a re-execution can capture what it
29/// does.
30///
31/// The counterfactual analog of [`Upstream`]: a native async trait (return-position `impl Future`,
32/// not the `async-trait` crate), so the driver is chosen at compile time — an in-process stub in
33/// tests, a spawned subprocess in production — with no `dyn` and no per-run allocation.
34pub trait AgentDriver: Send + Sync {
35    /// Drive the agent once against `base_url`, returning when it has finished.
36    ///
37    /// The agent's own exit code is deliberately *not* a failure here: a run that exits non-zero
38    /// is often exactly the run being re-executed, and its trajectory is what matters, not its
39    /// verdict. [`AgentError`] is reserved for not being able to run the agent at all.
40    ///
41    /// # Errors
42    ///
43    /// Returns [`AgentError`] when the agent cannot be launched (for the subprocess driver, the
44    /// command could not be spawned).
45    fn drive(&self, base_url: &str) -> impl Future<Output = Result<(), AgentError>> + Send;
46}
47
48/// Why the agent could not be driven.
49///
50/// `#[non_exhaustive]`: the subprocess driver may add variants (a spawn-vs-signal distinction)
51/// without breaking callers.
52#[derive(Debug)]
53#[non_exhaustive]
54pub enum AgentError {
55    /// The agent command could not be launched — in practice, the binary is missing or not
56    /// executable.
57    Spawn(std::io::Error),
58}
59
60impl fmt::Display for AgentError {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        match self {
63            Self::Spawn(source) => write!(f, "cannot run the agent: {source}"),
64        }
65    }
66}
67
68impl std::error::Error for AgentError {
69    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
70        match self {
71            Self::Spawn(source) => Some(source),
72        }
73    }
74}
75
76/// Why a re-execution could not be carried out at all.
77///
78/// Distinct from a [`RunVerdict`] of `Inconclusive`: that is a re-run that *ran* but produced no
79/// usable evidence. `ReexecError` is the experiment failing to start — the listener would not bind,
80/// or the agent could not be launched. Both abort a `verify`; an inconclusive run does not.
81#[derive(Debug)]
82#[non_exhaustive]
83pub enum ReexecError {
84    /// The loopback replay listener could not be stood up.
85    Bind(ReplayError),
86    /// The agent could not be driven.
87    Agent(AgentError),
88}
89
90impl fmt::Display for ReexecError {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        match self {
93            Self::Bind(source) => write!(f, "cannot start the replay listener: {source}"),
94            Self::Agent(source) => write!(f, "{source}"),
95        }
96    }
97}
98
99impl std::error::Error for ReexecError {
100    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
101        match self {
102            Self::Bind(source) => Some(source),
103            Self::Agent(source) => Some(source),
104        }
105    }
106}
107
108/// Re-execute the patched cassette once and return whether the run recovered.
109///
110/// Stands up a [`ReplayServer`] over `patched`, drives `driver` against its loopback URL, then
111/// normalizes the re-executed tape and asks the oracle whether the fork at `origin` is gone. The
112/// re-run answers recorded turns from the tape and relays through `upstream` once it branches off
113/// — so the patched step is served the good run's response, and everything after runs live.
114///
115/// `Ok(verdict)` is the experiment's result, `Inconclusive` included; `Err` means the experiment
116/// could not run (see [`ReexecError`]).
117///
118/// # Errors
119///
120/// [`ReexecError::Bind`] if the listener cannot bind; [`ReexecError::Agent`] if the agent cannot be
121/// launched.
122pub(crate) async fn reexecute_once<U, D>(
123    patched: amberfork_record::Cassette,
124    good: &Run,
125    origin: usize,
126    driver: &D,
127    upstream: U,
128    reexecuted_id: &str,
129    params: &DiffParams,
130) -> Result<RunVerdict, ReexecError>
131where
132    U: Upstream + 'static,
133    D: AgentDriver,
134{
135    let server = ReplayServer::bind(patched, upstream, reexecuted_id)
136        .await
137        .map_err(ReexecError::Bind)?;
138    // A completed agent run — any exit code — leaves its tape; only a failure to launch it aborts.
139    driver
140        .drive(&server.base_url())
141        .await
142        .map_err(ReexecError::Agent)?;
143    // The re-executed tape is itself a cassette (recorded turns + relayed misses, in order), so it
144    // normalizes into a `Run` through the very same path a first-party recording does.
145    let reexecuted = normalize(&server.reexecuted().await);
146    Ok(classify_recovery(good, &reexecuted, origin, params))
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::testkit::{
153        B2, G0, G1, G2, ScriptedAgent, body_of, cassette, good_cassette, response,
154    };
155    use amberfork_record::Cassette;
156    use amberfork_replay::ScriptedUpstream;
157
158    fn good_run() -> Run {
159        normalize(&good_cassette())
160    }
161
162    /// The patched bad cassette: prefix matches good, turn 1 (the fork) serves the good response,
163    /// turn 2 still holds the bad answer for the bad request. Origin (the patched step) is 1 — this
164    /// is what `patch_cassette` produces from the good/bad pair, built directly here so the driver
165    /// test does not depend on the patch builder.
166    fn patched_cassette() -> Cassette {
167        cassette("bad", &[("q0", G0), ("q1", G1), ("q2_bad", B2)])
168    }
169
170    #[tokio::test]
171    async fn a_rerun_that_follows_the_good_path_is_recovered() {
172        // The scripted agent, having been served the good turn-1 response, now asks the good
173        // turn-2 question — which the bad cassette never recorded, so it cache-misses and the
174        // upstream serves the good turn-2 answer. The re-executed outputs are [G0, G1, G2], which
175        // aligns against good with no fork.
176        let verdict = reexecute_once(
177            patched_cassette(),
178            &good_run(),
179            1,
180            &ScriptedAgent {
181                requests: vec![body_of("q0"), body_of("q1"), body_of("q2_good")],
182            },
183            ScriptedUpstream::new([response(G2)]),
184            "reexec",
185            &DiffParams::default(),
186        )
187        .await
188        .expect("the experiment runs");
189
190        assert_eq!(verdict, RunVerdict::Recovered);
191    }
192
193    #[tokio::test]
194    async fn a_rerun_that_still_diverges_is_not_recovered() {
195        // Even served the good turn-1 response, this agent asks the bad turn-2 question, which the
196        // patched cassette still answers with the bad response. The re-executed outputs are
197        // [G0, G1, B2]; B2 is disjoint from good's G2, so the fork survives at the tail.
198        let verdict = reexecute_once(
199            patched_cassette(),
200            &good_run(),
201            1,
202            &ScriptedAgent {
203                requests: vec![body_of("q0"), body_of("q1"), body_of("q2_bad")],
204            },
205            ScriptedUpstream::new([]), // every turn hits the tape; no relay expected
206            "reexec",
207            &DiffParams::default(),
208        )
209        .await
210        .expect("the experiment runs");
211
212        assert_eq!(verdict, RunVerdict::NotRecovered);
213    }
214}