use serde::{Deserialize, Serialize};
use crate::types::{LearnedWorkflowRef, ReplayResult};
const INTERIM_P: f64 = 0.001;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
Passes,
Fails,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct StoppingRule {
pub margin: f64,
pub runs_per_task: u32,
pub sequential: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
struct Pairs {
done: u32,
parent_passed: u32,
child_passed: u32,
child_only: u32,
parent_only: u32,
planned: u32,
}
impl StoppingRule {
pub fn verdict(
&self,
tasks: &[String],
results: &[ReplayResult],
parent: &LearnedWorkflowRef,
child: &LearnedWorkflowRef,
) -> Verdict {
let pairs = self.pairs(tasks, results, parent, child);
if self.gain(&pairs, Outlook::SoFar) >= self.margin {
Verdict::Passes
} else {
Verdict::Fails
}
}
pub fn clear(
&self,
tasks: &[String],
results: &[ReplayResult],
parent: &LearnedWorkflowRef,
child: &LearnedWorkflowRef,
) -> Option<Verdict> {
let pairs = self.pairs(tasks, results, parent, child);
let best = self.gain(&pairs, Outlook::Best);
let worst = self.gain(&pairs, Outlook::Worst);
if worst >= self.margin {
return Some(Verdict::Passes);
}
if best < self.margin {
return Some(Verdict::Fails);
}
let child_only: u32 = pairs.iter().map(|task| task.child_only).sum();
let parent_only: u32 = pairs.iter().map(|task| task.parent_only).sum();
let so_far = self.gain(&pairs, Outlook::SoFar);
if sign_test(child_only, parent_only) < INTERIM_P && so_far >= self.margin {
return Some(Verdict::Passes);
}
if sign_test(parent_only, child_only) < INTERIM_P && so_far < self.margin {
return Some(Verdict::Fails);
}
None
}
pub fn dropped(
task: &str,
results: &[ReplayResult],
parent: &LearnedWorkflowRef,
child: &LearnedWorkflowRef,
) -> bool {
let first = |learned_workflow: &LearnedWorkflowRef| {
results
.iter()
.find(|result| {
!result.holdout
&& result.task == task
&& result.try_number == 1
&& &result.learned_workflow == learned_workflow
})
.map(|result| result.passed)
};
matches!((first(parent), first(child)), (Some(a), Some(b)) if a == b)
}
fn pairs(
&self,
tasks: &[String],
results: &[ReplayResult],
parent: &LearnedWorkflowRef,
child: &LearnedWorkflowRef,
) -> Vec<Pairs> {
let runs = self.runs_per_task.max(1);
tasks
.iter()
.map(|task| {
let mut pairs = Pairs {
planned: if Self::dropped(task, results, parent, child) {
1
} else {
runs
},
..Pairs::default()
};
for try_number in 1..=runs {
let passed = |learned_workflow: &LearnedWorkflowRef| {
results
.iter()
.find(|result| {
!result.holdout
&& &result.task == task
&& result.try_number == try_number
&& &result.learned_workflow == learned_workflow
})
.map(|result| result.passed)
};
if let (Some(p), Some(c)) = (passed(parent), passed(child)) {
pairs.done += 1;
pairs.parent_passed += u32::from(p);
pairs.child_passed += u32::from(c);
pairs.child_only += u32::from(c && !p);
pairs.parent_only += u32::from(p && !c);
}
}
pairs
})
.collect()
}
fn gain(&self, tasks: &[Pairs], outlook: Outlook) -> f64 {
let mut total = 0.0;
let mut counted = 0u32;
for task in tasks {
let left = task.planned.saturating_sub(task.done);
let (child, parent, runs) = match outlook {
Outlook::SoFar if task.done == 0 => continue,
Outlook::SoFar => (task.child_passed, task.parent_passed, task.done),
Outlook::Best => (task.child_passed + left, task.parent_passed, task.planned),
Outlook::Worst => (task.child_passed, task.parent_passed + left, task.planned),
};
total += (f64::from(child) - f64::from(parent)) / f64::from(runs.max(1));
counted += 1;
}
if counted == 0 {
0.0
} else {
total / f64::from(counted)
}
}
}
#[derive(Debug, Clone, Copy)]
enum Outlook {
SoFar,
Best,
Worst,
}
fn sign_test(wins: u32, losses: u32) -> f64 {
let n = wins + losses;
if n == 0 {
return 1.0;
}
let mut log_pmf = -f64::from(n) * std::f64::consts::LN_2;
let mut tail = 0.0;
for i in 0..=n {
if i >= wins {
tail += log_pmf.exp();
}
if i < n {
log_pmf += (f64::from(n - i) / f64::from(i + 1)).ln();
}
}
tail.min(1.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sign_test_matches_the_binomial_tail() {
assert!((sign_test(0, 0) - 1.0).abs() < 1e-12);
assert!((sign_test(1, 0) - 0.5).abs() < 1e-12);
assert!((sign_test(10, 0) - 1.0 / 1024.0).abs() < 1e-12);
assert!((sign_test(2, 1) - 0.5).abs() < 1e-12);
assert!(sign_test(1500, 1500) > 0.4);
}
}