use serde::{Deserialize, Serialize};
pub const EVALUATION_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EvaluationPairRecord {
pub pair_id: String,
pub judge_probability: f64,
pub judge_dependency: bool,
pub analyzer_dependency: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EvaluationRecord {
pub schema_version: u32,
pub recorded_at: String,
pub conflux_version: String,
pub purpose: String,
pub mode: String,
pub outcome: String,
pub duration_ms: u64,
pub expected_answers: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub returned_answers: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub yes_threshold: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pairs: Option<Vec<EvaluationPairRecord>>,
}
pub const OUTCOME_OBSERVED: &str = "observed";
impl EvaluationRecord {
pub fn is_observed(&self) -> bool {
self.outcome == OUTCOME_OBSERVED
}
pub fn to_jsonl(&self) -> serde_json::Result<String> {
let mut line = serde_json::to_string(self)?;
line.push('\n');
Ok(line)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn observed() -> EvaluationRecord {
EvaluationRecord {
schema_version: EVALUATION_SCHEMA_VERSION,
recorded_at: "2026-09-18T00:00:00Z".to_string(),
conflux_version: "0.6.324".to_string(),
purpose: "parallel_dependency".to_string(),
mode: "shadow".to_string(),
outcome: OUTCOME_OBSERVED.to_string(),
duration_ms: 892,
expected_answers: 2,
model: Some("jev-1.13.0".to_string()),
returned_answers: Some(2),
input_tokens: Some(1038),
output_tokens: Some(56),
yes_threshold: Some(0.8),
pairs: Some(vec![EvaluationPairRecord {
pair_id: "sha256:aa".to_string(),
judge_probability: 0.97,
judge_dependency: true,
analyzer_dependency: true,
}]),
}
}
#[test]
fn observed_record_round_trips_as_one_line() {
let record = observed();
let line = record.to_jsonl().expect("record must serialize");
assert!(line.ends_with('\n'));
assert_eq!(line.matches('\n').count(), 1, "one record is one line");
let parsed: EvaluationRecord =
serde_json::from_str(line.trim_end()).expect("record must round-trip");
assert_eq!(parsed, record);
assert!(parsed.is_observed());
}
#[test]
fn failure_record_omits_response_only_fields() {
let record = EvaluationRecord {
outcome: "timeout".to_string(),
model: None,
returned_answers: None,
input_tokens: None,
output_tokens: None,
yes_threshold: None,
pairs: None,
..observed()
};
let line = record.to_jsonl().expect("record must serialize");
for absent in [
"model",
"returned_answers",
"input_tokens",
"output_tokens",
"yes_threshold",
"pairs",
] {
assert!(
!line.contains(absent),
"failure record must not carry `{absent}`: {line}"
);
}
assert!(!record.is_observed());
assert!(line.contains("\"outcome\":\"timeout\""));
}
#[test]
fn every_failure_category_maps_to_its_own_stable_token() {
use crate::judge_command::JudgeFailureCategory as C;
let expected = [
(C::Spawn, "spawn"),
(C::InputTooLarge, "input_too_large"),
(C::Timeout, "timeout"),
(C::Cancelled, "cancelled"),
(C::NonzeroExit, "nonzero_exit"),
(C::InvalidRequest, "invalid_request"),
(C::Authentication, "authentication"),
(C::Transient, "transient"),
(C::OutputTooLarge, "output_too_large"),
(C::InvalidJson, "invalid_json"),
(C::SchemaMismatch, "schema_mismatch"),
(C::Io, "io"),
];
let mut tokens = std::collections::BTreeSet::new();
for (category, token) in expected {
assert_eq!(category.as_str(), token);
assert!(tokens.insert(token), "outcome tokens must be distinct");
assert_ne!(token, OUTCOME_OBSERVED, "a failure is never `observed`");
}
assert_eq!(tokens.len(), 12);
}
}