Skip to main content

everruns_core/
eval.rs

1// Eval domain types
2//
3// Design Decision: Evals are user-facing behavioral tests for agents.
4// Each eval case creates a real session — same behavior as production, debuggable.
5// Scorers return 0.0–1.0 (not binary) to support nuanced grading.
6//
7// Design Decision: EvalTarget is the session setup contract.
8// Resolution order: EvalRun.target → EvalCase.target → Eval.target → org default harness.
9// EvalTarget::Session mirrors CreateSessionRequest params; EvalTarget::App references a deployed app.
10// EvalCaseResult stores both a live reference and a frozen snapshot for reproducibility.
11//
12// See specs/evals.md for full specification.
13
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16use std::collections::BTreeMap;
17use uuid::Uuid;
18
19use crate::typed_id::{
20    AgentId, AppId, EvalCaseId, EvalDatasetId, EvalId, EvalResultId, EvalRunId, HarnessId,
21    SessionId,
22};
23
24#[cfg(feature = "openapi")]
25use utoipa::ToSchema;
26
27/// Named session file to collect after an eval case completes.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29#[cfg_attr(feature = "openapi", derive(ToSchema))]
30pub struct ArtifactSpec {
31    /// Export key for this artifact (for example `patch` or `log`).
32    pub name: String,
33    /// Absolute path in the session filesystem.
34    pub path: String,
35}
36
37// ============================================
38// Eval Target
39// ============================================
40
41/// Defines how to instantiate a session for an eval case.
42///
43/// Two modes:
44/// - `Session`: mirrors `CreateSessionRequest` — full control over session creation parameters.
45/// - `App`: references a deployed app by ID.
46///
47/// Resolution order: EvalRun.target → EvalCase.target → Eval.target → org default harness.
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
49#[cfg_attr(feature = "openapi", derive(ToSchema))]
50#[serde(tag = "type", rename_all = "snake_case")]
51pub enum EvalTarget {
52    /// Session creation parameters (mirrors CreateSessionRequest).
53    Session {
54        /// Harness for the session. If omitted, org default harness is used.
55        #[serde(skip_serializing_if = "Option::is_none")]
56        #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
57        harness_id: Option<HarnessId>,
58        /// Addressable harness name (alternative to harness_id).
59        #[serde(skip_serializing_if = "Option::is_none")]
60        harness_name: Option<String>,
61        /// Agent to work in this session.
62        #[serde(skip_serializing_if = "Option::is_none")]
63        #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
64        agent_id: Option<AgentId>,
65        /// LLM model override.
66        #[serde(skip_serializing_if = "Option::is_none")]
67        model_id: Option<String>,
68        /// System prompt override (prepended to agent prompt).
69        #[serde(skip_serializing_if = "Option::is_none")]
70        system_prompt: Option<String>,
71        /// Max LLM iterations per turn.
72        #[serde(skip_serializing_if = "Option::is_none")]
73        max_iterations: Option<usize>,
74    },
75    /// Reference to a deployed app.
76    App {
77        #[cfg_attr(feature = "openapi", schema(value_type = String))]
78        app_id: AppId,
79    },
80    /// Label-only target for externally-executed runs (e.g. imported from Mira).
81    ///
82    /// Carries provider/model labels and opaque params instead of session setup:
83    /// external runs are ingested already-complete, so everruns never builds a
84    /// session from this. Mirrors a provider-agnostic `(provider, model)` pair.
85    External {
86        provider: String,
87        model: String,
88        #[serde(default, skip_serializing_if = "Option::is_none")]
89        params: Option<serde_json::Value>,
90    },
91}
92
93// ============================================
94// Eval Status
95// ============================================
96
97/// Eval lifecycle status (standard building-block lifecycle).
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
99#[cfg_attr(feature = "openapi", derive(ToSchema))]
100#[serde(rename_all = "lowercase")]
101pub enum EvalStatus {
102    Active,
103    Archived,
104    Deleted,
105}
106
107impl std::fmt::Display for EvalStatus {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        match self {
110            EvalStatus::Active => write!(f, "active"),
111            EvalStatus::Archived => write!(f, "archived"),
112            EvalStatus::Deleted => write!(f, "deleted"),
113        }
114    }
115}
116
117impl From<&str> for EvalStatus {
118    fn from(s: &str) -> Self {
119        match s {
120            "archived" => EvalStatus::Archived,
121            "deleted" => EvalStatus::Deleted,
122            _ => EvalStatus::Active,
123        }
124    }
125}
126
127// ============================================
128// Eval Run Status
129// ============================================
130
131/// Status of an eval run.
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
133#[cfg_attr(feature = "openapi", derive(ToSchema))]
134#[serde(rename_all = "lowercase")]
135pub enum EvalRunStatus {
136    Pending,
137    Running,
138    Completed,
139    Failed,
140    Cancelled,
141}
142
143impl std::fmt::Display for EvalRunStatus {
144    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145        match self {
146            EvalRunStatus::Pending => write!(f, "pending"),
147            EvalRunStatus::Running => write!(f, "running"),
148            EvalRunStatus::Completed => write!(f, "completed"),
149            EvalRunStatus::Failed => write!(f, "failed"),
150            EvalRunStatus::Cancelled => write!(f, "cancelled"),
151        }
152    }
153}
154
155impl From<&str> for EvalRunStatus {
156    fn from(s: &str) -> Self {
157        match s {
158            "running" => EvalRunStatus::Running,
159            "completed" => EvalRunStatus::Completed,
160            "failed" => EvalRunStatus::Failed,
161            "cancelled" => EvalRunStatus::Cancelled,
162            _ => EvalRunStatus::Pending,
163        }
164    }
165}
166
167// ============================================
168// Eval Run Source
169// ============================================
170
171/// Where an eval run came from.
172///
173/// `Internal` runs are executed by everruns (sessions spawned per case).
174/// `External` runs are ingested already-complete from an external eval system
175/// (e.g. Mira) via the import API; everruns hosts and visualizes them but never
176/// executes them. See proposals/mira-results-publishing.md.
177#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
178#[cfg_attr(feature = "openapi", derive(ToSchema))]
179#[serde(rename_all = "lowercase")]
180pub enum EvalRunSource {
181    #[default]
182    Internal,
183    External,
184}
185
186impl std::fmt::Display for EvalRunSource {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        match self {
189            EvalRunSource::Internal => write!(f, "internal"),
190            EvalRunSource::External => write!(f, "external"),
191        }
192    }
193}
194
195impl From<&str> for EvalRunSource {
196    fn from(s: &str) -> Self {
197        match s {
198            "external" => EvalRunSource::External,
199            _ => EvalRunSource::Internal,
200        }
201    }
202}
203
204// ============================================
205// Case Result Status
206// ============================================
207
208/// Status of an individual eval case result.
209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
210#[cfg_attr(feature = "openapi", derive(ToSchema))]
211#[serde(rename_all = "lowercase")]
212pub enum CaseResultStatus {
213    Pending,
214    Running,
215    Passed,
216    Failed,
217    Errored,
218    Timeout,
219    /// Case was not executed (e.g. an external system skipped it: model
220    /// unavailable, filtered out). Excluded from pass/fail tallies.
221    Skipped,
222}
223
224impl std::fmt::Display for CaseResultStatus {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        match self {
227            CaseResultStatus::Pending => write!(f, "pending"),
228            CaseResultStatus::Running => write!(f, "running"),
229            CaseResultStatus::Passed => write!(f, "passed"),
230            CaseResultStatus::Failed => write!(f, "failed"),
231            CaseResultStatus::Errored => write!(f, "errored"),
232            CaseResultStatus::Timeout => write!(f, "timeout"),
233            CaseResultStatus::Skipped => write!(f, "skipped"),
234        }
235    }
236}
237
238impl From<&str> for CaseResultStatus {
239    fn from(s: &str) -> Self {
240        match s {
241            "running" => CaseResultStatus::Running,
242            "passed" => CaseResultStatus::Passed,
243            "failed" => CaseResultStatus::Failed,
244            "errored" => CaseResultStatus::Errored,
245            "timeout" => CaseResultStatus::Timeout,
246            "skipped" => CaseResultStatus::Skipped,
247            _ => CaseResultStatus::Pending,
248        }
249    }
250}
251
252// ============================================
253// Scorer types
254// ============================================
255
256/// A scoring rule applied to eval case output.
257#[derive(Debug, Clone, Serialize, Deserialize)]
258#[cfg_attr(feature = "openapi", derive(ToSchema))]
259#[serde(tag = "type", rename_all = "snake_case")]
260pub enum Scorer {
261    /// Final assistant message contains substring.
262    Contains {
263        text: String,
264        #[serde(default = "default_weight")]
265        weight: f64,
266    },
267    /// Final assistant message does NOT contain substring.
268    NotContains {
269        text: String,
270        #[serde(default = "default_weight")]
271        weight: f64,
272    },
273    /// Final assistant message matches regex pattern.
274    Regex {
275        pattern: String,
276        #[serde(default = "default_weight")]
277        weight: f64,
278    },
279    /// Agent called named tool at least `min` times.
280    ToolCalled {
281        tool: String,
282        #[serde(default = "default_min_one")]
283        min: u32,
284        #[serde(default = "default_weight")]
285        weight: f64,
286    },
287    /// Agent did NOT call named tool.
288    ToolNotCalled {
289        tool: String,
290        #[serde(default = "default_weight")]
291        weight: f64,
292    },
293    /// Total tool calls within range.
294    ToolCallCount {
295        #[serde(skip_serializing_if = "Option::is_none")]
296        min: Option<u32>,
297        #[serde(skip_serializing_if = "Option::is_none")]
298        max: Option<u32>,
299        #[serde(default = "default_weight")]
300        weight: f64,
301    },
302    /// Completed within N turns.
303    TurnsWithin {
304        max: u32,
305        #[serde(default = "default_weight")]
306        weight: f64,
307    },
308    /// Session filesystem file contains substring.
309    FileContains {
310        path: String,
311        text: String,
312        #[serde(default = "default_weight")]
313        weight: f64,
314    },
315    /// Final assistant message parses as JSON matching schema.
316    JsonSchema {
317        schema: serde_json::Value,
318        #[serde(default = "default_weight")]
319        weight: f64,
320    },
321}
322
323impl Scorer {
324    /// Stable kind tag for this scorer, matching the serde `type` discriminant.
325    ///
326    /// Scores are persisted as an ordered `Vec<Score>` that carries no scorer
327    /// identity, so consumers that need a name (e.g. dataset export) join this
328    /// positionally against the case's `scorers`. Keep this exhaustive so adding
329    /// a variant forces a decision here.
330    pub fn kind(&self) -> &'static str {
331        match self {
332            Scorer::Contains { .. } => "contains",
333            Scorer::NotContains { .. } => "not_contains",
334            Scorer::Regex { .. } => "regex",
335            Scorer::ToolCalled { .. } => "tool_called",
336            Scorer::ToolNotCalled { .. } => "tool_not_called",
337            Scorer::ToolCallCount { .. } => "tool_call_count",
338            Scorer::TurnsWithin { .. } => "turns_within",
339            Scorer::FileContains { .. } => "file_contains",
340            Scorer::JsonSchema { .. } => "json_schema",
341        }
342    }
343}
344
345fn default_weight() -> f64 {
346    1.0
347}
348
349fn default_min_one() -> u32 {
350    1
351}
352
353// ============================================
354// Score result
355// ============================================
356
357/// Result from a single scorer evaluation.
358#[derive(Debug, Clone, Serialize, Deserialize)]
359#[cfg_attr(feature = "openapi", derive(ToSchema))]
360pub struct Score {
361    /// Whether this scorer passed.
362    pub pass: bool,
363    /// Score value 0.0–1.0.
364    pub value: f64,
365    /// Human-readable explanation.
366    pub reason: String,
367}
368
369// ============================================
370// Run summary
371// ============================================
372
373/// Aggregate metrics for a completed eval run.
374#[derive(Debug, Clone, Serialize, Deserialize)]
375#[cfg_attr(feature = "openapi", derive(ToSchema))]
376pub struct RunSummary {
377    pub total: u32,
378    pub passed: u32,
379    pub failed: u32,
380    pub errored: u32,
381    pub pass_rate: f64,
382    pub avg_score: f64,
383    pub avg_turns: f64,
384    pub avg_latency_ms: u64,
385    pub total_input_tokens: u64,
386    pub total_output_tokens: u64,
387}
388
389// ============================================
390// Input message for eval cases
391// ============================================
392
393/// A message to send to the agent during an eval case.
394#[derive(Debug, Clone, Serialize, Deserialize)]
395#[cfg_attr(feature = "openapi", derive(ToSchema))]
396pub struct EvalInputMessage {
397    /// The text content to send.
398    pub content: String,
399}
400
401// ============================================
402// Main entity structs
403// ============================================
404
405/// An eval: a named collection of test cases for an agent.
406#[derive(Debug, Clone, Serialize, Deserialize)]
407#[cfg_attr(feature = "openapi", derive(ToSchema))]
408pub struct Eval {
409    /// External identifier (eval_<32-hex>). Shown as "id" in API.
410    #[serde(rename = "id")]
411    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "eval_01933b5a000070008000000000000001"))]
412    pub public_id: EvalId,
413    /// Internal UUID primary key. Never exposed in API.
414    #[serde(skip, default = "Uuid::nil")]
415    pub internal_id: Uuid,
416    /// Organization ID. Internal only.
417    #[serde(skip, default)]
418    pub org_id: i64,
419    /// Display name.
420    pub name: String,
421    /// Optional description.
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub description: Option<String>,
424    /// Session setup target. Defines how to create sessions for eval cases.
425    #[serde(skip_serializing_if = "Option::is_none")]
426    pub target: Option<EvalTarget>,
427    /// Optional default model override for runs.
428    #[serde(skip_serializing_if = "Option::is_none")]
429    pub model_override: Option<String>,
430    /// Organization tags.
431    #[serde(default)]
432    pub tags: Vec<String>,
433    /// Lifecycle status.
434    pub status: EvalStatus,
435    /// Number of cases.
436    #[serde(default)]
437    pub case_count: i64,
438    /// Last run summary (if any).
439    #[serde(skip_serializing_if = "Option::is_none")]
440    pub last_run: Option<EvalRunSummaryView>,
441    pub created_at: DateTime<Utc>,
442    pub updated_at: DateTime<Utc>,
443    #[serde(skip_serializing_if = "Option::is_none")]
444    pub archived_at: Option<DateTime<Utc>>,
445    #[serde(skip_serializing_if = "Option::is_none")]
446    pub deleted_at: Option<DateTime<Utc>>,
447}
448
449/// Compact run summary for listing evals.
450#[derive(Debug, Clone, Serialize, Deserialize)]
451#[cfg_attr(feature = "openapi", derive(ToSchema))]
452pub struct EvalRunSummaryView {
453    #[serde(rename = "id")]
454    #[cfg_attr(feature = "openapi", schema(value_type = String))]
455    pub public_id: EvalRunId,
456    pub status: EvalRunStatus,
457    #[serde(skip_serializing_if = "Option::is_none")]
458    pub summary: Option<RunSummary>,
459    pub created_at: DateTime<Utc>,
460}
461
462/// A single test case within an eval.
463#[derive(Debug, Clone, Serialize, Deserialize)]
464#[cfg_attr(feature = "openapi", derive(ToSchema))]
465pub struct EvalCase {
466    /// External identifier (evalcase_<32-hex>).
467    #[serde(rename = "id")]
468    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "evalcase_01933b5a000070008000000000000001"))]
469    pub public_id: EvalCaseId,
470    #[serde(skip, default = "Uuid::nil")]
471    pub internal_id: Uuid,
472    pub name: String,
473    #[serde(skip_serializing_if = "Option::is_none")]
474    pub description: Option<String>,
475    /// Optional per-case target override.
476    #[serde(skip_serializing_if = "Option::is_none")]
477    pub target: Option<EvalTarget>,
478    #[serde(default)]
479    pub tags: Vec<String>,
480    /// Input messages sent sequentially.
481    pub conversation: Vec<EvalInputMessage>,
482    /// Verification messages sent after conversation completes and session idles.
483    /// Scorers run after post messages complete (not after conversation).
484    #[serde(skip_serializing_if = "Option::is_none")]
485    pub post: Option<Vec<EvalInputMessage>>,
486    /// Session files to collect after scoring completes.
487    #[serde(skip_serializing_if = "Option::is_none")]
488    pub artifacts: Option<Vec<ArtifactSpec>>,
489    /// Scoring rules.
490    pub scorers: Vec<Scorer>,
491    /// Max agent turns (default: 10).
492    #[serde(skip_serializing_if = "Option::is_none")]
493    pub max_turns: Option<u32>,
494    /// Per-case timeout in seconds (default: 120).
495    #[serde(skip_serializing_if = "Option::is_none")]
496    pub timeout_seconds: Option<u32>,
497    /// Display order.
498    pub position: i32,
499    pub created_at: DateTime<Utc>,
500    pub updated_at: DateTime<Utc>,
501}
502
503/// An eval run: one execution of all/some cases.
504#[derive(Debug, Clone, Serialize, Deserialize)]
505#[cfg_attr(feature = "openapi", derive(ToSchema))]
506pub struct EvalRun {
507    #[serde(rename = "id")]
508    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "evalrun_01933b5a000070008000000000000001"))]
509    pub public_id: EvalRunId,
510    #[serde(skip, default = "Uuid::nil")]
511    pub internal_id: Uuid,
512    #[serde(skip, default)]
513    pub org_id: i64,
514    /// Optional per-run target override.
515    #[serde(skip_serializing_if = "Option::is_none")]
516    pub target: Option<EvalTarget>,
517    /// Model override for this run.
518    #[serde(skip_serializing_if = "Option::is_none")]
519    pub model_override: Option<String>,
520    /// Only run cases matching these tags.
521    #[serde(skip_serializing_if = "Option::is_none")]
522    pub filter_tags: Option<Vec<String>>,
523    pub status: EvalRunStatus,
524    /// Whether everruns executed this run (`internal`) or it was imported from
525    /// an external eval system (`external`).
526    #[serde(default)]
527    pub source: EvalRunSource,
528    /// Provenance for external runs: which system produced them, version, link
529    /// back, and any environment labels. `None` for internal runs. Open-vocab
530    /// JSON so new attribution fields need no schema change.
531    #[serde(default, skip_serializing_if = "Option::is_none")]
532    pub attribution: Option<serde_json::Value>,
533    /// What triggered this run.
534    pub triggered_by: String,
535    #[serde(skip_serializing_if = "Option::is_none")]
536    pub started_at: Option<DateTime<Utc>>,
537    #[serde(skip_serializing_if = "Option::is_none")]
538    pub completed_at: Option<DateTime<Utc>>,
539    /// Aggregate metrics (set on completion).
540    #[serde(skip_serializing_if = "Option::is_none")]
541    pub summary: Option<RunSummary>,
542    /// Case results (populated on detail view).
543    #[serde(default, skip_serializing_if = "Vec::is_empty")]
544    pub results: Vec<EvalCaseResult>,
545    pub created_at: DateTime<Utc>,
546    pub updated_at: DateTime<Utc>,
547}
548
549// ============================================
550// Eval Run Dataset (async dataset export — specs/dataset-export.md)
551// ============================================
552
553/// Status of an async dataset export.
554#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
555#[cfg_attr(feature = "openapi", derive(ToSchema))]
556#[serde(rename_all = "lowercase")]
557pub enum EvalDatasetStatus {
558    /// Enqueued, export not started yet.
559    Pending,
560    /// Export in progress.
561    Running,
562    /// Export finished; NDJSON `body` is available on the detail view.
563    Completed,
564    /// Export failed; see `error_message`.
565    Failed,
566}
567
568impl std::fmt::Display for EvalDatasetStatus {
569    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
570        match self {
571            EvalDatasetStatus::Pending => write!(f, "pending"),
572            EvalDatasetStatus::Running => write!(f, "running"),
573            EvalDatasetStatus::Completed => write!(f, "completed"),
574            EvalDatasetStatus::Failed => write!(f, "failed"),
575        }
576    }
577}
578
579impl From<&str> for EvalDatasetStatus {
580    fn from(s: &str) -> Self {
581        match s {
582            "running" => EvalDatasetStatus::Running,
583            "completed" => EvalDatasetStatus::Completed,
584            "failed" => EvalDatasetStatus::Failed,
585            _ => EvalDatasetStatus::Pending,
586        }
587    }
588}
589
590/// An async dataset-export handle: the durable result of enqueuing a dataset
591/// export from a completed eval run. The `body` (NDJSON) is only populated on
592/// the `GET .../dataset/{dataset_id}` detail view once `status` is `completed`.
593#[derive(Debug, Clone, Serialize, Deserialize)]
594#[cfg_attr(feature = "openapi", derive(ToSchema))]
595pub struct EvalRunDataset {
596    #[serde(rename = "id")]
597    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "evaldataset_01933b5a000070008000000000000001"))]
598    pub public_id: EvalDatasetId,
599    /// The eval run this dataset was exported from.
600    #[cfg_attr(feature = "openapi", schema(value_type = String))]
601    pub eval_run_id: EvalRunId,
602    pub status: EvalDatasetStatus,
603    /// Number of NDJSON records (surviving cases). Set on completion.
604    #[serde(skip_serializing_if = "Option::is_none")]
605    pub record_count: Option<u64>,
606    /// Failure detail when `status` is `failed`.
607    #[serde(skip_serializing_if = "Option::is_none")]
608    pub error_message: Option<String>,
609    /// The produced NDJSON. Only present on the detail view once completed.
610    #[serde(skip_serializing_if = "Option::is_none")]
611    pub body: Option<String>,
612    pub created_at: DateTime<Utc>,
613    pub updated_at: DateTime<Utc>,
614}
615
616/// Result of a single case within a run.
617#[derive(Debug, Clone, Serialize, Deserialize)]
618#[cfg_attr(feature = "openapi", derive(ToSchema))]
619pub struct EvalCaseResult {
620    #[serde(rename = "id")]
621    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "evalresult_01933b5a000070008000000000000001"))]
622    pub public_id: EvalResultId,
623    #[serde(skip, default = "Uuid::nil")]
624    pub internal_id: Uuid,
625    /// The case this result is for.
626    #[cfg_attr(feature = "openapi", schema(value_type = String))]
627    pub eval_case_id: EvalCaseId,
628    /// Case name (denormalized for display).
629    #[serde(skip_serializing_if = "Option::is_none")]
630    pub case_name: Option<String>,
631    /// Session created for this case (browsable in UI).
632    #[serde(skip_serializing_if = "Option::is_none")]
633    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
634    pub session_id: Option<SessionId>,
635    /// Resolved target used for this result (live reference).
636    #[serde(skip_serializing_if = "Option::is_none")]
637    pub target: Option<EvalTarget>,
638    /// Frozen snapshot of the resolved target at execution time.
639    #[serde(skip_serializing_if = "Option::is_none")]
640    pub target_snapshot: Option<EvalTarget>,
641    pub status: CaseResultStatus,
642    /// Per-scorer results.
643    #[serde(skip_serializing_if = "Option::is_none")]
644    pub scores: Option<serde_json::Value>,
645    /// External scorer metadata captured during deferred write-back.
646    #[serde(skip_serializing_if = "Option::is_none")]
647    pub metadata: Option<serde_json::Value>,
648    /// Turn count.
649    #[serde(skip_serializing_if = "Option::is_none")]
650    pub turns: Option<u32>,
651    /// Execution time in milliseconds.
652    #[serde(skip_serializing_if = "Option::is_none")]
653    pub latency_ms: Option<u64>,
654    /// Token usage.
655    #[serde(skip_serializing_if = "Option::is_none")]
656    pub input_tokens: Option<u64>,
657    #[serde(skip_serializing_if = "Option::is_none")]
658    pub output_tokens: Option<u64>,
659    /// Error message if errored.
660    #[serde(skip_serializing_if = "Option::is_none")]
661    pub error_message: Option<String>,
662    /// Collected session file contents keyed by artifact name.
663    #[serde(skip_serializing_if = "Option::is_none")]
664    pub artifacts: Option<BTreeMap<String, String>>,
665    pub created_at: DateTime<Utc>,
666    pub updated_at: DateTime<Utc>,
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672
673    #[test]
674    fn test_eval_status_display() {
675        assert_eq!(EvalStatus::Active.to_string(), "active");
676        assert_eq!(EvalStatus::Archived.to_string(), "archived");
677        assert_eq!(EvalStatus::Deleted.to_string(), "deleted");
678    }
679
680    #[test]
681    fn test_eval_status_from_str() {
682        assert_eq!(EvalStatus::from("active"), EvalStatus::Active);
683        assert_eq!(EvalStatus::from("archived"), EvalStatus::Archived);
684        assert_eq!(EvalStatus::from("deleted"), EvalStatus::Deleted);
685        assert_eq!(EvalStatus::from("unknown"), EvalStatus::Active);
686    }
687
688    #[test]
689    fn test_eval_status_serde_roundtrip() {
690        let json = serde_json::to_string(&EvalStatus::Archived).unwrap();
691        assert_eq!(json, r#""archived""#);
692        let parsed: EvalStatus = serde_json::from_str(&json).unwrap();
693        assert_eq!(parsed, EvalStatus::Archived);
694    }
695
696    #[test]
697    fn test_eval_run_status_display() {
698        assert_eq!(EvalRunStatus::Pending.to_string(), "pending");
699        assert_eq!(EvalRunStatus::Running.to_string(), "running");
700        assert_eq!(EvalRunStatus::Completed.to_string(), "completed");
701        assert_eq!(EvalRunStatus::Failed.to_string(), "failed");
702        assert_eq!(EvalRunStatus::Cancelled.to_string(), "cancelled");
703    }
704
705    #[test]
706    fn test_eval_run_status_from_str() {
707        assert_eq!(EvalRunStatus::from("pending"), EvalRunStatus::Pending);
708        assert_eq!(EvalRunStatus::from("running"), EvalRunStatus::Running);
709        assert_eq!(EvalRunStatus::from("completed"), EvalRunStatus::Completed);
710        assert_eq!(EvalRunStatus::from("failed"), EvalRunStatus::Failed);
711        assert_eq!(EvalRunStatus::from("cancelled"), EvalRunStatus::Cancelled);
712        assert_eq!(EvalRunStatus::from("unknown"), EvalRunStatus::Pending);
713    }
714
715    #[test]
716    fn test_case_result_status_display() {
717        assert_eq!(CaseResultStatus::Pending.to_string(), "pending");
718        assert_eq!(CaseResultStatus::Passed.to_string(), "passed");
719        assert_eq!(CaseResultStatus::Failed.to_string(), "failed");
720        assert_eq!(CaseResultStatus::Errored.to_string(), "errored");
721        assert_eq!(CaseResultStatus::Timeout.to_string(), "timeout");
722    }
723
724    #[test]
725    fn test_case_result_status_from_str() {
726        assert_eq!(CaseResultStatus::from("passed"), CaseResultStatus::Passed);
727        assert_eq!(CaseResultStatus::from("failed"), CaseResultStatus::Failed);
728        assert_eq!(CaseResultStatus::from("errored"), CaseResultStatus::Errored);
729        assert_eq!(CaseResultStatus::from("timeout"), CaseResultStatus::Timeout);
730        assert_eq!(CaseResultStatus::from("unknown"), CaseResultStatus::Pending);
731    }
732
733    #[test]
734    fn test_scorer_serde_roundtrip() {
735        let scorer = Scorer::Contains {
736            text: "hello".to_string(),
737            weight: 1.0,
738        };
739        let json = serde_json::to_value(&scorer).unwrap();
740        assert_eq!(json["type"], "contains");
741        assert_eq!(json["text"], "hello");
742        assert_eq!(json["weight"], 1.0);
743
744        let parsed: Scorer = serde_json::from_value(json).unwrap();
745        match parsed {
746            Scorer::Contains { text, weight } => {
747                assert_eq!(text, "hello");
748                assert_eq!(weight, 1.0);
749            }
750            _ => panic!("wrong variant"),
751        }
752    }
753
754    #[test]
755    fn test_scorer_tool_called_defaults() {
756        let json = r#"{"type": "tool_called", "tool": "read_file"}"#;
757        let scorer: Scorer = serde_json::from_str(json).unwrap();
758        match scorer {
759            Scorer::ToolCalled { tool, min, weight } => {
760                assert_eq!(tool, "read_file");
761                assert_eq!(min, 1);
762                assert_eq!(weight, 1.0);
763            }
764            _ => panic!("wrong variant"),
765        }
766    }
767
768    #[test]
769    fn test_score_serde() {
770        let score = Score {
771            pass: true,
772            value: 0.85,
773            reason: "Output contains expected text".to_string(),
774        };
775        let json = serde_json::to_value(&score).unwrap();
776        assert_eq!(json["pass"], true);
777        assert_eq!(json["value"], 0.85);
778    }
779
780    #[test]
781    fn test_run_summary_serde() {
782        let summary = RunSummary {
783            total: 10,
784            passed: 8,
785            failed: 1,
786            errored: 1,
787            pass_rate: 0.8,
788            avg_score: 0.85,
789            avg_turns: 3.5,
790            avg_latency_ms: 2500,
791            total_input_tokens: 50000,
792            total_output_tokens: 10000,
793        };
794        let json = serde_json::to_value(&summary).unwrap();
795        assert_eq!(json["total"], 10);
796        assert_eq!(json["pass_rate"], 0.8);
797    }
798
799    #[test]
800    fn test_eval_input_message_serde() {
801        let msg = EvalInputMessage {
802            content: "What is 2+2?".to_string(),
803        };
804        let json = serde_json::to_value(&msg).unwrap();
805        assert_eq!(json["content"], "What is 2+2?");
806    }
807
808    #[test]
809    fn test_eval_target_session_serde_roundtrip() {
810        let target = EvalTarget::Session {
811            harness_id: Some(HarnessId::from_uuid(Uuid::nil())),
812            harness_name: None,
813            agent_id: Some(AgentId::from_uuid(Uuid::nil())),
814            model_id: Some("gpt-4".to_string()),
815            system_prompt: None,
816            max_iterations: None,
817        };
818        let json = serde_json::to_value(&target).unwrap();
819        assert_eq!(json["type"], "session");
820        assert!(json.get("harness_id").is_some());
821        assert!(json.get("model_id").is_some());
822        assert!(json.get("system_prompt").is_none()); // skip_serializing_if
823        assert!(json.get("harness_name").is_none());
824        assert!(json.get("max_iterations").is_none());
825
826        let parsed: EvalTarget = serde_json::from_value(json).unwrap();
827        assert_eq!(parsed, target);
828    }
829
830    #[test]
831    fn test_eval_target_session_minimal() {
832        // Session with just harness_name, no other params
833        let target = EvalTarget::Session {
834            harness_id: None,
835            harness_name: Some("generic".to_string()),
836            agent_id: None,
837            model_id: None,
838            system_prompt: None,
839            max_iterations: None,
840        };
841        let json = serde_json::to_value(&target).unwrap();
842        assert_eq!(json["type"], "session");
843        assert_eq!(json["harness_name"], "generic");
844        assert!(json.get("harness_id").is_none());
845
846        let parsed: EvalTarget = serde_json::from_value(json).unwrap();
847        assert_eq!(parsed, target);
848    }
849
850    #[test]
851    fn test_eval_target_app_variant() {
852        let target = EvalTarget::App {
853            app_id: AppId::from_uuid(Uuid::nil()),
854        };
855        let json = serde_json::to_value(&target).unwrap();
856        assert_eq!(json["type"], "app");
857        assert!(json.get("app_id").is_some());
858
859        let parsed: EvalTarget = serde_json::from_value(json).unwrap();
860        assert_eq!(parsed, target);
861    }
862
863    #[test]
864    fn test_eval_serde_skips_internal_fields() {
865        let eval = Eval {
866            public_id: EvalId::from_uuid(Uuid::nil()),
867            internal_id: Uuid::nil(),
868            org_id: 1,
869            name: "test".into(),
870            description: None,
871            target: Some(EvalTarget::Session {
872                harness_id: Some(HarnessId::from_uuid(Uuid::nil())),
873                harness_name: None,
874                agent_id: Some(AgentId::from_uuid(Uuid::nil())),
875                model_id: None,
876                system_prompt: None,
877                max_iterations: None,
878            }),
879            model_override: None,
880            tags: vec![],
881            status: EvalStatus::Active,
882            case_count: 0,
883            last_run: None,
884            created_at: Utc::now(),
885            updated_at: Utc::now(),
886            archived_at: None,
887            deleted_at: None,
888        };
889        let json = serde_json::to_value(&eval).unwrap();
890        assert!(json.get("id").is_some());
891        assert!(json.get("internal_id").is_none());
892        assert!(json.get("org_id").is_none());
893        assert!(json.get("target").is_some());
894        assert!(json.get("description").is_none());
895        assert!(json.get("model_override").is_none());
896    }
897
898    #[test]
899    fn test_eval_case_artifacts_serde_roundtrip() {
900        let json = serde_json::json!({
901            "id": "evalcase_01933b5a000070008000000000000001",
902            "name": "case",
903            "conversation": [{"content": "hello"}],
904            "artifacts": [{"name": "patch", "path": "/workspace/fix.patch"}],
905            "scorers": [{"type": "contains", "text": "done", "weight": 1.0}],
906            "tags": [],
907            "position": 0,
908            "created_at": "2026-01-01T00:00:00Z",
909            "updated_at": "2026-01-01T00:00:00Z"
910        });
911
912        let case: EvalCase = serde_json::from_value(json.clone()).unwrap();
913        assert_eq!(
914            case.artifacts,
915            Some(vec![ArtifactSpec {
916                name: "patch".to_string(),
917                path: "/workspace/fix.patch".to_string(),
918            }])
919        );
920        assert_eq!(serde_json::to_value(case).unwrap(), json);
921    }
922}