use anyhow::{Result, ensure};
use rand::SeedableRng;
use rand_distr::{Distribution, Gamma};
use rand_pcg::Pcg64;
use serde::{Deserialize, Serialize};
mod prediction;
const METHOD: &str =
"paired-dirichlet-v1; Pcg64 0.10.2; rand 0.10.3; rand_distr 0.6.0; linear quantiles";
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Bar {
pub margin: f64,
pub credible_level: f64,
pub prior: f64,
pub seed: u64,
pub draws: usize,
pub trials_per_task: usize,
pub prediction: PredictionBudget,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PredictionBudget {
pub simulations: usize,
pub interval_draws: usize,
pub max_additional_tasks: usize,
}
impl Default for PredictionBudget {
fn default() -> Self {
Self {
simulations: 200,
interval_draws: 512,
max_additional_tasks: 2_000,
}
}
}
impl Default for Bar {
fn default() -> Self {
Self {
margin: -0.10,
credible_level: 0.95,
prior: 1.0,
seed: 0,
draws: 20_000,
trials_per_task: 3,
prediction: PredictionBudget::default(),
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct Score {
pub passed: bool,
pub wrong_finding: bool,
}
#[derive(Clone, Debug)]
pub struct TaskScores {
pub baseline: Vec<Score>,
pub candidate: Vec<Score>,
pub dropped_as_void: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Interval {
pub lower: f64,
pub upper: f64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Verdict {
pub bar: Bar,
pub thirdshift_version: String,
pub method: String,
pub accuracy: Assessment,
pub wrong_findings: Assessment,
pub verdict: Decision,
pub retained_tasks: usize,
pub dropped_tasks: usize,
pub pass_cubed: Option<PassCubed>,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct PassCubed {
pub baseline: f64,
pub candidate: f64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Decision {
Pass,
Fail,
Inconclusive,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Assessment {
pub difference_counts: Vec<usize>,
pub interval: Interval,
pub verdict: Decision,
pub more_tasks: Option<MoreTasks>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MoreTasks {
pub target_probability: f64,
pub additional_tasks: Option<usize>,
pub clear_probabilities: Vec<f64>,
}
pub fn judge(scores: &[TaskScores], bar: &Bar) -> Result<Verdict> {
evaluate(scores, bar, true)
}
pub fn judge_without_predictions(scores: &[TaskScores], bar: &Bar) -> Result<Verdict> {
evaluate(scores, bar, false)
}
fn evaluate(scores: &[TaskScores], bar: &Bar, predictions: bool) -> Result<Verdict> {
validate(scores, bar)?;
let trials = bar.trials_per_task;
let mut counts = vec![0; 2 * trials + 1];
let mut wrong_counts = vec![0; 2 * trials + 1];
let mut baseline_all_passed = 0;
let mut candidate_all_passed = 0;
let dropped_tasks = scores.iter().filter(|task| task.dropped_as_void).count();
for task in scores {
if task.dropped_as_void {
continue;
}
let baseline = task.baseline.iter().filter(|s| s.passed).count();
let candidate = task.candidate.iter().filter(|s| s.passed).count();
counts[candidate + trials - baseline] += 1;
baseline_all_passed += usize::from(baseline == trials);
candidate_all_passed += usize::from(candidate == trials);
let baseline_wrong = task.baseline.iter().filter(|s| s.wrong_finding).count();
let candidate_wrong = task.candidate.iter().filter(|s| s.wrong_finding).count();
wrong_counts[baseline_wrong + trials - candidate_wrong] += 1;
}
let accuracy = assess(&counts, bar, bar.seed, predictions)?;
let wrong_findings = assess(&wrong_counts, bar, bar.seed.wrapping_add(1), predictions)?;
let decisions = [accuracy.verdict, wrong_findings.verdict];
let verdict = if dropped_tasks > scores.len() / 10 {
Decision::Inconclusive
} else if decisions.contains(&Decision::Fail) {
Decision::Fail
} else if decisions.contains(&Decision::Inconclusive) {
Decision::Inconclusive
} else {
Decision::Pass
};
let retained_tasks = scores.len() - dropped_tasks;
Ok(Verdict {
bar: bar.clone(),
thirdshift_version: env!("CARGO_PKG_VERSION").to_owned(),
method: METHOD.to_owned(),
accuracy,
wrong_findings,
verdict,
retained_tasks,
dropped_tasks,
pass_cubed: (trials == 3 && retained_tasks > 0).then(|| PassCubed {
baseline: baseline_all_passed as f64 / retained_tasks as f64,
candidate: candidate_all_passed as f64 / retained_tasks as f64,
}),
})
}
fn validate(scores: &[TaskScores], bar: &Bar) -> Result<()> {
ensure!(!scores.is_empty(), "at least one Eval task is required");
ensure!(
matches!(bar.trials_per_task, 1 | 3),
"Trials per Eval task must be one or three"
);
ensure!(
bar.margin.is_finite() && (-1.0..=1.0).contains(&bar.margin),
"margin must be finite and within [-1, 1]"
);
ensure!(
bar.credible_level.is_finite() && bar.credible_level > 0.0 && bar.credible_level < 1.0,
"credible level must be strictly between zero and one"
);
let prior_per_value = bar.prior / (2 * bar.trials_per_task + 1) as f64;
ensure!(
bar.prior.is_finite() && prior_per_value > 0.0,
"prior must be finite and positive in every category"
);
ensure!(bar.draws >= 2, "at least two interval draws are required");
ensure!(
bar.prediction.simulations > 0
&& bar.prediction.interval_draws >= 2
&& bar.prediction.max_additional_tasks > 0,
"prediction budget requires simulations, at least two interval draws and a positive task limit"
);
for (index, task) in scores.iter().enumerate() {
if !task.dropped_as_void {
ensure!(
task.baseline.len() == bar.trials_per_task
&& task.candidate.len() == bar.trials_per_task,
"Eval task {index} must have the Bar's Trials in both Arms"
);
}
}
Ok(())
}
fn assess(counts: &[usize], bar: &Bar, seed: u64, predictions: bool) -> Result<Assessment> {
let posterior = Posterior::new(counts, bar)?;
let mut rng = Pcg64::seed_from_u64(seed);
let mut draws: Vec<_> = (0..bar.draws)
.map(|_| posterior.draw(&mut rng).mean())
.collect();
ensure!(
draws.iter().all(|d| d.is_finite()),
"posterior draws are outside floating-point range"
);
let interval = interval(&mut draws, bar.credible_level);
let verdict = decide(interval, bar.margin);
Ok(Assessment {
difference_counts: counts.to_vec(),
interval,
verdict,
more_tasks: (predictions && verdict == Decision::Inconclusive)
.then(|| prediction::more_tasks(&posterior, bar, &mut rng))
.transpose()?,
})
}
fn decide(interval: Interval, margin: f64) -> Decision {
if interval.upper < 0.0 {
Decision::Fail
} else if interval.lower > margin && interval.upper >= 0.0 {
Decision::Pass
} else {
Decision::Inconclusive
}
}
struct Posterior {
samplers: Vec<Gamma<f64>>,
values: Vec<f64>,
}
impl Posterior {
fn new(counts: &[usize], bar: &Bar) -> Result<Self> {
let samplers = counts
.iter()
.map(|&n| Gamma::new(n as f64 + bar.prior / counts.len() as f64, 1.0))
.collect::<std::result::Result<_, _>>()?;
let values = (0..counts.len())
.map(|i| (i as f64 - bar.trials_per_task as f64) / bar.trials_per_task as f64)
.collect();
Ok(Self { samplers, values })
}
fn draw(&self, rng: &mut Pcg64) -> Draw {
let mut draw = Draw {
total: 0.0,
weighted: 0.0,
};
for (sampler, &value) in self.samplers.iter().zip(&self.values) {
let weight = sampler.sample(rng);
draw.total += weight;
draw.weighted += weight * value;
}
draw
}
}
struct Draw {
total: f64,
weighted: f64,
}
impl Draw {
fn mean(&self) -> f64 {
self.weighted / self.total
}
}
fn interval(draws: &mut [f64], credible_level: f64) -> Interval {
let tail = (1.0 - credible_level) / 2.0;
Interval {
lower: quantile(draws, tail),
upper: quantile(draws, 1.0 - tail),
}
}
fn quantile(draws: &mut [f64], q: f64) -> f64 {
let index = q * (draws.len() - 1) as f64;
let lower = *draws
.select_nth_unstable_by(index.floor() as usize, f64::total_cmp)
.1;
let upper = *draws
.select_nth_unstable_by(index.ceil() as usize, f64::total_cmp)
.1;
lower + (upper - lower) * index.fract()
}
#[cfg(test)]
mod tests;