use std::path::{Path, PathBuf};
use std::sync::Arc;
use rand::rngs::StdRng;
use rand::{RngExt as _, SeedableRng as _};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use argonauts_core::RunId;
use argonauts_core::future::BoxFuture;
use argonauts_core::limits::CheckRunner;
use argonauts_core::settings::Settings;
use argonauts_core::settings::evolution::ReplaySettings;
use super::files::{
append_json_line, read_json, read_json_dir, read_json_lines, remove_if_present, write_json,
};
use super::{Entry, Proposal, ReplaySet, checks};
use crate::error::{LearningError, Result};
use crate::store::Store;
use crate::types::LearnedWorkflowRef;
const REPOS_DIR: &str = "replay-repos";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SplitRecord {
pub entry: String,
pub seed: Option<u64>,
pub holdout: bool,
pub reason: SplitReason,
pub generation: u32,
#[serde(with = "time::serde::rfc3339")]
pub at: OffsetDateTime,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SplitReason {
Drawn,
HoldoutFull,
Retired,
}
pub fn draw_seed() -> u64 {
rand::random()
}
pub fn holdout_draw(seed: u64, holdout_share: f64) -> bool {
StdRng::seed_from_u64(seed).random::<f64>() < holdout_share
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct SetState {
tuning_tasks: u32,
holdout_tasks: u32,
holdout_retire_after: u32,
candidates: u32,
generation: u32,
}
impl Default for SetState {
fn default() -> Self {
let mut state = Self {
tuning_tasks: 0,
holdout_tasks: 0,
holdout_retire_after: 0,
candidates: 0,
generation: 0,
};
state.follow(&ReplaySettings::default());
state
}
}
impl SetState {
fn follow(&mut self, settings: &ReplaySettings) {
self.tuning_tasks = settings.tasks;
self.holdout_tasks = settings.holdout_tasks;
self.holdout_retire_after = settings.holdout_retire_after;
}
fn holdout_share(&self) -> f64 {
let total = self.tuning_tasks + self.holdout_tasks;
if total == 0 {
0.0
} else {
f64::from(self.holdout_tasks) / f64::from(total)
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct SpendRecord {
#[serde(with = "time::serde::rfc3339")]
at: OffsetDateTime,
usd: f64,
task: String,
learned_workflow: LearnedWorkflowRef,
}
impl ReplaySet {
pub fn open(store: &Store) -> Result<Self> {
Ok(Self {
store: store.clone(),
})
}
pub fn store(&self) -> &Store {
&self.store
}
pub fn entries(&self) -> Result<Vec<Entry>> {
let mut entries: Vec<Entry> = read_json_dir(&self.dir().join("entries"))?;
entries.sort_by(|a, b| a.accepted.cmp(&b.accepted).then_with(|| a.id.cmp(&b.id)));
Ok(entries)
}
pub fn tuning(&self) -> Result<Vec<Entry>> {
let mut entries = self.entries()?;
entries.retain(|entry| !entry.holdout);
Ok(entries)
}
pub fn holdout(&self) -> Result<Vec<Entry>> {
let mut entries = self.entries()?;
entries.retain(|entry| entry.holdout);
Ok(entries)
}
pub fn entry(&self, id: &str) -> Result<Option<Entry>> {
read_json(&self.entry_path(checked(id)?))
}
pub fn proposals(&self) -> Result<Vec<Proposal>> {
read_json_dir(&self.dir().join("proposals"))
}
pub fn splits(&self) -> Result<Vec<SplitRecord>> {
read_json_lines(&self.dir().join("split.jsonl"))
}
pub fn consider<'a>(
&'a self,
run: &'a RunId,
settings: &'a Settings,
checks: Arc<dyn CheckRunner>,
) -> BoxFuture<'a, Result<Option<Proposal>>> {
Box::pin(checks::consider(self, run, settings, checks))
}
pub fn accept(&self, id: &str, seed: u64) -> Result<Entry> {
let id = checked(id)?;
let proposal: Proposal = read_json(&self.proposal_path(id))?.ok_or_else(|| {
LearningError::Refused(format!("there's no replay proposal named {id}"))
})?;
if !proposal.valid() {
let failed: Vec<&str> = proposal
.checks
.iter()
.filter(|check| !check.passed)
.map(|check| check.name.as_str())
.collect();
return Err(LearningError::Refused(format!(
"{id} can't join the replay set: it failed {}",
failed.join(", ")
)));
}
let state = self.state()?;
let drawn = holdout_draw(seed, state.holdout_share());
let room = self.holdout()?.len() < state.holdout_tasks as usize;
let mut entry = proposal.entry;
entry.holdout = drawn && room;
entry.accepted = OffsetDateTime::now_utc();
write_json(&self.entry_path(id), &entry)?;
remove_if_present(&self.proposal_path(id))?;
self.record_split(SplitRecord {
entry: entry.id.clone(),
seed: Some(seed),
holdout: entry.holdout,
reason: if drawn && !room {
SplitReason::HoldoutFull
} else {
SplitReason::Drawn
},
generation: state.generation,
at: entry.accepted,
})?;
self.write_lists()?;
Ok(entry)
}
pub fn reject(&self, id: &str) -> Result<()> {
let id = checked(id)?;
if self.entry(id)?.is_some() {
return Err(LearningError::Refused(format!(
"{id} is already in the replay set"
)));
}
remove_if_present(&self.proposal_path(id))?;
remove_if_present(&self.hidden_tests_dir(id))?;
remove_if_present(&self.scoring_setup_path(id))
}
pub fn holdout_used(&self, settings: &Settings) -> Result<()> {
let mut state = self.state()?;
state.follow(&settings.evolution.replay);
state.candidates += 1;
if state.candidates >= state.holdout_retire_after.max(1) {
let at = OffsetDateTime::now_utc();
for mut entry in self.holdout()? {
entry.holdout = false;
write_json(&self.entry_path(&entry.id), &entry)?;
self.record_split(SplitRecord {
entry: entry.id,
seed: None,
holdout: false,
reason: SplitReason::Retired,
generation: state.generation,
at,
})?;
}
state.candidates = 0;
state.generation += 1;
}
self.write_state(&state)?;
self.write_lists()
}
pub fn holdout_candidates(&self) -> Result<u32> {
Ok(self.state()?.candidates)
}
pub fn spent_in_month(&self, at: OffsetDateTime) -> Result<f64> {
let records: Vec<SpendRecord> = read_json_lines(&self.spend_path())?;
Ok(records
.iter()
.filter(|record| record.at.year() == at.year() && record.at.month() == at.month())
.fold(0.0, |total, record| total + record.usd))
}
pub fn limit_left(&self, settings: &Settings, now: OffsetDateTime) -> Result<Option<f64>> {
let Some(limit) = settings.limits.evolution_usd_per_month else {
return Ok(None);
};
let spent = self.spent_in_month(now)?;
Ok(Some((limit - spent).max(0.0)))
}
pub fn repos_dir(&self) -> PathBuf {
self.store.state_dir.join(REPOS_DIR)
}
pub(super) fn dir(&self) -> PathBuf {
self.store.replay_dir()
}
pub(super) fn work_dir(&self) -> PathBuf {
self.dir().join("work")
}
pub(super) fn hidden_tests_dir(&self, id: &str) -> PathBuf {
self.dir().join("tests").join(id)
}
pub(super) fn hidden_paths(&self, entry: &Entry) -> Vec<String> {
let root = self.hidden_tests_dir(&entry.id);
let mut paths: Vec<String> = entry
.tests
.iter()
.filter_map(|test| test.strip_prefix(&root).ok())
.map(|relative| relative.to_string_lossy().replace('\\', "/"))
.collect();
paths.push(self.dir().to_string_lossy().into_owned());
paths
}
pub(super) fn scoring_setup_path(&self, id: &str) -> PathBuf {
scoring_setup_path(&self.dir(), id)
}
pub(super) fn progress_path(&self, key: &str) -> PathBuf {
self.dir().join("progress").join(format!("{key}.json"))
}
pub(super) fn proposal_path(&self, id: &str) -> PathBuf {
self.dir().join("proposals").join(format!("{id}.json"))
}
pub(super) fn refresh_rule(&self, settings: &ReplaySettings) -> Result<()> {
let mut state = self.state()?;
state.follow(settings);
self.write_state(&state)
}
pub(super) fn record_spend(
&self,
task: &str,
learned_workflow: &LearnedWorkflowRef,
usd: f64,
) -> Result<()> {
append_json_line(
&self.spend_path(),
&SpendRecord {
at: OffsetDateTime::now_utc(),
usd,
task: task.to_owned(),
learned_workflow: learned_workflow.clone(),
},
)
}
fn entry_path(&self, id: &str) -> PathBuf {
self.dir().join("entries").join(format!("{id}.json"))
}
fn spend_path(&self) -> PathBuf {
self.dir().join("spend.jsonl")
}
fn state(&self) -> Result<SetState> {
Ok(read_json(&self.dir().join("state.json"))?.unwrap_or_default())
}
fn write_state(&self, state: &SetState) -> Result<()> {
write_json(&self.dir().join("state.json"), state)
}
fn record_split(&self, record: SplitRecord) -> Result<()> {
append_json_line(&self.dir().join("split.jsonl"), &record)
}
fn write_lists(&self) -> Result<()> {
let entries = self.entries()?;
let ids = |holdout: bool| -> Vec<&str> {
entries
.iter()
.filter(|entry| entry.holdout == holdout)
.map(|entry| entry.id.as_str())
.collect()
};
write_json(&self.dir().join("tuning.json"), &ids(false))?;
write_json(&self.dir().join("holdout.json"), &ids(true))
}
}
fn checked(id: &str) -> Result<&str> {
RunId::new(id)
.map(|_| id)
.map_err(|_| LearningError::Refused(format!("{id:?} isn't a replay entry")))
}
pub(super) fn scoring_setup_path(replay_dir: &Path, id: &str) -> PathBuf {
replay_dir.join("setup").join(format!("{id}.json"))
}