use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::Metadata;
use crate::protocol::{EvalInfo, ListResult, RunResult};
pub const SESSION_FORMAT: u32 = 1;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Session {
pub format: u32,
pub study: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub study_version: Option<String>,
pub total: usize,
pub created_unix: u64,
pub updated_unix: u64,
#[serde(default)]
pub fingerprints: BTreeMap<String, String>,
pub results: Vec<RunResult>,
}
impl Session {
pub fn new(
study: impl Into<String>,
study_version: Option<String>,
total: usize,
fingerprints: BTreeMap<String, String>,
) -> Self {
let now = now_unix();
Self {
format: SESSION_FORMAT,
study: study.into(),
study_version,
total,
created_unix: now,
updated_unix: now,
fingerprints,
results: Vec::new(),
}
}
pub fn load(path: &str) -> std::io::Result<Option<Session>> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
let session: Session = serde_json::from_str(&text)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
if session.format != SESSION_FORMAT {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"unsupported session format {} (expected {SESSION_FORMAT})",
session.format
),
));
}
Ok(Some(session))
}
pub fn save(&mut self, path: &str) -> std::io::Result<()> {
self.updated_unix = now_unix();
let text = serde_json::to_string_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(path, text)
}
pub fn update(
&mut self,
total: usize,
fingerprints: BTreeMap<String, String>,
results: Vec<RunResult>,
) {
self.total = total;
self.fingerprints = fingerprints;
self.results = results;
}
pub fn stale_keys(&self, current: &BTreeMap<String, String>) -> Vec<String> {
self.results
.iter()
.filter(
|r| match (self.fingerprints.get(&r.eval), current.get(&r.eval)) {
(Some(stored), Some(now)) => stored != now,
(None, Some(_)) => true,
(_, None) => false,
},
)
.map(|r| r.key())
.collect()
}
}
pub fn now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn fingerprints(listing: &ListResult) -> BTreeMap<String, String> {
listing
.evals
.iter()
.map(|e| (e.name.clone(), fingerprint(e)))
.collect()
}
fn fingerprint(eval: &EvalInfo) -> String {
#[derive(Serialize)]
struct Fingerprintable<'a> {
scorers: &'a [String],
max_turns: usize,
axes: Vec<(&'a str, &'a [String])>,
targets: Vec<&'a str>,
samples: Vec<(&'a str, &'a [String])>,
metadata: &'a Metadata,
}
let fp = Fingerprintable {
scorers: &eval.scorers,
max_turns: eval.max_turns,
axes: eval
.axes
.iter()
.map(|a| (a.name.as_str(), a.values.as_slice()))
.collect(),
targets: eval.targets.iter().map(|m| m.label.as_str()).collect(),
samples: eval
.samples
.iter()
.map(|s| (s.id.as_str(), s.tags.as_slice()))
.collect(),
metadata: &eval.metadata,
};
let bytes = serde_json::to_vec(&fp).expect("fingerprint serialization is infallible");
format!("{:016x}", fnv1a(&bytes))
}
fn fnv1a(bytes: &[u8]) -> u64 {
const OFFSET: u64 = 0xcbf29ce484222325;
const PRIME: u64 = 0x00000100000001b3;
let mut h = OFFSET;
for &b in bytes {
h ^= b as u64;
h = h.wrapping_mul(PRIME);
}
h
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::{AxisInfo, SampleInfo, TargetInfo, TranscriptSummary};
fn eval_info(scorers: &[&str]) -> EvalInfo {
EvalInfo {
name: "greet".into(),
description: String::new(),
samples: vec![SampleInfo {
id: "hi".into(),
tags: vec![],
metadata: Default::default(),
}],
next_cursor: None,
scorers: scorers.iter().map(|s| s.to_string()).collect(),
targets: vec![TargetInfo {
label: "sim".into(),
provider: "sim".into(),
available: true,
metadata: Default::default(),
}],
axes: vec![AxisInfo {
name: "effort".into(),
values: vec!["low".into(), "high".into()],
}],
max_turns: 4,
trials: 0,
seed: None,
metadata: Default::default(),
}
}
fn result(eval: &str) -> RunResult {
RunResult {
eval: eval.into(),
sample: "hi".into(),
target: "sim".into(),
params: Default::default(),
trial: 0,
trials: 0,
seed: None,
passed: true,
aggregate: 1.0,
scores: vec![],
transcript: TranscriptSummary::default(),
skipped: false,
}
}
#[test]
fn fingerprint_is_stable_and_change_sensitive() {
let a = fingerprint(&eval_info(&["contains"]));
let b = fingerprint(&eval_info(&["contains"]));
assert_eq!(a, b, "same definition ⇒ same fingerprint");
let c = fingerprint(&eval_info(&["contains", "regex"]));
assert_ne!(a, c, "added scorer ⇒ different fingerprint");
}
#[test]
fn stale_keys_flags_changed_evals_only() {
let listing = ListResult {
evals: vec![eval_info(&["contains"])],
};
let mut session = Session::new("study", None, 1, fingerprints(&listing));
session.results = vec![result("greet")];
assert!(session.stale_keys(&fingerprints(&listing)).is_empty());
let changed = ListResult {
evals: vec![eval_info(&["contains", "regex"])],
};
let stale = session.stale_keys(&fingerprints(&changed));
assert_eq!(stale, vec!["greet/hi@sim".to_string()]);
}
#[test]
fn stale_keys_ignores_dropped_evals() {
let listing = ListResult {
evals: vec![eval_info(&["contains"])],
};
let mut session = Session::new("study", None, 1, fingerprints(&listing));
session.results = vec![result("gone")]; assert!(session.stale_keys(&fingerprints(&listing)).is_empty());
}
#[test]
fn save_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("session.json");
let path = path.to_str().unwrap();
let listing = ListResult {
evals: vec![eval_info(&["contains"])],
};
let mut session = Session::new("study", Some("0.1.0".into()), 3, fingerprints(&listing));
session.results = vec![result("greet")];
session.save(path).unwrap();
let back = Session::load(path).unwrap().expect("loads");
assert_eq!(back.study, "study");
assert_eq!(back.total, 3);
assert_eq!(back.results.len(), 1);
assert_eq!(back.results[0].key(), "greet/hi@sim");
}
#[test]
fn load_missing_is_ok_none() {
assert!(Session::load("/no/such/session.json").unwrap().is_none());
}
#[test]
fn load_corrupt_is_err() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.json");
let path = path.to_str().unwrap();
std::fs::write(path, "{ not valid json").unwrap();
assert!(Session::load(path).is_err());
std::fs::write(path, r#"{"format":999,"study":"x","total":0,"created_unix":0,"updated_unix":0,"results":[]}"#).unwrap();
assert!(Session::load(path).is_err());
}
}