1use std::collections::BTreeMap;
4use std::fmt;
5use std::str::FromStr;
6
7use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
8
9use crate::evaluator::CaseEvaluator;
10use crate::execution::{EvalBudget, EvalTurn, ExecutionSpec, TraceLevel, VerifyCommand};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum OwnerKind {
17 Builtin,
18 Tenant,
19}
20
21impl OwnerKind {
22 pub const fn as_str(self) -> &'static str {
23 match self {
24 Self::Builtin => "builtin",
25 Self::Tenant => "tenant",
26 }
27 }
28}
29
30impl FromStr for OwnerKind {
31 type Err = SnapshotRefError;
32
33 fn from_str(value: &str) -> Result<Self, Self::Err> {
34 match value {
35 "builtin" => Ok(Self::Builtin),
36 "tenant" => Ok(Self::Tenant),
37 other => Err(SnapshotRefError::UnknownOwner(other.to_string())),
38 }
39 }
40}
41
42pub const BUILTIN_SCOPE_SENTINEL: &str = "-";
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum SnapshotLifecycle {
50 Draft,
52 Published,
53 Deprecated,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum Capability {
63 ToolUse,
64 FileEdit,
65 CodeGeneration,
66 InstructionFollowing,
67 LongContext,
68 MultiTurnMemory,
69 Planning,
70 ErrorRecovery,
71 DataAnalysis,
72 WebResearch,
73 ComputerUse,
74 RefusalSafety,
75}
76
77impl Capability {
78 pub const ALL: [Self; 12] = [
79 Self::ToolUse,
80 Self::FileEdit,
81 Self::CodeGeneration,
82 Self::InstructionFollowing,
83 Self::LongContext,
84 Self::MultiTurnMemory,
85 Self::Planning,
86 Self::ErrorRecovery,
87 Self::DataAnalysis,
88 Self::WebResearch,
89 Self::ComputerUse,
90 Self::RefusalSafety,
91 ];
92
93 pub const fn as_str(self) -> &'static str {
94 match self {
95 Self::ToolUse => "tool_use",
96 Self::FileEdit => "file_edit",
97 Self::CodeGeneration => "code_generation",
98 Self::InstructionFollowing => "instruction_following",
99 Self::LongContext => "long_context",
100 Self::MultiTurnMemory => "multi_turn_memory",
101 Self::Planning => "planning",
102 Self::ErrorRecovery => "error_recovery",
103 Self::DataAnalysis => "data_analysis",
104 Self::WebResearch => "web_research",
105 Self::ComputerUse => "computer_use",
106 Self::RefusalSafety => "refusal_safety",
107 }
108 }
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum Difficulty {
114 Easy,
115 Medium,
116 Hard,
117}
118
119impl Difficulty {
120 pub const fn as_str(self) -> &'static str {
121 match self {
122 Self::Easy => "easy",
123 Self::Medium => "medium",
124 Self::Hard => "hard",
125 }
126 }
127}
128
129#[derive(Debug, thiserror::Error, PartialEq, Eq)]
130pub enum SnapshotRefError {
131 #[error("snapshot reference must look like `<owner>/<id>@<version>`")]
132 Malformed,
133 #[error("unknown snapshot owner `{0}`")]
134 UnknownOwner(String),
135 #[error("snapshot reference has an empty {0}")]
136 EmptyPart(&'static str),
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
144pub struct SnapshotRef {
145 pub owner: OwnerKind,
146 pub snapshot_id: String,
147 pub version: String,
148}
149
150impl SnapshotRef {
151 pub fn new(
152 owner: OwnerKind,
153 snapshot_id: impl Into<String>,
154 version: impl Into<String>,
155 ) -> Self {
156 Self {
157 owner,
158 snapshot_id: snapshot_id.into(),
159 version: version.into(),
160 }
161 }
162
163 pub const fn is_builtin(&self) -> bool {
164 matches!(self.owner, OwnerKind::Builtin)
165 }
166}
167
168impl fmt::Display for SnapshotRef {
169 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
170 write!(
171 formatter,
172 "{}/{}@{}",
173 self.owner.as_str(),
174 self.snapshot_id,
175 self.version
176 )
177 }
178}
179
180impl FromStr for SnapshotRef {
181 type Err = SnapshotRefError;
182
183 fn from_str(value: &str) -> Result<Self, Self::Err> {
184 let (owner, rest) = value.split_once('/').ok_or(SnapshotRefError::Malformed)?;
185 let (snapshot_id, version) = rest.split_once('@').ok_or(SnapshotRefError::Malformed)?;
186 if snapshot_id.is_empty() {
187 return Err(SnapshotRefError::EmptyPart("id"));
188 }
189 if version.is_empty() {
190 return Err(SnapshotRefError::EmptyPart("version"));
191 }
192 Ok(Self {
193 owner: owner.parse()?,
194 snapshot_id: snapshot_id.to_string(),
195 version: version.to_string(),
196 })
197 }
198}
199
200impl Serialize for SnapshotRef {
201 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
202 serializer.serialize_str(&self.to_string())
203 }
204}
205
206impl<'de> Deserialize<'de> for SnapshotRef {
207 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
208 let raw = String::deserialize(deserializer)?;
209 raw.parse().map_err(D::Error::custom)
210 }
211}
212
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(rename_all = "camelCase", deny_unknown_fields)]
218pub struct SnapshotInclude {
219 #[serde(rename = "ref")]
220 pub reference: SnapshotRef,
221 #[serde(default)]
223 pub case_ids: Vec<String>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub case_id_prefix: Option<String>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub execution_override: Option<ExecutionSpec>,
231}
232
233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
234#[serde(rename_all = "camelCase", deny_unknown_fields)]
235pub struct EvalCase {
236 pub case_id: String,
237 #[serde(default)]
239 pub digest: String,
240 pub category: String,
241 #[serde(default)]
242 pub turns: Vec<EvalTurn>,
243 #[serde(default)]
246 pub fixtures: BTreeMap<String, String>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub fixture_ref: Option<String>,
249 #[serde(default)]
250 pub verify_commands: Vec<VerifyCommand>,
251 #[serde(default)]
252 pub budget: EvalBudget,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub execution: Option<ExecutionSpec>,
256 #[serde(default = "default_trace_level")]
258 pub requires_trace_level: TraceLevel,
259 pub evaluators: Vec<CaseEvaluator>,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub origin_ref: Option<SnapshotRef>,
263}
264
265fn default_trace_level() -> TraceLevel {
266 TraceLevel::Driver
267}
268
269#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
270#[serde(rename_all = "camelCase", deny_unknown_fields)]
271pub struct EvalSnapshot {
272 pub owner: OwnerKind,
273 pub tenant_id: String,
275 pub project_id: String,
276 pub snapshot_id: String,
277 pub version: String,
278 pub lifecycle: SnapshotLifecycle,
279 pub schema_version: u32,
280 pub digest: String,
281 pub capability: Capability,
282 pub difficulty: Difficulty,
283 #[serde(default)]
284 pub tags: BTreeMap<String, String>,
285 pub execution: ExecutionSpec,
286 #[serde(default)]
287 pub includes: Vec<SnapshotInclude>,
288 pub cases: Vec<EvalCase>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub ephemeral_expires_at_ms: Option<i64>,
293 pub created_at_ms: i64,
294}
295
296impl EvalSnapshot {
297 pub fn reference(&self) -> SnapshotRef {
298 SnapshotRef::new(self.owner, self.snapshot_id.clone(), self.version.clone())
299 }
300
301 pub const fn is_ephemeral(&self) -> bool {
302 self.ephemeral_expires_at_ms.is_some()
303 }
304
305 pub fn case(&self, case_id: &str) -> Option<&EvalCase> {
306 self.cases.iter().find(|case| case.case_id == case_id)
307 }
308}
309
310#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
311#[serde(rename_all = "camelCase", deny_unknown_fields)]
312pub struct RegisterSnapshotRequest {
313 pub snapshot_id: String,
314 pub version: String,
315 pub capability: Capability,
316 pub difficulty: Difficulty,
317 #[serde(default)]
318 pub tags: BTreeMap<String, String>,
319 pub execution: ExecutionSpec,
320 #[serde(default)]
321 pub includes: Vec<SnapshotInclude>,
322 #[serde(default)]
323 pub cases: Vec<EvalCase>,
324 #[serde(default)]
327 pub ephemeral: bool,
328 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub ephemeral_ttl_ms: Option<i64>,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub expected_digest: Option<String>,
334}
335
336#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
337#[serde(rename_all = "camelCase", deny_unknown_fields)]
338pub struct ListSnapshotsRequest {
339 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub owner: Option<OwnerKind>,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
342 pub capability: Option<Capability>,
343 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub snapshot_id: Option<String>,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub cursor: Option<String>,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub limit: Option<usize>,
349}
350
351#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
352#[serde(rename_all = "camelCase")]
353pub struct SnapshotSummary {
354 #[serde(rename = "ref")]
355 pub reference: SnapshotRef,
356 pub lifecycle: SnapshotLifecycle,
357 pub capability: Capability,
358 pub difficulty: Difficulty,
359 pub digest: String,
360 pub case_count: usize,
361 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub ephemeral_expires_at_ms: Option<i64>,
363 pub created_at_ms: i64,
364}
365
366#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
367#[serde(rename_all = "camelCase")]
368pub struct SnapshotPage {
369 pub items: Vec<SnapshotSummary>,
370 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub next_cursor: Option<String>,
372}
373
374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
375#[serde(rename_all = "camelCase", deny_unknown_fields)]
376pub struct GetSnapshotRequest {
377 #[serde(rename = "ref")]
378 pub reference: SnapshotRef,
379}
380
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
382#[serde(rename_all = "camelCase", deny_unknown_fields)]
383pub struct SnapshotLifecycleRequest {
384 #[serde(rename = "ref")]
385 pub reference: SnapshotRef,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub replacement: Option<SnapshotRef>,
389}
390
391#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
394#[serde(rename_all = "camelCase", deny_unknown_fields)]
395pub struct SnapshotBundle {
396 pub schema_version: u32,
397 pub snapshots: Vec<EvalSnapshot>,
398 pub exported_at_ms: i64,
399}
400
401#[cfg(test)]
402mod tests {
403 use super::{OwnerKind, SnapshotRef, SnapshotRefError};
404
405 #[test]
406 fn snapshot_refs_round_trip_through_their_wire_form() {
407 let parsed: SnapshotRef = "builtin/tool-use@2026-08-20.1".parse().unwrap();
408 assert_eq!(parsed.owner, OwnerKind::Builtin);
409 assert_eq!(parsed.snapshot_id, "tool-use");
410 assert_eq!(parsed.version, "2026-08-20.1");
411 assert_eq!(parsed.to_string(), "builtin/tool-use@2026-08-20.1");
412
413 let json = serde_json::to_string(&parsed).unwrap();
414 assert_eq!(json, "\"builtin/tool-use@2026-08-20.1\"");
415 assert_eq!(serde_json::from_str::<SnapshotRef>(&json).unwrap(), parsed);
416 }
417
418 #[test]
419 fn malformed_refs_are_rejected_rather_than_silently_defaulted() {
420 assert_eq!(
421 "tool-use@1".parse::<SnapshotRef>().unwrap_err(),
422 SnapshotRefError::Malformed
423 );
424 assert_eq!(
425 "builtin/tool-use".parse::<SnapshotRef>().unwrap_err(),
426 SnapshotRefError::Malformed
427 );
428 assert_eq!(
429 "builtin/tool-use@".parse::<SnapshotRef>().unwrap_err(),
430 SnapshotRefError::EmptyPart("version")
431 );
432 assert_eq!(
433 "platform/tool-use@1".parse::<SnapshotRef>().unwrap_err(),
434 SnapshotRefError::UnknownOwner("platform".into())
435 );
436 }
437}