Skip to main content

mira/
study.rs

1//! The **study** side of the eval protocol. A [`Study`] is your eval program:
2//! it bundles the evals you're investigating and, when you call
3//! [`serve`](Study::serve), runs the stdio loop that answers the host's
4//! `initialize` / `list` / `run` requests.
5//!
6//! ```no_run
7//! # async fn f() -> std::io::Result<()> {
8//! // Every `#[eval]`-registered eval in the binary:
9//! mira::Study::registered().serve().await
10//! # }
11//! ```
12//!
13//! ```no_run
14//! # fn greet() -> mira::Eval { unimplemented!() }
15//! # fn coding() -> mira::Eval { unimplemented!() }
16//! # async fn f() -> std::io::Result<()> {
17//! // …or an explicit set:
18//! mira::Study::new().eval(greet()).eval(coding()).serve().await
19//! # }
20//! ```
21//!
22//! Keep stdout clean: only protocol JSON goes there. Logging belongs on stderr.
23
24use std::collections::HashMap;
25use std::sync::Arc;
26
27use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
28use tokio::sync::{Mutex, oneshot};
29use tokio::task::JoinSet;
30
31use crate::eval::Eval;
32use crate::protocol::{
33    AxisInfo, CancelParams, CancelResult, EvalInfo, EventParams, ExecuteResult, InitializeResult,
34    ListResult, ListSamplesParams, ListSamplesResult, Notification, PROTOCOL_VERSION, Request,
35    Response, RpcError, RunParams, RunResult, SampleInfo, ScoreParams, TargetInfo,
36    TranscriptSummary, capabilities, codes, event,
37};
38use crate::registry::registered_evals;
39use crate::runner::{aggregate_value, execute_case, run_case, score_transcript, verdict};
40
41/// The shared, line-serialized output sink. Boxed (not a concrete `Stdout`) so
42/// the serve loop is testable over in-memory pipes via [`Study::serve_io`].
43type SharedWriter = Arc<Mutex<Box<dyn AsyncWrite + Send + Unpin>>>;
44
45/// In-flight cancellable requests: request `id` → a signal that, when fired,
46/// aborts that request's run via the task's `select!`. Held under a sync mutex
47/// (never across an `.await`), like the host's pending map.
48type Inflight = Arc<std::sync::Mutex<HashMap<u64, oneshot::Sender<()>>>>;
49
50/// Default samples-per-page when paginating `list`. Chosen so realistic small
51/// studies (examples, smoke tests) fit in one page — `list` then enumerates every
52/// sample inline — while a thousands-of-samples dataset (e.g. SWE-bench full) is
53/// chunked across `list` + `list_samples` instead of one giant line.
54pub const DEFAULT_PAGE_SIZE: usize = 500;
55
56/// Your eval program: a named bundle of [`Eval`]s exposed to the host over the
57/// protocol. Build one, then [`serve`](Study::serve) it.
58pub struct Study {
59    /// Name advertised to the host in `initialize` (defaults to the crate name).
60    name: String,
61    evals: Vec<Eval>,
62    /// Max samples per `list`/`list_samples` page. `None` disables pagination
63    /// (every sample is enumerated inline in `list`).
64    page_size: Option<usize>,
65}
66
67impl Default for Study {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl Study {
74    /// An empty study. Add evals with [`eval`](Study::eval) /
75    /// [`evals`](Study::evals).
76    pub fn new() -> Self {
77        Self {
78            name: env!("CARGO_PKG_NAME").into(),
79            evals: Vec::new(),
80            page_size: Some(DEFAULT_PAGE_SIZE),
81        }
82    }
83
84    /// A study of every [`register_eval!`](crate::register_eval)-registered eval
85    /// in the binary (the `#[eval]` / `cargo test`-style discovery path).
86    pub fn registered() -> Self {
87        Self::new().evals(registered_evals())
88    }
89
90    /// Add one eval (builder style).
91    pub fn eval(mut self, eval: Eval) -> Self {
92        self.evals.push(eval);
93        self
94    }
95
96    /// Add many evals.
97    pub fn evals(mut self, evals: impl IntoIterator<Item = Eval>) -> Self {
98        self.evals.extend(evals);
99        self
100    }
101
102    /// Override the name advertised to the host (defaults to the crate name).
103    pub fn named(mut self, name: impl Into<String>) -> Self {
104        self.name = name.into();
105        self
106    }
107
108    /// Set the samples-per-page for `list` pagination. `0` disables pagination
109    /// (all samples are enumerated inline in `list`). Defaults to
110    /// [`DEFAULT_PAGE_SIZE`]; lower it to chunk a very large dataset more
111    /// aggressively, or raise it to send bigger pages.
112    pub fn page_size(mut self, size: usize) -> Self {
113        self.page_size = (size > 0).then_some(size);
114        self
115    }
116
117    /// Serve this study over newline-delimited JSON on stdin/stdout until EOF.
118    /// The host drives the loop; this returns when stdin closes.
119    pub async fn serve(self) -> std::io::Result<()> {
120        self.serve_io(tokio::io::stdin(), tokio::io::stdout()).await
121    }
122
123    /// Serve over arbitrary line-framed transports (e.g. in-memory pipes in
124    /// tests). [`serve`](Study::serve) is this over real stdin/stdout.
125    ///
126    /// Requests are dispatched **concurrently**: each `run` is handled on its own
127    /// task so a host can keep many cases in flight at once. Writes are serialized
128    /// through a shared writer mutex (one whole line per lock), so responses and
129    /// `event`/`log` notifications never interleave mid-line. The host bounds how
130    /// many runs are in flight (see [`crate::exec`]).
131    ///
132    /// A `cancel` request aborts one in-flight `run`/`execute`/`score` by its
133    /// request `id`: the run's task is dropped at its next await point and replies
134    /// with a `cancelled` error, so the host's pending call resolves promptly
135    /// instead of leaking until EOF. `cancel` is handled inline (not on a task)
136    /// and is itself never cancellable.
137    pub async fn serve_io<R, W>(self, reader: R, writer: W) -> std::io::Result<()>
138    where
139        R: AsyncRead + Unpin,
140        W: AsyncWrite + Send + Unpin + 'static,
141    {
142        let mut lines = BufReader::new(reader).lines();
143        let out: SharedWriter = Arc::new(Mutex::new(Box::new(writer)));
144        let me = Arc::new(self);
145        let mut tasks: JoinSet<()> = JoinSet::new();
146        let inflight: Inflight = Default::default();
147
148        while let Some(line) = lines.next_line().await? {
149            if line.trim().is_empty() {
150                continue;
151            }
152            let request: Request = match serde_json::from_str(&line) {
153                Ok(req) => req,
154                Err(e) => {
155                    // Can't correlate a malformed line to an id; report and move on.
156                    write_line(&out, &Notification::log(format!("bad request: {e}"), 0)).await?;
157                    continue;
158                }
159            };
160
161            // `cancel` mutates the in-flight registry and must resolve promptly;
162            // handle it inline rather than racing it against the runs it cancels.
163            if request.method == "cancel" {
164                let response = cancel(&request, &inflight);
165                write_line(&out, &response).await?;
166                continue;
167            }
168
169            // Register a cancel signal before spawning, so a `cancel` arriving the
170            // instant after dispatch starts still finds it.
171            let (cancel_tx, cancel_rx) = oneshot::channel::<()>();
172            inflight
173                .lock()
174                .expect("inflight mutex poisoned")
175                .insert(request.id, cancel_tx);
176
177            let me = me.clone();
178            let out = out.clone();
179            let inflight = inflight.clone();
180            tasks.spawn(async move {
181                let id = request.id;
182                let response = tokio::select! {
183                    resp = me.dispatch(&request, &out) => resp,
184                    // Cancelled: drop the run future (stops work at its next await)
185                    // and reply with an error the host correlates to its `run`.
186                    _ = cancel_rx => Response::err(id, "cancelled"),
187                };
188                inflight
189                    .lock()
190                    .expect("inflight mutex poisoned")
191                    .remove(&id);
192                let _ = write_line(&out, &response).await;
193            });
194        }
195
196        // Drain in-flight runs before returning so no response is lost on EOF.
197        while tasks.join_next().await.is_some() {}
198        Ok(())
199    }
200
201    async fn dispatch(&self, request: &Request, stdout: &SharedWriter) -> Response {
202        match request.method.as_str() {
203            "initialize" => Response::ok(
204                request.id,
205                json(&InitializeResult {
206                    protocol_version: PROTOCOL_VERSION.into(),
207                    study: self.name.clone(),
208                    evals: self.evals.len(),
209                    study_version: Some(env!("CARGO_PKG_VERSION").into()),
210                    capabilities: vec![
211                        capabilities::AXES.into(),
212                        capabilities::EVENTS.into(),
213                        capabilities::USAGE.into(),
214                        capabilities::EXECUTE.into(),
215                        capabilities::SCORE.into(),
216                        capabilities::TRIALS.into(),
217                        capabilities::CANCEL.into(),
218                        capabilities::PAGINATE.into(),
219                        capabilities::TRAJECTORY.into(),
220                    ],
221                    // Structured config for the advertised capabilities (event
222                    // kinds, supported modalities) — see capability_params.
223                    capability_params: advertised_capability_params(),
224                }),
225            ),
226            "list" => Response::ok(request.id, json(&self.list())),
227            "list_samples" => {
228                let params: ListSamplesParams = match serde_json::from_value(request.params.clone())
229                {
230                    Ok(p) => p,
231                    Err(e) => {
232                        return Response::err(request.id, format!("bad list_samples params: {e}"));
233                    }
234                };
235                match self.list_samples(&params) {
236                    Ok(result) => Response::ok(request.id, json(&result)),
237                    Err(e) => Response::err(request.id, e),
238                }
239            }
240            "run" => {
241                let params: RunParams = match serde_json::from_value(request.params.clone()) {
242                    Ok(p) => p,
243                    Err(e) => {
244                        return Response::err_with(
245                            request.id,
246                            RpcError::new(format!("bad run params: {e}"))
247                                .with_code(codes::INVALID_PARAMS),
248                        );
249                    }
250                };
251                // Progress so the host can render a live spinner / log. Each
252                // event carries `request.id`, so the host correlates it to this
253                // call even with many cases (or trials) multiplexed at once.
254                let _ = write_line(stdout, &case_event(request.id, &params, event::STARTED)).await;
255                let result = self.run(&params).await;
256                let _ = write_line(stdout, &case_event(request.id, &params, event::FINISHED)).await;
257                match result {
258                    Ok(result) => Response::ok(request.id, json(&result)),
259                    Err(e) => Response::err(request.id, e),
260                }
261            }
262            "execute" => {
263                let params: RunParams = match serde_json::from_value(request.params.clone()) {
264                    Ok(p) => p,
265                    Err(e) => {
266                        return Response::err_with(
267                            request.id,
268                            RpcError::new(format!("bad execute params: {e}"))
269                                .with_code(codes::INVALID_PARAMS),
270                        );
271                    }
272                };
273                let _ = write_line(stdout, &case_event(request.id, &params, event::STARTED)).await;
274                let result = self.execute(&params).await;
275                let _ = write_line(stdout, &case_event(request.id, &params, event::FINISHED)).await;
276                match result {
277                    Ok(result) => Response::ok(request.id, json(&result)),
278                    Err(e) => Response::err(request.id, e),
279                }
280            }
281            "score" => {
282                let params: ScoreParams = match serde_json::from_value(request.params.clone()) {
283                    Ok(p) => p,
284                    Err(e) => {
285                        return Response::err_with(
286                            request.id,
287                            RpcError::new(format!("bad score params: {e}"))
288                                .with_code(codes::INVALID_PARAMS),
289                        );
290                    }
291                };
292                match self.score(&params).await {
293                    Ok(result) => Response::ok(request.id, json(&result)),
294                    Err(e) => Response::err(request.id, e),
295                }
296            }
297            other => Response::err_with(
298                request.id,
299                RpcError::new(format!("unknown method: {other}"))
300                    .with_code(codes::METHOD_NOT_FOUND),
301            ),
302        }
303    }
304
305    /// Build the `list` advertisement from this study's evals. Each eval carries
306    /// the **first page** of its samples plus a `next_cursor` when more remain;
307    /// the host fetches the rest with `list_samples`.
308    pub fn list(&self) -> ListResult {
309        let evals = self
310            .evals
311            .iter()
312            .map(|eval| {
313                let (samples, next_cursor) = self.sample_page(eval, 0);
314                EvalInfo {
315                    name: eval.name.clone(),
316                    description: eval.description.clone(),
317                    samples,
318                    next_cursor,
319                    scorers: eval.scorers.iter().map(|s| s.name()).collect(),
320                    targets: eval
321                        .targets
322                        .iter()
323                        .map(|m| TargetInfo {
324                            label: m.label.clone(),
325                            provider: m.provider.clone(),
326                            available: m.available,
327                            metadata: m.metadata.clone(),
328                        })
329                        .collect(),
330                    axes: eval
331                        .axes
332                        .iter()
333                        .map(|a| AxisInfo {
334                            name: a.name.clone(),
335                            values: a.values.clone(),
336                        })
337                        .collect(),
338                    max_turns: eval.max_turns,
339                    trials: eval.trials,
340                    seed: eval.seed,
341                    metadata: eval.metadata.clone(),
342                }
343            })
344            .collect();
345        ListResult { evals }
346    }
347
348    /// Answer `list_samples`: the page of `eval`'s samples beginning at `cursor`.
349    /// The cursor is the opaque token from a prior page (we encode it as the
350    /// next sample offset); an unknown eval or malformed cursor is an error.
351    pub fn list_samples(&self, params: &ListSamplesParams) -> Result<ListSamplesResult, String> {
352        let eval = self
353            .evals
354            .iter()
355            .find(|e| e.name == params.eval)
356            .ok_or_else(|| format!("no such eval: {}", params.eval))?;
357        let offset: usize = params
358            .cursor
359            .parse()
360            .map_err(|_| format!("bad cursor: {}", params.cursor))?;
361        let (samples, next_cursor) = self.sample_page(eval, offset);
362        Ok(ListSamplesResult {
363            samples,
364            next_cursor,
365        })
366    }
367
368    /// One page of an eval's samples starting at `offset`, plus the cursor for
369    /// the page after it (`None` once the dataset is exhausted). With pagination
370    /// disabled (`page_size == None`) a single page holds every remaining sample
371    /// and there is never a next cursor.
372    fn sample_page(&self, eval: &Eval, offset: usize) -> (Vec<SampleInfo>, Option<String>) {
373        let all = &eval.dataset.samples;
374        let start = offset.min(all.len());
375        let end = match self.page_size {
376            Some(p) => start.saturating_add(p).min(all.len()),
377            None => all.len(),
378        };
379        let page = all[start..end]
380            .iter()
381            .map(|s| SampleInfo {
382                id: s.id.clone(),
383                tags: s.tags.clone(),
384                metadata: s.metadata.clone(),
385            })
386            .collect();
387        let next = (end < all.len()).then(|| end.to_string());
388        (page, next)
389    }
390
391    async fn run(&self, params: &RunParams) -> Result<RunResult, String> {
392        let (eval, sample, target) = self.locate(&params.eval, &params.sample, &params.target)?;
393
394        // Don't burn time on an unrunnable case; report it as skipped.
395        if !target.available {
396            return Ok(skipped_result(params, sample));
397        }
398
399        let outcome = run_case(eval, sample, target, &params.params, params.trial()).await;
400        Ok(RunResult {
401            eval: outcome.eval,
402            sample: outcome.sample_id,
403            target: outcome.target,
404            params: outcome.params,
405            trial: params.trial,
406            trials: params.trials,
407            seed: params.seed,
408            input: sample.input.clone(),
409            expected: sample.expected.clone(),
410            passed: outcome.passed,
411            aggregate: outcome.aggregate,
412            scores: outcome.scores,
413            transcript: TranscriptSummary::of(&outcome.transcript),
414            skipped: false,
415        })
416    }
417
418    /// Execute a case's subject only, returning the **full** transcript with no
419    /// scoring (the run-now-score-later half of `run`).
420    async fn execute(&self, params: &RunParams) -> Result<ExecuteResult, String> {
421        let (eval, sample, target) = self.locate(&params.eval, &params.sample, &params.target)?;
422        if !target.available {
423            return Ok(ExecuteResult {
424                eval: params.eval.clone(),
425                sample: params.sample.clone(),
426                target: params.target.clone(),
427                params: params.params.clone(),
428                trial: params.trial,
429                trials: params.trials,
430                seed: params.seed,
431                transcript: Default::default(),
432                skipped: true,
433            });
434        }
435        let transcript = execute_case(eval, sample, target, &params.params, params.trial()).await;
436        Ok(ExecuteResult {
437            eval: params.eval.clone(),
438            sample: params.sample.clone(),
439            target: params.target.clone(),
440            params: params.params.clone(),
441            trial: params.trial,
442            trials: params.trials,
443            seed: params.seed,
444            transcript,
445            skipped: false,
446        })
447    }
448
449    /// Score a supplied transcript with an eval's scorers, without re-executing
450    /// the subject (the deferred-/re-scoring half of `run`). The target label
451    /// need not still exist — scoring depends only on the eval + sample.
452    async fn score(&self, params: &ScoreParams) -> Result<RunResult, String> {
453        let eval = self
454            .evals
455            .iter()
456            .find(|e| e.name == params.eval)
457            .ok_or_else(|| format!("no such eval: {}", params.eval))?;
458        let sample = eval
459            .dataset
460            .samples
461            .iter()
462            .find(|s| s.id == params.sample)
463            .ok_or_else(|| format!("no such sample: {}/{}", params.eval, params.sample))?;
464
465        // Normalize on receipt: a replayed transcript may be trajectory-only
466        // (a foreign study set nothing but `trajectory`). Fill-if-default, so
467        // explicitly-set flat fields are never overwritten; scorers and the
468        // summary then see the projected names/usage.
469        let mut transcript = params.transcript.clone();
470        transcript.project_trajectory();
471        let scores = score_transcript(eval, sample, &transcript).await;
472        Ok(RunResult {
473            eval: params.eval.clone(),
474            sample: params.sample.clone(),
475            target: params.target.clone(),
476            params: params.params.clone(),
477            trial: params.trial,
478            trials: params.trials,
479            seed: params.seed,
480            input: sample.input.clone(),
481            expected: sample.expected.clone(),
482            passed: verdict(&scores),
483            aggregate: aggregate_value(&scores),
484            scores,
485            transcript: TranscriptSummary::of(&transcript),
486            skipped: false,
487        })
488    }
489
490    /// Resolve a case to its `(eval, sample, target)` definitions.
491    fn locate(
492        &self,
493        eval: &str,
494        sample: &str,
495        target: &str,
496    ) -> Result<(&Eval, &crate::Sample, &crate::Target), String> {
497        let e = self
498            .evals
499            .iter()
500            .find(|e| e.name == eval)
501            .ok_or_else(|| format!("no such eval: {eval}"))?;
502        let s = e
503            .dataset
504            .samples
505            .iter()
506            .find(|s| s.id == sample)
507            .ok_or_else(|| format!("no such sample: {eval}/{sample}"))?;
508        let m = e
509            .targets
510            .iter()
511            .find(|m| m.label == target)
512            .ok_or_else(|| format!("no such target: {eval}@{target}"))?;
513        Ok((e, s, m))
514    }
515}
516
517/// A skipped (unexecuted) case result, e.g. when the target is unavailable.
518fn skipped_result(params: &RunParams, sample: &crate::Sample) -> RunResult {
519    RunResult {
520        eval: params.eval.clone(),
521        sample: params.sample.clone(),
522        target: params.target.clone(),
523        params: params.params.clone(),
524        trial: params.trial,
525        trials: params.trials,
526        seed: params.seed,
527        input: sample.input.clone(),
528        expected: sample.expected.clone(),
529        passed: false,
530        aggregate: 0.0,
531        scores: Vec::new(),
532        transcript: TranscriptSummary::default(),
533        skipped: true,
534    }
535}
536
537/// Abort the in-flight request named by `params.id`, replying with whether one
538/// was found. A miss (`cancelled: false`) is benign: the target already finished
539/// or was never in flight. Synchronous — it only fires the run's cancel signal;
540/// the run's own task writes its `cancelled` error and deregisters itself.
541fn cancel(request: &Request, inflight: &Inflight) -> Response {
542    let params: CancelParams = match serde_json::from_value(request.params.clone()) {
543        Ok(p) => p,
544        Err(e) => {
545            return Response::err_with(
546                request.id,
547                RpcError::new(format!("bad cancel params: {e}")).with_code(codes::INVALID_PARAMS),
548            );
549        }
550    };
551    let cancelled = inflight
552        .lock()
553        .expect("inflight mutex poisoned")
554        .remove(&params.id)
555        .is_some_and(|tx| tx.send(()).is_ok());
556    Response::ok(request.id, json(&CancelResult { cancelled }))
557}
558
559fn json<T: serde::Serialize>(value: &T) -> serde_json::Value {
560    serde_json::to_value(value).unwrap_or(serde_json::Value::Null)
561}
562
563/// The structured capability config advertised in `initialize` — the `event`
564/// kinds this study emits and the content modalities it understands. Keyed by
565/// capability token (see [`InitializeResult::capability_params`]).
566fn advertised_capability_params() -> crate::Metadata {
567    let modalities = serde_json::json!(["text", "image", "audio", "file", "json"]);
568    crate::Metadata::from([
569        (
570            capabilities::EVENTS.to_string(),
571            serde_json::json!({ "kinds": [event::STARTED, event::FINISHED] }),
572        ),
573        (
574            "modalities".to_string(),
575            serde_json::json!({ "input": modalities, "output": modalities }),
576        ),
577        (
578            // The trajectory representation this study emits (a reader is
579            // more lenient — any ATIF-v1.x parses). `format` keeps the door
580            // open for a non-ATIF or ATIF-v2 form without a new token.
581            capabilities::TRAJECTORY.to_string(),
582            serde_json::json!({
583                "format": crate::trajectory::ATIF_FORMAT,
584                "version": crate::trajectory::ATIF_VERSION.trim_start_matches("ATIF-v"),
585            }),
586        ),
587    ])
588}
589
590/// A typed case-progress `event`, correlated to its request by `req_id`.
591fn case_event(req_id: u64, p: &RunParams, kind: &str) -> Notification {
592    Notification::event(EventParams {
593        request_id: req_id,
594        eval: p.eval.clone(),
595        sample: p.sample.clone(),
596        target: p.target.clone(),
597        params: p.params.clone(),
598        kind: kind.into(),
599        ..Default::default()
600    })
601}
602
603/// Serialize `value` as one line and write it under the shared writer lock, so
604/// concurrent tasks never interleave partial lines.
605async fn write_line<T: serde::Serialize>(out: &SharedWriter, value: &T) -> std::io::Result<()> {
606    let mut buf = serde_json::to_vec(value).unwrap_or_default();
607    buf.push(b'\n');
608    let mut out = out.lock().await;
609    out.write_all(&buf).await?;
610    out.flush().await
611}
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616    use crate::scorer::contains;
617    use crate::subject::subject_fn;
618    use crate::{Eval, Sample, Target, Transcript};
619    use serde_json::json;
620
621    fn study() -> Study {
622        Study::new().eval(
623            Eval::new("greet")
624                .describe("greeting eval")
625                .meta("suite", "smoke")
626                .add_sample(
627                    Sample::new("hi", "say hi")
628                        .tag("smoke")
629                        .meta("difficulty", "easy"),
630                )
631                .subject(subject_fn(|_, _| async {
632                    Transcript::response("hi there")
633                }))
634                .scorer(contains("hi"))
635                .targets([Target::sim().meta("agent", "demo")])
636                .build(),
637        )
638    }
639
640    #[test]
641    fn initialize_advertises_capability_params() {
642        let init = advertised_capability_params();
643        let info = InitializeResult {
644            protocol_version: PROTOCOL_VERSION.into(),
645            study: "x".into(),
646            evals: 0,
647            study_version: None,
648            capabilities: vec![capabilities::EVENTS.into()],
649            capability_params: init,
650        };
651        // Structured config keyed by capability token, readable via the accessor.
652        let modalities = info.capability_param("modalities").unwrap();
653        assert!(
654            modalities["input"]
655                .as_array()
656                .unwrap()
657                .iter()
658                .any(|m| m == "image")
659        );
660        assert!(info.capability_param("events").unwrap()["kinds"][0] == "started");
661        assert!(info.capability_param("absent").is_none());
662        // Round-trips on the committed wire.
663        let back: InitializeResult =
664            serde_json::from_str(&serde_json::to_string(&info).unwrap()).unwrap();
665        assert_eq!(back.capability_params, info.capability_params);
666    }
667
668    #[test]
669    fn list_advertises_everything() {
670        let listing = study().list();
671        assert_eq!(listing.evals.len(), 1);
672        let e = &listing.evals[0];
673        assert_eq!(e.description, "greeting eval");
674        assert_eq!(e.metadata.get("suite").unwrap(), "smoke");
675        assert_eq!(e.samples[0].tags, vec!["smoke"]);
676        // Per-sample and per-target metadata ride their own wire columns.
677        assert_eq!(e.samples[0].metadata.get("difficulty").unwrap(), "easy");
678        assert_eq!(e.targets[0].label, "sim");
679        assert!(e.targets[0].available);
680        assert_eq!(e.targets[0].metadata.get("agent").unwrap(), "demo");
681    }
682
683    fn big_study(samples: usize, page: usize) -> Study {
684        let mut eval = Eval::new("big")
685            .subject(subject_fn(|_, _| async { Transcript::response("ok") }))
686            .scorer(contains("ok"));
687        for i in 0..samples {
688            eval = eval.add_sample(Sample::new(format!("s{i}"), "go"));
689        }
690        Study::new().page_size(page).eval(eval.build())
691    }
692
693    #[test]
694    fn list_paginates_first_page_with_cursor() {
695        let s = big_study(250, 100);
696        let listing = s.list();
697        let e = &listing.evals[0];
698        assert_eq!(e.samples.len(), 100);
699        assert_eq!(e.samples[0].id, "s0");
700        assert_eq!(e.next_cursor.as_deref(), Some("100"));
701    }
702
703    #[test]
704    fn list_samples_walks_every_page_then_stops() {
705        let s = big_study(250, 100);
706        // Reassemble the dataset by following cursors, as the host does.
707        let mut ids: Vec<String> = s.list().evals[0]
708            .samples
709            .iter()
710            .map(|x| x.id.clone())
711            .collect();
712        let mut cursor = s.list().evals[0].next_cursor.clone();
713        while let Some(c) = cursor {
714            let page = s
715                .list_samples(&ListSamplesParams {
716                    eval: "big".into(),
717                    cursor: c,
718                })
719                .unwrap();
720            ids.extend(page.samples.into_iter().map(|x| x.id));
721            cursor = page.next_cursor;
722        }
723        assert_eq!(ids.len(), 250);
724        assert_eq!(ids[0], "s0");
725        assert_eq!(ids[249], "s249");
726        // The final page (s200..s249, exactly one page) reports no continuation.
727        let last = s
728            .list_samples(&ListSamplesParams {
729                eval: "big".into(),
730                cursor: "200".into(),
731            })
732            .unwrap();
733        assert_eq!(last.samples.len(), 50);
734        assert!(last.next_cursor.is_none());
735    }
736
737    #[test]
738    fn page_size_zero_disables_pagination() {
739        let s = big_study(250, 0);
740        let e = &s.list().evals[0];
741        assert_eq!(e.samples.len(), 250);
742        assert!(e.next_cursor.is_none());
743    }
744
745    #[test]
746    fn list_samples_rejects_unknown_eval_and_bad_cursor() {
747        let s = big_study(10, 5);
748        assert!(
749            s.list_samples(&ListSamplesParams {
750                eval: "nope".into(),
751                cursor: "0".into(),
752            })
753            .is_err()
754        );
755        assert!(
756            s.list_samples(&ListSamplesParams {
757                eval: "big".into(),
758                cursor: "xyz".into(),
759            })
760            .is_err()
761        );
762        // An offset past the end is benign: an empty final page, no next cursor.
763        let past = s
764            .list_samples(&ListSamplesParams {
765                eval: "big".into(),
766                cursor: "999".into(),
767            })
768            .unwrap();
769        assert!(past.samples.is_empty());
770        assert!(past.next_cursor.is_none());
771    }
772
773    #[tokio::test]
774    async fn run_scores_a_case() {
775        let params = RunParams {
776            eval: "greet".into(),
777            sample: "hi".into(),
778            target: "sim".into(),
779            params: Default::default(),
780            trial: 0,
781            trials: 0,
782            seed: None,
783        };
784        let result = study().run(&params).await.unwrap();
785        assert!(result.passed);
786        assert_eq!(result.transcript.final_response, "hi there");
787    }
788
789    #[tokio::test]
790    async fn run_echoes_trial_identity_and_threads_seed() {
791        // A study whose subject echoes its seed, so we can confirm the host's
792        // trial/seed params reached the subject and round-tripped into the result.
793        let s = Study::new().eval(
794            Eval::new("rng")
795                .sample("a", "x")
796                .trials(4)
797                .subject(subject_fn(|_, cx| async move {
798                    Transcript::response(format!("seed={:?}", cx.seed()))
799                }))
800                .scorer(contains("seed="))
801                .build(),
802        );
803        let params = RunParams {
804            eval: "rng".into(),
805            sample: "a".into(),
806            target: "sim".into(),
807            params: Default::default(),
808            trial: 2,
809            trials: 4,
810            seed: Some(77),
811        };
812        let result = s.run(&params).await.unwrap();
813        assert_eq!(result.trial, 2);
814        assert_eq!(result.trials, 4);
815        assert_eq!(result.seed, Some(77));
816        assert_eq!(result.key(), "rng/a@sim#2");
817        assert!(result.transcript.final_response.contains("77"));
818    }
819
820    #[tokio::test]
821    async fn run_and_score_carry_sample_input_and_expected() {
822        // A persisted result is self-describing: the sample's input turns and
823        // expected value ride along on the RunResult, whether the case was run,
824        // scored, or skipped for an unavailable target.
825        let s = Study::new().eval(
826            Eval::new("qa")
827                .add_sample(Sample::new("a", "what is 6*7?").expected("42"))
828                .subject(subject_fn(|_, _| async { Transcript::response("42") }))
829                .scorer(contains("42"))
830                .targets([
831                    Target::sim(),
832                    Target::new("down", "anthropic", "claude").available(false),
833                ])
834                .build(),
835        );
836        let case = |target: &str| RunParams {
837            eval: "qa".into(),
838            sample: "a".into(),
839            target: target.into(),
840            params: Default::default(),
841            trial: 0,
842            trials: 0,
843            seed: None,
844        };
845
846        let ran = s.run(&case("sim")).await.unwrap();
847        assert_eq!(ran.input, vec!["what is 6*7?".to_string()]);
848        assert_eq!(ran.expected, Some(json!("42")));
849
850        // An unavailable target yields a skipped result — still self-describing.
851        let skipped = s.run(&case("down")).await.unwrap();
852        assert!(skipped.skipped);
853        assert_eq!(skipped.input, vec!["what is 6*7?".to_string()]);
854        assert_eq!(skipped.expected, Some(json!("42")));
855
856        // The score (re-scoring) path populates them too.
857        let scored = s
858            .score(&ScoreParams {
859                eval: "qa".into(),
860                sample: "a".into(),
861                target: "sim".into(),
862                params: Default::default(),
863                trial: 0,
864                trials: 0,
865                seed: None,
866                transcript: Transcript::response("42"),
867            })
868            .await
869            .unwrap();
870        assert_eq!(scored.input, vec!["what is 6*7?".to_string()]);
871        assert_eq!(scored.expected, Some(json!("42")));
872    }
873
874    #[tokio::test]
875    async fn run_rejects_unknown_eval() {
876        let params = RunParams {
877            eval: "nope".into(),
878            sample: "hi".into(),
879            target: "sim".into(),
880            params: Default::default(),
881            trial: 0,
882            trials: 0,
883            seed: None,
884        };
885        assert!(study().run(&params).await.is_err());
886    }
887
888    #[tokio::test]
889    async fn execute_returns_full_transcript_without_scoring() {
890        let params = RunParams {
891            eval: "greet".into(),
892            sample: "hi".into(),
893            target: "sim".into(),
894            params: Default::default(),
895            trial: 0,
896            trials: 0,
897            seed: None,
898        };
899        let captured = study().execute(&params).await.unwrap();
900        assert!(!captured.skipped);
901        assert_eq!(captured.transcript.final_response, "hi there");
902    }
903
904    #[tokio::test]
905    async fn execute_then_score_matches_run() {
906        let s = study();
907        let rp = RunParams {
908            eval: "greet".into(),
909            sample: "hi".into(),
910            target: "sim".into(),
911            params: Default::default(),
912            trial: 0,
913            trials: 0,
914            seed: None,
915        };
916        let fused = s.run(&rp).await.unwrap();
917
918        // Split path: execute, then score the captured transcript.
919        let captured = s.execute(&rp).await.unwrap();
920        let sp = ScoreParams {
921            eval: captured.eval.clone(),
922            sample: captured.sample.clone(),
923            target: captured.target.clone(),
924            params: captured.params.clone(),
925            trial: captured.trial,
926            trials: captured.trials,
927            seed: captured.seed,
928            transcript: captured.transcript.clone(),
929        };
930        let split = s.score(&sp).await.unwrap();
931
932        assert_eq!(split.passed, fused.passed);
933        assert_eq!(split.aggregate, fused.aggregate);
934        assert_eq!(split.scores, fused.scores);
935        assert_eq!(
936            split.transcript.final_response,
937            fused.transcript.final_response
938        );
939    }
940
941    #[tokio::test]
942    async fn score_is_repeatable_for_rescoring() {
943        let s = study();
944        let sp = ScoreParams {
945            eval: "greet".into(),
946            sample: "hi".into(),
947            target: "sim".into(),
948            params: Default::default(),
949            trial: 0,
950            trials: 0,
951            seed: None,
952            transcript: Transcript::response("hi there"),
953        };
954        let first = s.score(&sp).await.unwrap();
955        let second = s.score(&sp).await.unwrap();
956        assert_eq!(first.scores, second.scores);
957        assert!(first.passed && second.passed);
958    }
959
960    #[tokio::test]
961    async fn score_normalizes_a_trajectory_only_transcript() {
962        use crate::scorer::{tool_called, tool_calls_within};
963        use crate::trajectory::{Agent, Step, StepSource, ToolCall, Trajectory};
964
965        // A foreign/polyglot study serialized ONLY `{"transcript": {"trajectory": …}}`
966        // — no flat fields, no events. The study must normalize on receipt so
967        // the existing name-based scorers see the projected tool names.
968        let s = Study::new().eval(
969            Eval::new("traj")
970                .sample("hi", "say hi")
971                .subject(subject_fn(|_, _| async { Transcript::response("unused") }))
972                .scorer(contains("hi there"))
973                .scorer(tool_called("search"))
974                .scorer(tool_calls_within(1))
975                .build(),
976        );
977
978        let mut trajectory = Trajectory::new(Agent::new("external-agent", "1.0"));
979        let mut step = Step::new(1, StepSource::Agent, "hi there");
980        step.tool_calls = vec![ToolCall::new(
981            "c1",
982            "search",
983            serde_json::json!({"q": "hi"}),
984        )];
985        trajectory.steps.push(step);
986        // The exact wire shape a trajectory-only producer emits.
987        let wire = serde_json::json!({ "trajectory": trajectory });
988        let transcript: Transcript = serde_json::from_value(wire).unwrap();
989
990        let result = s
991            .score(&ScoreParams {
992                eval: "traj".into(),
993                sample: "hi".into(),
994                target: "sim".into(),
995                params: Default::default(),
996                trial: 0,
997                trials: 0,
998                seed: None,
999                transcript,
1000            })
1001            .await
1002            .unwrap();
1003
1004        assert!(result.passed, "scores: {:?}", result.scores);
1005        assert!(result.scores.iter().all(|sc| sc.pass));
1006        // The summary carries the projections too.
1007        assert_eq!(result.transcript.final_response, "hi there");
1008        assert_eq!(result.transcript.tool_calls, vec!["search"]);
1009        assert_eq!(result.transcript.tool_calls_count, 1);
1010    }
1011
1012    #[tokio::test]
1013    async fn score_rejects_unknown_eval() {
1014        let sp = ScoreParams {
1015            eval: "nope".into(),
1016            sample: "hi".into(),
1017            target: "sim".into(),
1018            params: Default::default(),
1019            trial: 0,
1020            trials: 0,
1021            seed: None,
1022            transcript: Transcript::response("x"),
1023        };
1024        assert!(study().score(&sp).await.is_err());
1025    }
1026
1027    /// A study whose only case sleeps far longer than the test, so a `run`
1028    /// observably stays in flight until cancelled.
1029    fn slow_study() -> Study {
1030        Study::new().eval(
1031            Eval::new("slow")
1032                .add_sample(Sample::new("s", "go"))
1033                .subject(subject_fn(|_, _| async {
1034                    tokio::time::sleep(std::time::Duration::from_secs(30)).await;
1035                    Transcript::response("done")
1036                }))
1037                .scorer(contains("done"))
1038                .build(),
1039        )
1040    }
1041
1042    /// Drive `serve_io` over in-memory pipes: a `cancel` aborts the in-flight
1043    /// `run` (which would otherwise sleep 30s), the run replies with a `cancelled`
1044    /// error, and the cancel reports it found the request.
1045    #[tokio::test]
1046    async fn cancel_aborts_inflight_run() {
1047        use std::time::Duration;
1048        use tokio::io::AsyncWriteExt;
1049
1050        let (mut host_w, study_r) = tokio::io::duplex(8192);
1051        let (study_w, host_r) = tokio::io::duplex(8192);
1052        let server = tokio::spawn(async move { slow_study().serve_io(study_r, study_w).await });
1053        let mut reader = BufReader::new(host_r).lines();
1054
1055        // Fire the slow run (id 1), then cancel it by that request id (id 2).
1056        host_w
1057            .write_all(
1058                b"{\"id\":1,\"method\":\"run\",\"params\":\
1059                  {\"eval\":\"slow\",\"sample\":\"s\",\"target\":\"sim\"}}\n",
1060            )
1061            .await
1062            .unwrap();
1063        host_w
1064            .write_all(b"{\"id\":2,\"method\":\"cancel\",\"params\":{\"id\":1}}\n")
1065            .await
1066            .unwrap();
1067        host_w.flush().await.unwrap();
1068
1069        // Collect both responses, skipping the `event` notifications.
1070        let (mut run_resp, mut cancel_resp) = (None, None);
1071        while run_resp.is_none() || cancel_resp.is_none() {
1072            let line = tokio::time::timeout(Duration::from_secs(5), reader.next_line())
1073                .await
1074                .expect("response did not arrive — cancel did not abort the run")
1075                .expect("read line")
1076                .expect("study closed early");
1077            let v: serde_json::Value = serde_json::from_str(&line).unwrap();
1078            match v.get("id").and_then(|i| i.as_u64()) {
1079                Some(1) => run_resp = Some(v),
1080                Some(2) => cancel_resp = Some(v),
1081                _ => {} // notification (no id)
1082            }
1083        }
1084
1085        assert_eq!(cancel_resp.unwrap()["result"]["cancelled"], json!(true));
1086        let msg = run_resp.unwrap()["error"]["message"]
1087            .as_str()
1088            .unwrap()
1089            .to_string();
1090        assert!(msg.contains("cancelled"), "run error was {msg:?}");
1091
1092        drop(host_w);
1093        let _ = server.await;
1094    }
1095
1096    /// Cancelling an `id` that isn't in flight (already done, or never sent) is a
1097    /// benign miss: `cancelled: false`, no error.
1098    #[tokio::test]
1099    async fn cancel_unknown_id_is_benign_miss() {
1100        use tokio::io::AsyncWriteExt;
1101
1102        let (mut host_w, study_r) = tokio::io::duplex(8192);
1103        let (study_w, host_r) = tokio::io::duplex(8192);
1104        let server = tokio::spawn(async move { study().serve_io(study_r, study_w).await });
1105        let mut reader = BufReader::new(host_r).lines();
1106
1107        host_w
1108            .write_all(b"{\"id\":9,\"method\":\"cancel\",\"params\":{\"id\":123}}\n")
1109            .await
1110            .unwrap();
1111        host_w.flush().await.unwrap();
1112
1113        let line = reader.next_line().await.unwrap().unwrap();
1114        let v: serde_json::Value = serde_json::from_str(&line).unwrap();
1115        assert_eq!(v["id"], json!(9));
1116        assert_eq!(v["result"]["cancelled"], json!(false));
1117
1118        drop(host_w);
1119        let _ = server.await;
1120    }
1121}