mod evidence;
mod sample;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use rand::SeedableRng;
use rand::rngs::StdRng;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use argonauts_core::limits::{TrackRecord, TrackRecords};
use argonauts_core::names::ModelRef;
use argonauts_core::settings::Settings;
use argonauts_core::settings::protocol::RoutingSettings;
use crate::error::Result;
use crate::outcomes::{LogLine, Prediction};
use crate::predictions;
use crate::store::Store;
use evidence::{CellFacts, Evidence, Pools};
pub const POOLED_ALL_TYPES: &str = "all task types";
pub const POOLED_CREW: &str = "crew";
const CLAIM_DRAWS: u32 = 10_000;
const CLAIM_SEED: u64 = 0x7A_C1A1;
const PIN_STRENGTH: f64 = 1e6;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Beta {
pub alpha: f64,
pub beta: f64,
}
impl Beta {
pub fn prior(mean: f64, strength: f64) -> Self {
let mean = mean.clamp(0.0, 1.0);
Self {
alpha: mean * strength,
beta: (1.0 - mean) * strength,
}
}
pub fn mean(&self) -> f64 {
let total = self.alpha + self.beta;
if total > 0.0 { self.alpha / total } else { 0.5 }
}
pub fn sample(&self, rng: &mut StdRng) -> f64 {
let alpha = if self.alpha.is_finite() {
self.alpha.max(0.0)
} else {
0.0
};
let beta = if self.beta.is_finite() {
self.beta.max(0.0)
} else {
0.0
};
match (alpha > 0.0, beta > 0.0) {
(true, true) => sample::beta(alpha, beta, rng),
(true, false) => 1.0,
(false, true) => 0.0,
(false, false) => 0.5,
}
}
pub fn prob_above(&self, other: &Beta) -> f64 {
let mut rng = StdRng::seed_from_u64(CLAIM_SEED);
let mut above = 0.0;
for _ in 0..CLAIM_DRAWS {
let mine = self.sample(&mut rng);
let theirs = other.sample(&mut rng);
if mine > theirs {
above += 1.0;
} else if mine == theirs {
above += 0.5;
}
}
above / f64::from(CLAIM_DRAWS)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Record {
pub worker: ModelRef,
pub task_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pooled_from: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_id: Option<String>,
pub pass: Beta,
pub approval: Beta,
pub cost_ratio: f64,
pub time_ratio: f64,
pub prediction: f64,
pub evidence: f64,
pub counts: TrackRecord,
pub pinned: bool,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Book {
records: Vec<Record>,
settings: RoutingSettings,
pass: Pools,
approval: Pools,
models: BTreeMap<ModelRef, String>,
prediction: BTreeMap<ModelRef, f64>,
}
impl Book {
pub fn build(lines: &[LogLine], settings: &RoutingSettings, as_of: OffsetDateTime) -> Self {
let evidence = Evidence::gather(lines, settings, as_of);
let mut book = Self {
records: Vec::new(),
settings: settings.clone(),
pass: evidence.pass_pools(settings),
approval: evidence.approval_pools(settings),
models: evidence.models.clone(),
prediction: prediction_scores(lines, &evidence, settings, as_of),
};
let mut cells = evidence.cells();
for (worker, types) in &settings.pins {
let Ok(worker) = worker.parse::<ModelRef>() else {
tracing::warn!("ignoring routing.pins.{worker}: not a model name");
continue;
};
for task_type in types.keys() {
cells
.entry((worker.clone(), task_type.clone()))
.or_default();
}
}
let mut records: Vec<Record> = cells
.iter()
.map(|((worker, task_type), facts)| book.compose(worker, task_type, facts))
.collect();
records.sort_by(|a, b| (&a.task_type, &a.worker).cmp(&(&b.task_type, &b.worker)));
book.records = records;
book
}
pub fn rebuild(
store: &Store,
settings: &RoutingSettings,
as_of: OffsetDateTime,
) -> Result<Self> {
let lines = crate::outcomes::read_all(store)?;
Ok(Self::build(&lines, settings, as_of))
}
pub fn record(&self, worker: &ModelRef, task_type: &str) -> Record {
if let Some(record) = self
.records
.iter()
.find(|record| &record.worker == worker && record.task_type == task_type)
{
return record.clone();
}
self.compose(worker, task_type, &CellFacts::default())
}
pub fn rows(&self) -> &[Record] {
&self.records
}
pub fn clearly_better(
&self,
a: &ModelRef,
b: &ModelRef,
task_type: &str,
settings: &RoutingSettings,
) -> Option<bool> {
let p = self
.record(a, task_type)
.pass
.prob_above(&self.record(b, task_type).pass);
if p > settings.claim_above {
Some(true)
} else if 1.0 - p > settings.claim_above {
Some(false)
} else {
None
}
}
fn compose(&self, worker: &ModelRef, task_type: &str, facts: &CellFacts) -> Record {
let settings = &self.settings;
let configured =
evidence::setting_for(&settings.priors, worker).and_then(|types| types.get(task_type));
let sparse =
self.pass.cell(worker, task_type).weight() < f64::from(settings.own_type_after);
let (pass, pooled_from, evidence) = self.pass.posterior(
worker,
task_type,
configured.and_then(|rates| rates.first_pass_rate),
sparse,
settings,
);
let (approval, _, _) = self.approval.posterior(
worker,
task_type,
configured.and_then(|rates| rates.approval_rate),
sparse,
settings,
);
let pin = evidence::setting_for(&settings.pins, worker)
.and_then(|types| types.get(task_type))
.copied();
let (pass, pooled_from) = match pin {
Some(rate) => (Beta::prior(rate, PIN_STRENGTH), None),
None => (pass, pooled_from),
};
Record {
worker: worker.clone(),
task_type: task_type.to_owned(),
pooled_from,
model_id: self.models.get(worker).cloned(),
pass,
approval,
cost_ratio: facts.cost_ratio,
time_ratio: facts.time_ratio,
prediction: self.prediction.get(worker).copied().unwrap_or(0.0),
evidence,
counts: facts.counts,
pinned: pin.is_some(),
}
}
}
fn prediction_scores(
lines: &[LogLine],
evidence: &Evidence<'_>,
settings: &RoutingSettings,
as_of: OffsetDateTime,
) -> BTreeMap<ModelRef, f64> {
let mut scored: Vec<LogLine> = evidence
.outcomes
.iter()
.map(|counted| LogLine::Outcome(counted.outcome.clone()))
.collect();
let counts = |prediction: &Prediction| {
prediction.at <= as_of && !evidence.reset_drops(&prediction.worker, prediction.at)
};
scored.extend(
lines
.iter()
.filter(|line| matches!(line, LogLine::Prediction(prediction) if counts(prediction)))
.cloned(),
);
let mut sums: BTreeMap<ModelRef, (f64, u32)> = BTreeMap::new();
for calibration in predictions::calibration(&scored, settings) {
if !predictions::WORKER_METRICS.contains(&calibration.metric.as_str()) {
continue;
}
let sum = sums.entry(calibration.worker).or_default();
sum.0 += calibration.skill * f64::from(calibration.count);
sum.1 += calibration.count;
}
sums.into_iter()
.filter(|(_, (_, count))| *count > 0)
.map(|(worker, (total, count))| {
let count = f64::from(count);
let skill = (total / count).clamp(-1.0, 1.0);
let trust = count / (count + settings.prior_strength.max(0.0));
(worker, skill * trust)
})
.collect()
}
pub struct LiveRecords {
store: Store,
settings: Arc<Settings>,
book: std::sync::Mutex<Option<Arc<Book>>>,
}
impl LiveRecords {
pub fn new(state_dir: PathBuf, repo_root: PathBuf, settings: Arc<Settings>) -> Self {
Self {
store: Store::new(state_dir, repo_root),
settings,
book: std::sync::Mutex::new(None),
}
}
pub fn book(&self) -> Arc<Book> {
let mut book = self
.book
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
book.get_or_insert_with(|| {
let built = Book::rebuild(
&self.store,
&self.settings.routing,
OffsetDateTime::now_utc(),
)
.unwrap_or_else(|err| {
tracing::warn!("track records unavailable: {err}");
Book::default()
});
Arc::new(built)
})
.clone()
}
pub fn refresh(&self) {
*self
.book
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
}
}
impl TrackRecords for LiveRecords {
fn get(&self, worker: &ModelRef, task_type: &str) -> Option<TrackRecord> {
let counts = self.book().record(worker, task_type).counts;
(counts.of > 0).then_some(counts)
}
}