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