use std::sync::atomic::{AtomicUsize, Ordering};
use amberfork_align::DiffParams;
use amberfork_model::{Attribution, AttributionMode, Counterfactual, DiffResult, Recovery, Run};
use amberfork_record::{Cassette, normalize};
use amberfork_replay::Upstream;
use crate::AgentDriver;
use crate::cause::{Labeling, fork_candidates, relabel};
use crate::ddmin::minimize;
use crate::driver::{ReexecError, reexecute_once};
use crate::oracle::RunVerdict;
use crate::patch::patch_many;
fn consensus(verdicts: &[RunVerdict]) -> Recovery {
let recovered = verdicts
.iter()
.filter(|v| **v == RunVerdict::Recovered)
.count();
let not_recovered = verdicts
.iter()
.filter(|v| **v == RunVerdict::NotRecovered)
.count();
if recovered > not_recovered {
Recovery::Recovered
} else if not_recovered > recovered {
Recovery::NotRecovered
} else {
Recovery::Unverified
}
}
struct Experiment<'a, D, F> {
good_run: &'a Run,
branch: usize,
driver: &'a D,
make_upstream: &'a F,
runs: u32,
params: &'a DiffParams,
id_prefix: &'a str,
}
impl<D: AgentDriver, F> Experiment<'_, D, F> {
async fn recovery_of<U>(&self, patched: Cassette) -> Result<Recovery, ReexecError>
where
U: Upstream + 'static,
F: Fn() -> U,
{
let mut verdicts = Vec::with_capacity(self.runs as usize);
for run in 0..self.runs {
let reexecuted_id = format!("{}-{run}", self.id_prefix);
let verdict = reexecute_once(
patched.clone(),
self.good_run,
self.branch,
self.driver,
(self.make_upstream)(),
&reexecuted_id,
self.params,
)
.await?;
verdicts.push(verdict);
}
Ok(consensus(&verdicts))
}
}
pub async fn verify<U, D, F>(
diff: &DiffResult,
good: &Cassette,
bad: &Cassette,
driver: &D,
make_upstream: F,
runs: u32,
params: &DiffParams,
) -> Result<Option<Attribution>, ReexecError>
where
U: Upstream + 'static,
D: AgentDriver,
F: Fn() -> U,
{
let Some(base) = diff.attribution.clone() else {
return Ok(None);
};
let candidates = fork_candidates(diff);
let Some(first) = candidates.first().copied() else {
return Ok(Some(base));
};
let good_run = normalize(good);
let id_prefix = format!("{}-cf", diff.runs.b.id);
let experiment = Experiment {
good_run: &good_run,
branch: first.bad_step,
driver,
make_upstream: &make_upstream,
runs,
params,
id_prefix: &id_prefix,
};
let region: Vec<usize> = base
.origin_step
.into_iter()
.chain(base.propagation.iter().copied())
.collect();
let total = AtomicUsize::new(0);
let conclusive = AtomicUsize::new(0);
let reduction = minimize(candidates.len(), |subset: &[usize]| {
let grafts: Vec<(usize, usize)> = subset
.iter()
.map(|&i| (candidates[i].bad_step, candidates[i].good_step))
.collect();
let patched = patch_many(bad, good, &grafts);
let experiment = &experiment;
let total = &total;
let conclusive = &conclusive;
async move {
total.fetch_add(1, Ordering::Relaxed);
let recovery = match patched {
Some(patched) => experiment.recovery_of(patched).await?,
None => Recovery::Unverified,
};
if recovery != Recovery::Unverified {
conclusive.fetch_add(1, Ordering::Relaxed);
}
Ok(recovery)
}
})
.await?;
let Labeling {
origin_step,
propagation,
recovered,
} = relabel(&reduction, &candidates, ®ion);
let total = total.load(Ordering::Relaxed);
let conclusive = conclusive.load(Ordering::Relaxed);
let confidence = if total == 0 {
base.confidence
} else {
conclusive as f64 / total as f64
};
Ok(Some(Attribution {
mode: AttributionMode::Counterfactual,
origin_step,
propagation,
counterfactual: Some(Counterfactual { recovered, runs }),
cause_label: None,
confidence,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testkit::{G2, ScriptedAgent, bad_cassette, body_of, good_cassette, response};
use amberfork_align::LexicalCost;
use amberfork_replay::ScriptedUpstream;
use RunVerdict::{Inconclusive, NotRecovered, Recovered};
#[test]
fn a_strict_majority_of_conclusive_runs_wins_and_inconclusive_ones_do_not_vote() {
assert_eq!(
consensus(&[Recovered, Recovered, NotRecovered, Inconclusive]),
Recovery::Recovered
);
assert_eq!(
consensus(&[NotRecovered, NotRecovered, Recovered]),
Recovery::NotRecovered
);
}
#[test]
fn a_tie_between_conclusive_runs_is_unverified() {
assert_eq!(
consensus(&[Recovered, NotRecovered, Inconclusive, Inconclusive]),
Recovery::Unverified
);
}
#[test]
fn all_inconclusive_or_no_runs_is_unverified() {
assert_eq!(
consensus(&[Inconclusive, Inconclusive]),
Recovery::Unverified
);
assert_eq!(consensus(&[]), Recovery::Unverified);
}
#[tokio::test]
async fn verify_upgrades_a_recovering_fork_to_counterfactual() {
let good = good_cassette();
let bad = bad_cassette();
let params = DiffParams::default();
let diff =
amberfork_align::diff(&normalize(&good), &normalize(&bad), &LexicalCost, ¶ms)
.expect("the good/bad pair aligns");
assert_eq!(
diff.attribution
.as_ref()
.expect("a fork is attributed")
.origin_step,
Some(1),
"static analysis localizes the fork at the bad run's turn 1"
);
let agent = ScriptedAgent {
requests: vec![body_of("q0"), body_of("q1"), body_of("q2_good")],
};
let attribution = verify(
&diff,
&good,
&bad,
&agent,
|| ScriptedUpstream::new([response(G2)]),
3,
¶ms,
)
.await
.expect("the experiment runs")
.expect("a forked diff yields an attribution");
assert_eq!(attribution.mode, AttributionMode::Counterfactual);
assert_eq!(
attribution.origin_step,
Some(1),
"ddmin confirms the fork step as the minimal cause"
);
assert_eq!(
attribution.propagation,
vec![2],
"the tail recovered once the fork was patched, so it is propagation"
);
let counterfactual = attribution
.counterfactual
.expect("counterfactual evidence is present");
assert_eq!(counterfactual.recovered, Recovery::Recovered);
assert_eq!(counterfactual.runs, 3);
assert!(
(attribution.confidence - 1.0).abs() < f64::EPSILON,
"every ddmin oracle call was conclusive, so confidence is 1.0, was {}",
attribution.confidence
);
assert!(
attribution.cause_label.is_none(),
"counterfactual localizes; naming stays the judge's job"
);
}
}