use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExperimentConfig {
pub name: String,
pub hypothesis: String,
#[serde(default)]
pub parameters: HashMap<String, Value>,
#[serde(default)]
pub description: String,
#[serde(default)]
pub tags: Vec<String>,
}
impl Default for ExperimentConfig {
fn default() -> Self {
Self {
name: "unnamed_experiment".into(),
hypothesis: String::new(),
parameters: HashMap::new(),
description: String::new(),
tags: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResultRecord {
pub key: String,
pub data: Value,
pub timestamp: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Experiment {
pub config: ExperimentConfig,
pub results: Vec<ResultRecord>,
pub created_at: String,
pub updated_at: String,
}
impl Experiment {
pub fn new(config: ExperimentConfig) -> Self {
let now = now_rfc3339();
Self {
config,
results: Vec::new(),
created_at: now.clone(),
updated_at: now,
}
}
pub fn add_result(&mut self, key: impl Into<String>, data: Value) {
self.results.push(ResultRecord {
key: key.into(),
data,
timestamp: now_rfc3339(),
});
self.updated_at = now_rfc3339();
}
pub fn result_count(&self) -> usize {
self.results.len()
}
pub fn get_result(&self, key: &str) -> Option<&ResultRecord> {
self.results.iter().find(|r| r.key == key)
}
pub fn field_stats(&self, field: &str) -> Option<FieldStats> {
let values: Vec<f64> = self
.results
.iter()
.filter_map(|r| r.data.get(field)?.as_f64())
.collect();
if values.is_empty() {
return None;
}
let n = values.len() as f64;
let mean = values.iter().sum::<f64>() / n;
let min = values.iter().cloned().fold(f64::INFINITY, f64::min);
let max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
let std_dev = variance.sqrt();
Some(FieldStats {
field: field.to_string(),
count: values.len(),
mean,
min,
max,
std_dev,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldStats {
pub field: String,
pub count: usize,
pub mean: f64,
pub min: f64,
pub max: f64,
pub std_dev: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExperimentSummary {
pub name: String,
pub hypothesis: String,
pub result_count: usize,
pub created_at: String,
pub updated_at: String,
}
pub struct ExperimentManager {
experiment: Experiment,
save_dir: Option<PathBuf>,
}
impl ExperimentManager {
pub fn in_memory(config: ExperimentConfig) -> Self {
Self {
experiment: Experiment::new(config),
save_dir: None,
}
}
pub fn with_save_dir(
config: ExperimentConfig,
save_dir: impl AsRef<Path>,
) -> std::io::Result<Self> {
let dir = save_dir.as_ref().to_path_buf();
std::fs::create_dir_all(&dir)?;
Ok(Self {
experiment: Experiment::new(config),
save_dir: Some(dir),
})
}
pub fn load(
path: impl AsRef<Path>,
save_dir: Option<PathBuf>,
) -> std::io::Result<Self> {
let bytes = std::fs::read(path)?;
let experiment: Experiment = serde_json::from_slice(&bytes)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
Ok(Self {
experiment,
save_dir,
})
}
pub fn record_result(
&mut self,
key: impl Into<String>,
data: Value,
) -> std::io::Result<()> {
self.experiment.add_result(key, data);
self.auto_save()
}
pub fn summary(&self) -> ExperimentSummary {
ExperimentSummary {
name: self.experiment.config.name.clone(),
hypothesis: self.experiment.config.hypothesis.clone(),
result_count: self.experiment.result_count(),
created_at: self.experiment.created_at.clone(),
updated_at: self.experiment.updated_at.clone(),
}
}
pub fn experiment(&self) -> &Experiment {
&self.experiment
}
pub fn field_stats(&self, field: &str) -> Option<FieldStats> {
self.experiment.field_stats(field)
}
pub fn save(&self) -> std::io::Result<()> {
let dir = match &self.save_dir {
None => return Ok(()),
Some(d) => d,
};
let filename = format!("{}.json", sanitise_filename(&self.experiment.config.name));
let path = dir.join(filename);
let json = serde_json::to_string_pretty(&self.experiment)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(path, json)
}
pub fn save_path(&self) -> Option<PathBuf> {
self.save_dir.as_ref().map(|dir| {
let filename = format!("{}.json", sanitise_filename(&self.experiment.config.name));
dir.join(filename)
})
}
fn auto_save(&self) -> std::io::Result<()> {
self.save()
}
}
#[derive(Default)]
pub struct ExperimentRegistry {
managers: HashMap<String, ExperimentManager>,
}
impl ExperimentRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, manager: ExperimentManager) {
let name = manager.experiment.config.name.clone();
self.managers.insert(name, manager);
}
pub fn get(&self, name: &str) -> Option<&ExperimentManager> {
self.managers.get(name)
}
pub fn get_mut(&mut self, name: &str) -> Option<&mut ExperimentManager> {
self.managers.get_mut(name)
}
pub fn list(&self) -> Vec<ExperimentSummary> {
let mut summaries: Vec<ExperimentSummary> =
self.managers.values().map(|m| m.summary()).collect();
summaries.sort_by(|a, b| a.name.cmp(&b.name));
summaries
}
pub fn len(&self) -> usize {
self.managers.len()
}
pub fn is_empty(&self) -> bool {
self.managers.is_empty()
}
}
fn now_rfc3339() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let s = secs;
let days_since_epoch = s / 86400;
let time_of_day = s % 86400;
let h = time_of_day / 3600;
let m = (time_of_day % 3600) / 60;
let sec = time_of_day % 60;
let (year, month, day) = days_to_ymd(days_since_epoch);
format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{sec:02}Z")
}
fn days_to_ymd(mut days: u64) -> (u64, u64, u64) {
let mut year = 1970u64;
loop {
let leap = is_leap(year);
let days_in_year = if leap { 366 } else { 365 };
if days < days_in_year {
break;
}
days -= days_in_year;
year += 1;
}
let leap = is_leap(year);
let month_days: &[u64] = if leap {
&[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
&[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut month = 1u64;
for &md in month_days {
if days < md {
break;
}
days -= md;
month += 1;
}
(year, month, days + 1)
}
fn is_leap(year: u64) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
fn sanitise_filename(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn make_config(name: &str) -> ExperimentConfig {
ExperimentConfig {
name: name.into(),
hypothesis: format!("Hypothesis for {name}"),
..Default::default()
}
}
#[test]
fn in_memory_manager_records_results() {
let mut mgr = ExperimentManager::in_memory(make_config("test_exp"));
mgr.record_result("run_1", json!({"quality": 0.9})).unwrap();
mgr.record_result("run_2", json!({"quality": 0.8})).unwrap();
assert_eq!(mgr.summary().result_count, 2);
}
#[test]
fn field_stats_computed_correctly() {
let mut mgr = ExperimentManager::in_memory(make_config("stats_test"));
mgr.record_result("r1", json!({"score": 10.0})).unwrap();
mgr.record_result("r2", json!({"score": 20.0})).unwrap();
mgr.record_result("r3", json!({"score": 30.0})).unwrap();
let stats = mgr.field_stats("score").unwrap();
assert_eq!(stats.count, 3);
assert!((stats.mean - 20.0).abs() < 1e-10);
assert!((stats.min - 10.0).abs() < 1e-10);
assert!((stats.max - 30.0).abs() < 1e-10);
}
#[test]
fn field_stats_none_when_no_numeric_field() {
let mgr = ExperimentManager::in_memory(make_config("no_field"));
assert!(mgr.field_stats("nonexistent").is_none());
}
#[test]
fn get_result_by_key() {
let mut mgr = ExperimentManager::in_memory(make_config("key_test"));
mgr.record_result("baseline", json!({"val": 1})).unwrap();
mgr.record_result("variant_a", json!({"val": 2})).unwrap();
let r = mgr.experiment().get_result("baseline").unwrap();
assert_eq!(r.data["val"], 1);
assert!(mgr.experiment().get_result("missing").is_none());
}
#[test]
fn registry_list_and_lookup() {
let mut registry = ExperimentRegistry::new();
registry.register(ExperimentManager::in_memory(make_config("exp_a")));
registry.register(ExperimentManager::in_memory(make_config("exp_b")));
assert_eq!(registry.len(), 2);
let list = registry.list();
assert_eq!(list[0].name, "exp_a");
assert_eq!(list[1].name, "exp_b");
}
#[test]
fn save_dir_creates_json() {
let tmp = std::env::temp_dir().join("eot_experiment_test");
let mut mgr =
ExperimentManager::with_save_dir(make_config("disk_test"), &tmp).unwrap();
mgr.record_result("r1", json!({"x": 42})).unwrap();
let path = mgr.save_path().unwrap();
assert!(path.exists(), "JSON file should have been written");
let content = std::fs::read_to_string(&path).unwrap();
let loaded: Experiment = serde_json::from_str(&content).unwrap();
assert_eq!(loaded.result_count(), 1);
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_dir(tmp);
}
#[test]
fn sanitise_filename_replaces_special_chars() {
assert_eq!(sanitise_filename("my exp/test"), "my_exp_test");
assert_eq!(sanitise_filename("valid-name_123"), "valid-name_123");
}
#[test]
fn now_rfc3339_format() {
let ts = now_rfc3339();
assert!(ts.len() >= 19);
assert!(ts.contains('T'));
assert!(ts.ends_with('Z'));
}
}