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, ModelId,
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    /// Citation faithfulness: the answer's citations (see `specs/citations.md`)
322    /// must cover the claim and be verified as supported. Scored from the
323    /// `TextAnnotation`s on the final message — pair with the
324    /// `citation_verification` capability so verdicts are present.
325    CitationFaithful {
326        /// Minimum number of citations the answer must carry.
327        #[serde(default)]
328        #[cfg_attr(feature = "openapi", schema(example = 1))]
329        min_citations: u32,
330        /// Minimum fraction of citations verified `entailed` to pass.
331        #[serde(default = "default_pass_threshold")]
332        #[cfg_attr(feature = "openapi", schema(example = 0.8))]
333        pass_threshold: f64,
334        /// Relative weight of this scorer in the case's weighted average.
335        #[serde(default = "default_weight")]
336        #[cfg_attr(feature = "openapi", schema(example = 1.0))]
337        weight: f64,
338    },
339    /// Citation faithfulness judged by an LLM: each cited claim/source pair is
340    /// graded by a model, so the eval works even without the
341    /// `citation_verification` capability. See `specs/citations.md`.
342    CitationJudged {
343        /// Rubric override; a citation-faithfulness rubric is used when absent.
344        #[serde(default, skip_serializing_if = "Option::is_none")]
345        #[cfg_attr(
346            feature = "openapi",
347            schema(example = "Score the fraction of cited claims supported by their source.")
348        )]
349        rubric: Option<String>,
350        /// Judge model; the org's default is used when absent.
351        #[serde(default, skip_serializing_if = "Option::is_none")]
352        model_id: Option<ModelId>,
353        /// Minimum judged score `[0,1]` to pass.
354        #[serde(default = "default_pass_threshold")]
355        #[cfg_attr(feature = "openapi", schema(example = 0.8))]
356        pass_threshold: f64,
357        /// Relative weight of this scorer in the case's weighted average.
358        #[serde(default = "default_weight")]
359        #[cfg_attr(feature = "openapi", schema(example = 1.0))]
360        weight: f64,
361    },
362}
363
364fn default_pass_threshold() -> f64 {
365    0.8
366}
367
368impl Scorer {
369    /// Stable kind tag for this scorer, matching the serde `type` discriminant.
370    ///
371    /// Scores are persisted as an ordered `Vec<Score>` that carries no scorer
372    /// identity, so consumers that need a name (e.g. dataset export) join this
373    /// positionally against the case's `scorers`. Keep this exhaustive so adding
374    /// a variant forces a decision here.
375    pub fn kind(&self) -> &'static str {
376        match self {
377            Scorer::Contains { .. } => "contains",
378            Scorer::NotContains { .. } => "not_contains",
379            Scorer::Regex { .. } => "regex",
380            Scorer::ToolCalled { .. } => "tool_called",
381            Scorer::ToolNotCalled { .. } => "tool_not_called",
382            Scorer::ToolCallCount { .. } => "tool_call_count",
383            Scorer::TurnsWithin { .. } => "turns_within",
384            Scorer::FileContains { .. } => "file_contains",
385            Scorer::JsonSchema { .. } => "json_schema",
386            Scorer::CitationFaithful { .. } => "citation_faithful",
387            Scorer::CitationJudged { .. } => "citation_judged",
388        }
389    }
390}
391
392fn default_weight() -> f64 {
393    1.0
394}
395
396fn default_min_one() -> u32 {
397    1
398}
399
400// ============================================
401// Score result
402// ============================================
403
404/// Result from a single scorer evaluation.
405#[derive(Debug, Clone, Serialize, Deserialize)]
406#[cfg_attr(feature = "openapi", derive(ToSchema))]
407pub struct Score {
408    /// Whether this scorer passed.
409    pub pass: bool,
410    /// Score value 0.0–1.0.
411    pub value: f64,
412    /// Human-readable explanation.
413    pub reason: String,
414}
415
416// ============================================
417// Run summary
418// ============================================
419
420/// Aggregate metrics for a completed eval run.
421#[derive(Debug, Clone, Serialize, Deserialize)]
422#[cfg_attr(feature = "openapi", derive(ToSchema))]
423pub struct RunSummary {
424    pub total: u32,
425    pub passed: u32,
426    pub failed: u32,
427    pub errored: u32,
428    pub pass_rate: f64,
429    pub avg_score: f64,
430    pub avg_turns: f64,
431    pub avg_latency_ms: u64,
432    pub total_input_tokens: u64,
433    pub total_output_tokens: u64,
434}
435
436// ============================================
437// Input message for eval cases
438// ============================================
439
440/// A message to send to the agent during an eval case.
441#[derive(Debug, Clone, Serialize, Deserialize)]
442#[cfg_attr(feature = "openapi", derive(ToSchema))]
443pub struct EvalInputMessage {
444    /// The text content to send.
445    pub content: String,
446}
447
448// ============================================
449// Main entity structs
450// ============================================
451
452/// An eval: a named collection of test cases for an agent.
453#[derive(Debug, Clone, Serialize, Deserialize)]
454#[cfg_attr(feature = "openapi", derive(ToSchema))]
455pub struct Eval {
456    /// External identifier (eval_<32-hex>). Shown as "id" in API.
457    #[serde(rename = "id")]
458    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "eval_01933b5a000070008000000000000001"))]
459    pub public_id: EvalId,
460    /// Internal UUID primary key. Never exposed in API.
461    #[serde(skip, default = "Uuid::nil")]
462    pub internal_id: Uuid,
463    /// Organization ID. Internal only.
464    #[serde(skip, default)]
465    pub org_id: i64,
466    /// Display name.
467    pub name: String,
468    /// Optional description.
469    #[serde(skip_serializing_if = "Option::is_none")]
470    pub description: Option<String>,
471    /// Session setup target. Defines how to create sessions for eval cases.
472    #[serde(skip_serializing_if = "Option::is_none")]
473    pub target: Option<EvalTarget>,
474    /// Optional default model override for runs.
475    #[serde(skip_serializing_if = "Option::is_none")]
476    pub model_override: Option<String>,
477    /// Organization tags.
478    #[serde(default)]
479    pub tags: Vec<String>,
480    /// Lifecycle status.
481    pub status: EvalStatus,
482    /// Number of cases.
483    #[serde(default)]
484    pub case_count: i64,
485    /// Last run summary (if any).
486    #[serde(skip_serializing_if = "Option::is_none")]
487    pub last_run: Option<EvalRunSummaryView>,
488    pub created_at: DateTime<Utc>,
489    pub updated_at: DateTime<Utc>,
490    #[serde(skip_serializing_if = "Option::is_none")]
491    pub archived_at: Option<DateTime<Utc>>,
492    #[serde(skip_serializing_if = "Option::is_none")]
493    pub deleted_at: Option<DateTime<Utc>>,
494}
495
496/// Compact run summary for listing evals.
497#[derive(Debug, Clone, Serialize, Deserialize)]
498#[cfg_attr(feature = "openapi", derive(ToSchema))]
499pub struct EvalRunSummaryView {
500    #[serde(rename = "id")]
501    #[cfg_attr(feature = "openapi", schema(value_type = String))]
502    pub public_id: EvalRunId,
503    pub status: EvalRunStatus,
504    #[serde(skip_serializing_if = "Option::is_none")]
505    pub summary: Option<RunSummary>,
506    pub created_at: DateTime<Utc>,
507}
508
509/// A single test case within an eval.
510#[derive(Debug, Clone, Serialize, Deserialize)]
511#[cfg_attr(feature = "openapi", derive(ToSchema))]
512pub struct EvalCase {
513    /// External identifier (evalcase_<32-hex>).
514    #[serde(rename = "id")]
515    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "evalcase_01933b5a000070008000000000000001"))]
516    pub public_id: EvalCaseId,
517    #[serde(skip, default = "Uuid::nil")]
518    pub internal_id: Uuid,
519    pub name: String,
520    #[serde(skip_serializing_if = "Option::is_none")]
521    pub description: Option<String>,
522    /// Optional per-case target override.
523    #[serde(skip_serializing_if = "Option::is_none")]
524    pub target: Option<EvalTarget>,
525    #[serde(default)]
526    pub tags: Vec<String>,
527    /// Input messages sent sequentially.
528    pub conversation: Vec<EvalInputMessage>,
529    /// Verification messages sent after conversation completes and session idles.
530    /// Scorers run after post messages complete (not after conversation).
531    #[serde(skip_serializing_if = "Option::is_none")]
532    pub post: Option<Vec<EvalInputMessage>>,
533    /// Session files to collect after scoring completes.
534    #[serde(skip_serializing_if = "Option::is_none")]
535    pub artifacts: Option<Vec<ArtifactSpec>>,
536    /// Scoring rules.
537    pub scorers: Vec<Scorer>,
538    /// Max agent turns (default: 10).
539    #[serde(skip_serializing_if = "Option::is_none")]
540    pub max_turns: Option<u32>,
541    /// Per-case timeout in seconds (default: 120).
542    #[serde(skip_serializing_if = "Option::is_none")]
543    pub timeout_seconds: Option<u32>,
544    /// Display order.
545    pub position: i32,
546    pub created_at: DateTime<Utc>,
547    pub updated_at: DateTime<Utc>,
548}
549
550/// An eval run: one execution of all/some cases.
551#[derive(Debug, Clone, Serialize, Deserialize)]
552#[cfg_attr(feature = "openapi", derive(ToSchema))]
553pub struct EvalRun {
554    #[serde(rename = "id")]
555    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "evalrun_01933b5a000070008000000000000001"))]
556    pub public_id: EvalRunId,
557    #[serde(skip, default = "Uuid::nil")]
558    pub internal_id: Uuid,
559    #[serde(skip, default)]
560    pub org_id: i64,
561    /// Optional per-run target override.
562    #[serde(skip_serializing_if = "Option::is_none")]
563    pub target: Option<EvalTarget>,
564    /// Model override for this run.
565    #[serde(skip_serializing_if = "Option::is_none")]
566    pub model_override: Option<String>,
567    /// Only run cases matching these tags.
568    #[serde(skip_serializing_if = "Option::is_none")]
569    pub filter_tags: Option<Vec<String>>,
570    pub status: EvalRunStatus,
571    /// Whether everruns executed this run (`internal`) or it was imported from
572    /// an external eval system (`external`).
573    #[serde(default)]
574    pub source: EvalRunSource,
575    /// Provenance for external runs: which system produced them, version, link
576    /// back, and any environment labels. `None` for internal runs. Open-vocab
577    /// JSON so new attribution fields need no schema change.
578    #[serde(default, skip_serializing_if = "Option::is_none")]
579    pub attribution: Option<serde_json::Value>,
580    /// What triggered this run.
581    pub triggered_by: String,
582    #[serde(skip_serializing_if = "Option::is_none")]
583    pub started_at: Option<DateTime<Utc>>,
584    #[serde(skip_serializing_if = "Option::is_none")]
585    pub completed_at: Option<DateTime<Utc>>,
586    /// Aggregate metrics (set on completion).
587    #[serde(skip_serializing_if = "Option::is_none")]
588    pub summary: Option<RunSummary>,
589    /// Case results (populated on detail view).
590    #[serde(default, skip_serializing_if = "Vec::is_empty")]
591    pub results: Vec<EvalCaseResult>,
592    pub created_at: DateTime<Utc>,
593    pub updated_at: DateTime<Utc>,
594}
595
596// ============================================
597// Eval Run Dataset (async dataset export — specs/dataset-export.md)
598// ============================================
599
600/// Status of an async dataset export.
601#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
602#[cfg_attr(feature = "openapi", derive(ToSchema))]
603#[serde(rename_all = "lowercase")]
604pub enum EvalDatasetStatus {
605    /// Enqueued, export not started yet.
606    Pending,
607    /// Export in progress.
608    Running,
609    /// Export finished; NDJSON `body` is available on the detail view.
610    Completed,
611    /// Export failed; see `error_message`.
612    Failed,
613}
614
615impl std::fmt::Display for EvalDatasetStatus {
616    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
617        match self {
618            EvalDatasetStatus::Pending => write!(f, "pending"),
619            EvalDatasetStatus::Running => write!(f, "running"),
620            EvalDatasetStatus::Completed => write!(f, "completed"),
621            EvalDatasetStatus::Failed => write!(f, "failed"),
622        }
623    }
624}
625
626impl From<&str> for EvalDatasetStatus {
627    fn from(s: &str) -> Self {
628        match s {
629            "running" => EvalDatasetStatus::Running,
630            "completed" => EvalDatasetStatus::Completed,
631            "failed" => EvalDatasetStatus::Failed,
632            _ => EvalDatasetStatus::Pending,
633        }
634    }
635}
636
637/// An async dataset-export handle: the durable result of enqueuing a dataset
638/// export from a completed eval run. The `body` (NDJSON) is only populated on
639/// the `GET .../dataset/{dataset_id}` detail view once `status` is `completed`.
640#[derive(Debug, Clone, Serialize, Deserialize)]
641#[cfg_attr(feature = "openapi", derive(ToSchema))]
642pub struct EvalRunDataset {
643    #[serde(rename = "id")]
644    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "evaldataset_01933b5a000070008000000000000001"))]
645    pub public_id: EvalDatasetId,
646    /// The eval run this dataset was exported from.
647    #[cfg_attr(feature = "openapi", schema(value_type = String))]
648    pub eval_run_id: EvalRunId,
649    pub status: EvalDatasetStatus,
650    /// Number of NDJSON records (surviving cases). Set on completion.
651    #[serde(skip_serializing_if = "Option::is_none")]
652    pub record_count: Option<u64>,
653    /// Failure detail when `status` is `failed`.
654    #[serde(skip_serializing_if = "Option::is_none")]
655    pub error_message: Option<String>,
656    /// The produced NDJSON. Only present on the detail view once completed.
657    #[serde(skip_serializing_if = "Option::is_none")]
658    pub body: Option<String>,
659    pub created_at: DateTime<Utc>,
660    pub updated_at: DateTime<Utc>,
661}
662
663/// Result of a single case within a run.
664#[derive(Debug, Clone, Serialize, Deserialize)]
665#[cfg_attr(feature = "openapi", derive(ToSchema))]
666pub struct EvalCaseResult {
667    #[serde(rename = "id")]
668    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "evalresult_01933b5a000070008000000000000001"))]
669    pub public_id: EvalResultId,
670    #[serde(skip, default = "Uuid::nil")]
671    pub internal_id: Uuid,
672    /// The case this result is for.
673    #[cfg_attr(feature = "openapi", schema(value_type = String))]
674    pub eval_case_id: EvalCaseId,
675    /// Case name (denormalized for display).
676    #[serde(skip_serializing_if = "Option::is_none")]
677    pub case_name: Option<String>,
678    /// Session created for this case (browsable in UI).
679    #[serde(skip_serializing_if = "Option::is_none")]
680    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
681    pub session_id: Option<SessionId>,
682    /// Resolved target used for this result (live reference).
683    #[serde(skip_serializing_if = "Option::is_none")]
684    pub target: Option<EvalTarget>,
685    /// Frozen snapshot of the resolved target at execution time.
686    #[serde(skip_serializing_if = "Option::is_none")]
687    pub target_snapshot: Option<EvalTarget>,
688    pub status: CaseResultStatus,
689    /// Per-scorer results.
690    #[serde(skip_serializing_if = "Option::is_none")]
691    pub scores: Option<serde_json::Value>,
692    /// External scorer metadata captured during deferred write-back.
693    #[serde(skip_serializing_if = "Option::is_none")]
694    pub metadata: Option<serde_json::Value>,
695    /// Turn count.
696    #[serde(skip_serializing_if = "Option::is_none")]
697    pub turns: Option<u32>,
698    /// Execution time in milliseconds.
699    #[serde(skip_serializing_if = "Option::is_none")]
700    pub latency_ms: Option<u64>,
701    /// Token usage.
702    #[serde(skip_serializing_if = "Option::is_none")]
703    pub input_tokens: Option<u64>,
704    #[serde(skip_serializing_if = "Option::is_none")]
705    pub output_tokens: Option<u64>,
706    /// Error message if errored.
707    #[serde(skip_serializing_if = "Option::is_none")]
708    pub error_message: Option<String>,
709    /// Collected session file contents keyed by artifact name.
710    #[serde(skip_serializing_if = "Option::is_none")]
711    pub artifacts: Option<BTreeMap<String, String>>,
712    pub created_at: DateTime<Utc>,
713    pub updated_at: DateTime<Utc>,
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719
720    #[test]
721    fn test_eval_status_display() {
722        assert_eq!(EvalStatus::Active.to_string(), "active");
723        assert_eq!(EvalStatus::Archived.to_string(), "archived");
724        assert_eq!(EvalStatus::Deleted.to_string(), "deleted");
725    }
726
727    #[test]
728    fn test_eval_status_from_str() {
729        assert_eq!(EvalStatus::from("active"), EvalStatus::Active);
730        assert_eq!(EvalStatus::from("archived"), EvalStatus::Archived);
731        assert_eq!(EvalStatus::from("deleted"), EvalStatus::Deleted);
732        assert_eq!(EvalStatus::from("unknown"), EvalStatus::Active);
733    }
734
735    #[test]
736    fn test_eval_status_serde_roundtrip() {
737        let json = serde_json::to_string(&EvalStatus::Archived).unwrap();
738        assert_eq!(json, r#""archived""#);
739        let parsed: EvalStatus = serde_json::from_str(&json).unwrap();
740        assert_eq!(parsed, EvalStatus::Archived);
741    }
742
743    #[test]
744    fn test_eval_run_status_display() {
745        assert_eq!(EvalRunStatus::Pending.to_string(), "pending");
746        assert_eq!(EvalRunStatus::Running.to_string(), "running");
747        assert_eq!(EvalRunStatus::Completed.to_string(), "completed");
748        assert_eq!(EvalRunStatus::Failed.to_string(), "failed");
749        assert_eq!(EvalRunStatus::Cancelled.to_string(), "cancelled");
750    }
751
752    #[test]
753    fn test_eval_run_status_from_str() {
754        assert_eq!(EvalRunStatus::from("pending"), EvalRunStatus::Pending);
755        assert_eq!(EvalRunStatus::from("running"), EvalRunStatus::Running);
756        assert_eq!(EvalRunStatus::from("completed"), EvalRunStatus::Completed);
757        assert_eq!(EvalRunStatus::from("failed"), EvalRunStatus::Failed);
758        assert_eq!(EvalRunStatus::from("cancelled"), EvalRunStatus::Cancelled);
759        assert_eq!(EvalRunStatus::from("unknown"), EvalRunStatus::Pending);
760    }
761
762    #[test]
763    fn test_case_result_status_display() {
764        assert_eq!(CaseResultStatus::Pending.to_string(), "pending");
765        assert_eq!(CaseResultStatus::Passed.to_string(), "passed");
766        assert_eq!(CaseResultStatus::Failed.to_string(), "failed");
767        assert_eq!(CaseResultStatus::Errored.to_string(), "errored");
768        assert_eq!(CaseResultStatus::Timeout.to_string(), "timeout");
769    }
770
771    #[test]
772    fn test_case_result_status_from_str() {
773        assert_eq!(CaseResultStatus::from("passed"), CaseResultStatus::Passed);
774        assert_eq!(CaseResultStatus::from("failed"), CaseResultStatus::Failed);
775        assert_eq!(CaseResultStatus::from("errored"), CaseResultStatus::Errored);
776        assert_eq!(CaseResultStatus::from("timeout"), CaseResultStatus::Timeout);
777        assert_eq!(CaseResultStatus::from("unknown"), CaseResultStatus::Pending);
778    }
779
780    #[test]
781    fn test_scorer_serde_roundtrip() {
782        let scorer = Scorer::Contains {
783            text: "hello".to_string(),
784            weight: 1.0,
785        };
786        let json = serde_json::to_value(&scorer).unwrap();
787        assert_eq!(json["type"], "contains");
788        assert_eq!(json["text"], "hello");
789        assert_eq!(json["weight"], 1.0);
790
791        let parsed: Scorer = serde_json::from_value(json).unwrap();
792        match parsed {
793            Scorer::Contains { text, weight } => {
794                assert_eq!(text, "hello");
795                assert_eq!(weight, 1.0);
796            }
797            _ => panic!("wrong variant"),
798        }
799    }
800
801    #[test]
802    fn test_scorer_tool_called_defaults() {
803        let json = r#"{"type": "tool_called", "tool": "read_file"}"#;
804        let scorer: Scorer = serde_json::from_str(json).unwrap();
805        match scorer {
806            Scorer::ToolCalled { tool, min, weight } => {
807                assert_eq!(tool, "read_file");
808                assert_eq!(min, 1);
809                assert_eq!(weight, 1.0);
810            }
811            _ => panic!("wrong variant"),
812        }
813    }
814
815    #[test]
816    fn test_score_serde() {
817        let score = Score {
818            pass: true,
819            value: 0.85,
820            reason: "Output contains expected text".to_string(),
821        };
822        let json = serde_json::to_value(&score).unwrap();
823        assert_eq!(json["pass"], true);
824        assert_eq!(json["value"], 0.85);
825    }
826
827    #[test]
828    fn test_run_summary_serde() {
829        let summary = RunSummary {
830            total: 10,
831            passed: 8,
832            failed: 1,
833            errored: 1,
834            pass_rate: 0.8,
835            avg_score: 0.85,
836            avg_turns: 3.5,
837            avg_latency_ms: 2500,
838            total_input_tokens: 50000,
839            total_output_tokens: 10000,
840        };
841        let json = serde_json::to_value(&summary).unwrap();
842        assert_eq!(json["total"], 10);
843        assert_eq!(json["pass_rate"], 0.8);
844    }
845
846    #[test]
847    fn test_eval_input_message_serde() {
848        let msg = EvalInputMessage {
849            content: "What is 2+2?".to_string(),
850        };
851        let json = serde_json::to_value(&msg).unwrap();
852        assert_eq!(json["content"], "What is 2+2?");
853    }
854
855    #[test]
856    fn test_eval_target_session_serde_roundtrip() {
857        let target = EvalTarget::Session {
858            harness_id: Some(HarnessId::from_uuid(Uuid::nil())),
859            harness_name: None,
860            agent_id: Some(AgentId::from_uuid(Uuid::nil())),
861            model_id: Some("gpt-4".to_string()),
862            system_prompt: None,
863            max_iterations: None,
864        };
865        let json = serde_json::to_value(&target).unwrap();
866        assert_eq!(json["type"], "session");
867        assert!(json.get("harness_id").is_some());
868        assert!(json.get("model_id").is_some());
869        assert!(json.get("system_prompt").is_none()); // skip_serializing_if
870        assert!(json.get("harness_name").is_none());
871        assert!(json.get("max_iterations").is_none());
872
873        let parsed: EvalTarget = serde_json::from_value(json).unwrap();
874        assert_eq!(parsed, target);
875    }
876
877    #[test]
878    fn test_eval_target_session_minimal() {
879        // Session with just harness_name, no other params
880        let target = EvalTarget::Session {
881            harness_id: None,
882            harness_name: Some("generic".to_string()),
883            agent_id: None,
884            model_id: None,
885            system_prompt: None,
886            max_iterations: None,
887        };
888        let json = serde_json::to_value(&target).unwrap();
889        assert_eq!(json["type"], "session");
890        assert_eq!(json["harness_name"], "generic");
891        assert!(json.get("harness_id").is_none());
892
893        let parsed: EvalTarget = serde_json::from_value(json).unwrap();
894        assert_eq!(parsed, target);
895    }
896
897    #[test]
898    fn test_eval_target_app_variant() {
899        let target = EvalTarget::App {
900            app_id: AppId::from_uuid(Uuid::nil()),
901        };
902        let json = serde_json::to_value(&target).unwrap();
903        assert_eq!(json["type"], "app");
904        assert!(json.get("app_id").is_some());
905
906        let parsed: EvalTarget = serde_json::from_value(json).unwrap();
907        assert_eq!(parsed, target);
908    }
909
910    #[test]
911    fn test_eval_serde_skips_internal_fields() {
912        let eval = Eval {
913            public_id: EvalId::from_uuid(Uuid::nil()),
914            internal_id: Uuid::nil(),
915            org_id: 1,
916            name: "test".into(),
917            description: None,
918            target: Some(EvalTarget::Session {
919                harness_id: Some(HarnessId::from_uuid(Uuid::nil())),
920                harness_name: None,
921                agent_id: Some(AgentId::from_uuid(Uuid::nil())),
922                model_id: None,
923                system_prompt: None,
924                max_iterations: None,
925            }),
926            model_override: None,
927            tags: vec![],
928            status: EvalStatus::Active,
929            case_count: 0,
930            last_run: None,
931            created_at: Utc::now(),
932            updated_at: Utc::now(),
933            archived_at: None,
934            deleted_at: None,
935        };
936        let json = serde_json::to_value(&eval).unwrap();
937        assert!(json.get("id").is_some());
938        assert!(json.get("internal_id").is_none());
939        assert!(json.get("org_id").is_none());
940        assert!(json.get("target").is_some());
941        assert!(json.get("description").is_none());
942        assert!(json.get("model_override").is_none());
943    }
944
945    #[test]
946    fn test_eval_case_artifacts_serde_roundtrip() {
947        let json = serde_json::json!({
948            "id": "evalcase_01933b5a000070008000000000000001",
949            "name": "case",
950            "conversation": [{"content": "hello"}],
951            "artifacts": [{"name": "patch", "path": "/workspace/fix.patch"}],
952            "scorers": [{"type": "contains", "text": "done", "weight": 1.0}],
953            "tags": [],
954            "position": 0,
955            "created_at": "2026-01-01T00:00:00Z",
956            "updated_at": "2026-01-01T00:00:00Z"
957        });
958
959        let case: EvalCase = serde_json::from_value(json.clone()).unwrap();
960        assert_eq!(
961            case.artifacts,
962            Some(vec![ArtifactSpec {
963                name: "patch".to_string(),
964                path: "/workspace/fix.patch".to_string(),
965            }])
966        );
967        assert_eq!(serde_json::to_value(case).unwrap(), json);
968    }
969}