use std::fs;
use std::io::Write as _;
use std::path::Path;
use serde::{Deserialize, Serialize};
use super::error::{io_error, json_error};
use super::{ANSWERS_FILE, CompareError, HARDWARE_FILE, QUESTIONS_FILE, SETUP_FILE};
use crate::dataset::{Example, Role};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: String,
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvalQuestion {
pub id: String,
pub topic: String,
pub messages: Vec<ChatMessage>,
pub parent: String,
pub parent_input_tokens: u64,
pub parent_output_tokens: u64,
}
impl EvalQuestion {
#[must_use]
pub fn from_example(example: &Example) -> Option<Self> {
let answer = example
.messages
.iter()
.rfind(|message| message.role == Role::Assistant)?;
let messages: Vec<ChatMessage> = example
.messages
.iter()
.take_while(|message| message.role != Role::Assistant)
.map(|message| ChatMessage {
role: match message.role {
Role::System => "system",
Role::User | Role::Assistant => "user",
}
.to_string(),
content: message.content.clone(),
})
.collect();
if !messages.iter().any(|message| message.role == "user") {
return None;
}
Some(Self {
id: example.id.to_string(),
topic: example.topic.clone(),
messages,
parent: super::strip_reasoning(&answer.content),
parent_input_tokens: example.meta.input_tokens,
parent_output_tokens: example.meta.output_tokens,
})
}
#[must_use]
pub fn text(&self) -> &str {
self.messages
.iter()
.rfind(|message| message.role == "user")
.map_or("", |message| message.content.as_str())
}
}
pub fn read_eval(path: &Path, limit: Option<usize>) -> Result<Vec<EvalQuestion>, CompareError> {
let examples: Vec<Example> = crate::dataset::read(path)?;
let questions: Vec<EvalQuestion> = examples
.iter()
.filter_map(EvalQuestion::from_example)
.take(limit.unwrap_or(usize::MAX))
.collect();
if questions.is_empty() {
return Err(CompareError::NoQuestions(path.to_path_buf()));
}
Ok(questions)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CompareSetup {
pub run: String,
pub compare: String,
pub gguf: String,
pub gguf_sha256: String,
pub quantize: String,
pub llama_cpp: String,
pub base_model: Option<String>,
pub seed: u64,
pub created: String,
pub questions: Vec<EvalQuestion>,
}
impl CompareSetup {
pub fn save(&self, dir: &Path) -> Result<(), CompareError> {
write_json(&dir.join(SETUP_FILE), self)
}
pub fn load(dir: &Path) -> Result<Self, CompareError> {
read_json(&dir.join(SETUP_FILE))
}
}
#[derive(Debug, Serialize)]
struct ChildQuestion<'a> {
id: &'a str,
messages: &'a [ChatMessage],
}
pub fn write_child_questions(dir: &Path, questions: &[EvalQuestion]) -> Result<(), CompareError> {
let path = dir.join(QUESTIONS_FILE);
let mut text = String::new();
for question in questions {
let line = ChildQuestion {
id: &question.id,
messages: &question.messages,
};
text.push_str(&serde_json::to_string(&line).map_err(json_error(&path))?);
text.push('\n');
}
fs::create_dir_all(dir).map_err(io_error(dir))?;
fs::write(&path, text).map_err(io_error(&path))
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct ChildAnswer {
pub id: String,
#[serde(default)]
pub answer: Option<String>,
#[serde(default)]
pub finish: Option<String>,
#[serde(default)]
pub input_tokens: Option<u64>,
#[serde(default)]
pub output_tokens: Option<u64>,
#[serde(default)]
pub seconds: Option<f64>,
#[serde(default)]
pub first_token_seconds: Option<f64>,
#[serde(default)]
pub tokens_per_second: Option<f64>,
#[serde(default)]
pub error: Option<String>,
}
impl ChildAnswer {
#[must_use]
pub fn is_error(&self) -> bool {
self.error.is_some() || self.answer.is_none()
}
}
pub fn read_answers(dir: &Path) -> Result<Vec<ChildAnswer>, CompareError> {
Ok(crate::dataset::read(&dir.join(ANSWERS_FILE))?)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Hardware {
pub build: String,
#[serde(default)]
pub gpus: Vec<String>,
#[serde(default)]
pub cpu: Option<String>,
}
impl Hardware {
#[must_use]
pub fn describe(&self) -> String {
if self.gpus.is_empty() {
return format!(
"{}, CPU ({})",
self.cpu.as_deref().unwrap_or("unknown CPU"),
self.build
);
}
let mut counted: Vec<(&str, usize)> = Vec::new();
for gpu in &self.gpus {
match counted.iter_mut().find(|(name, _)| name == gpu) {
Some((_, count)) => *count += 1,
None => counted.push((gpu, 1)),
}
}
let gpus: Vec<String> = counted
.into_iter()
.map(|(name, count)| format!("{count} x {name}"))
.collect();
format!("{} ({})", gpus.join(" + "), self.build)
}
#[must_use]
pub fn has_gpu(&self) -> bool {
let build = self.build.as_str();
if ["ubuntu-cuda-", "ubuntu-rocm-", "macos-"]
.iter()
.any(|prefix| build.starts_with(prefix))
{
return true;
}
if matches!(build, "ubuntu-x64" | "ubuntu-arm64") {
return false;
}
!self.gpus.is_empty()
}
}
#[must_use]
pub fn read_hardware(dir: &Path) -> Option<Hardware> {
read_json(&dir.join(HARDWARE_FILE)).ok()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Verdict {
Win,
Tie,
Loss,
Unparsed,
Error,
}
impl Verdict {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Win => "win",
Self::Tie => "tie",
Self::Loss => "loss",
Self::Unparsed => "unparsed",
Self::Error => "error",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VerdictLine {
pub id: String,
pub verdict: Verdict,
#[serde(default)]
pub reason: Option<String>,
pub child_first: bool,
}
pub fn read_verdicts(path: &Path) -> Result<Vec<VerdictLine>, CompareError> {
Ok(crate::dataset::read(path)?)
}
pub fn append_verdict(path: &Path, line: &VerdictLine) -> Result<(), CompareError> {
let mut text = serde_json::to_string(line).map_err(json_error(path))?;
text.push('\n');
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(io_error(path))?;
file.write_all(text.as_bytes()).map_err(io_error(path))
}
pub(crate) fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), CompareError> {
let mut text = serde_json::to_string_pretty(value).map_err(json_error(path))?;
text.push('\n');
if let Some(dir) = path.parent() {
fs::create_dir_all(dir).map_err(io_error(dir))?;
}
fs::write(path, text).map_err(io_error(path))
}
pub(crate) fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, CompareError> {
let text = fs::read_to_string(path).map_err(io_error(path))?;
serde_json::from_str(&text).map_err(json_error(path))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dataset::{
Example, FinishReason, Id, Message, Meta, ReasoningKind, Role as DataRole,
};
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn example(id: &str, answer: &str) -> Example {
let message = |role, content: &str| Message {
role,
content: content.to_string(),
reasoning_content: None,
};
Example {
id: Id::of(&[id]),
topic: "ownership".into(),
subtopic: "borrowing".into(),
messages: vec![
message(DataRole::System, "You are an expert."),
message(DataRole::User, &format!("Question {id}?")),
Message {
reasoning_content: Some("hidden".into()),
..message(DataRole::Assistant, answer)
},
],
meta: Meta {
model: "parent".into(),
input_tokens: 40,
output_tokens: 200,
finish_reason: FinishReason::Stop,
reasoning_kind: ReasoningKind::Raw,
excluded: None,
},
}
}
#[test]
fn an_eval_example_becomes_a_question() -> TestResult {
let question = EvalQuestion::from_example(&example("q1", "<think>x</think>Borrow it."))
.ok_or("no question")?;
assert_eq!(question.messages.len(), 2);
assert_eq!(question.messages[0].role, "system");
assert_eq!(question.text(), "Question q1?");
assert_eq!(question.parent, "Borrow it.");
assert_eq!(
(question.parent_input_tokens, question.parent_output_tokens),
(40, 200)
);
let mut no_answer = example("q2", "x");
no_answer.messages.pop();
assert!(EvalQuestion::from_example(&no_answer).is_none());
Ok(())
}
#[test]
fn the_eval_set_is_read_in_order_up_to_the_limit() -> TestResult {
let dir = tempfile::tempdir()?;
let path = dir.path().join("eval.jsonl");
let mut text = String::new();
for id in ["q1", "q2", "q3"] {
text.push_str(&serde_json::to_string(&example(id, "A."))?);
text.push('\n');
}
std::fs::write(&path, text)?;
let two = read_eval(&path, Some(2))?;
assert_eq!(
two.iter().map(EvalQuestion::text).collect::<Vec<_>>(),
["Question q1?", "Question q2?"]
);
assert_eq!(read_eval(&path, None)?.len(), 3);
std::fs::write(&path, "")?;
assert!(matches!(
read_eval(&path, None),
Err(CompareError::NoQuestions(_))
));
Ok(())
}
#[test]
fn verdicts_append_and_read_back() -> TestResult {
let dir = tempfile::tempdir()?;
let path = dir.path().join("verdicts-0.jsonl");
assert!(
read_verdicts(&path)?.is_empty(),
"a missing file is no verdict"
);
for (id, verdict) in [("q1", Verdict::Win), ("q2", Verdict::Unparsed)] {
append_verdict(
&path,
&VerdictLine {
id: id.into(),
verdict,
reason: None,
child_first: true,
},
)?;
}
let read = read_verdicts(&path)?;
assert_eq!(read.len(), 2);
assert_eq!(read[1].verdict, Verdict::Unparsed);
assert!(std::fs::read_to_string(&path)?.contains("\"verdict\":\"unparsed\""));
Ok(())
}
#[test]
fn child_answers_parse_with_missing_fields() -> TestResult {
let ok: ChildAnswer = serde_json::from_str(
r#"{"id":"q1","answer":"A.","finish":"stop","input_tokens":7,"output_tokens":3,
"seconds":0.5,"first_token_seconds":0.1,"tokens_per_second":7.5}"#,
)?;
assert!(!ok.is_error());
let failed: ChildAnswer = serde_json::from_str(r#"{"id":"q2","error":"timed out"}"#)?;
assert!(failed.is_error());
Ok(())
}
fn hardware(build: &str, gpus: &[&str]) -> Hardware {
Hardware {
build: build.into(),
gpus: gpus.iter().map(|gpu| (*gpu).to_string()).collect(),
cpu: Some("AMD EPYC 7413".into()),
}
}
#[test]
fn the_hardware_line_groups_identical_gpus() {
let rocm = "ubuntu-rocm-7.2-x64";
assert_eq!(
hardware(rocm, &["AMD Radeon RX 7800 XT", "AMD Radeon Graphics"]).describe(),
"1 x AMD Radeon RX 7800 XT + 1 x AMD Radeon Graphics (ubuntu-rocm-7.2-x64)"
);
assert_eq!(
hardware("ubuntu-cuda-13.4-x64", &["NVIDIA A40", "NVIDIA A40"]).describe(),
"2 x NVIDIA A40 (ubuntu-cuda-13.4-x64)"
);
assert_eq!(
hardware(
"ubuntu-cuda-13.4-x64",
&["NVIDIA A40", "NVIDIA T4", "NVIDIA A40"]
)
.describe(),
"2 x NVIDIA A40 + 1 x NVIDIA T4 (ubuntu-cuda-13.4-x64)"
);
assert_eq!(
hardware("ubuntu-x64", &[]).describe(),
"AMD EPYC 7413, CPU (ubuntu-x64)"
);
}
#[test]
fn the_build_says_whether_a_gpu_served() {
assert!(hardware("ubuntu-cuda-13.4-x64", &["NVIDIA A40"]).has_gpu());
assert!(hardware("ubuntu-cuda-13.4-x64", &[]).has_gpu());
assert!(hardware("ubuntu-rocm-7.2-x64", &["AMD GPU"]).has_gpu());
assert!(hardware("macos-arm64", &["Apple M2 Pro (Metal)"]).has_gpu());
assert!(hardware("macos-arm64", &[]).has_gpu());
assert!(!hardware("ubuntu-x64", &[]).has_gpu());
assert!(!hardware("ubuntu-x64", &["NVIDIA RTX 3090"]).has_gpu());
assert!(!hardware("ubuntu-arm64", &[]).has_gpu());
}
}