1use 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
47type SharedWriter = Arc<Mutex<Box<dyn AsyncWrite + Send + Unpin>>>;
50
51type Inflight = Arc<std::sync::Mutex<HashMap<u64, oneshot::Sender<()>>>>;
55
56pub const DEFAULT_PAGE_SIZE: usize = 500;
61
62pub struct Study {
66 name: String,
68 evals: Vec<Eval>,
69 page_size: Option<usize>,
72}
73
74impl Default for Study {
75 fn default() -> Self {
76 Self::new()
77 }
78}
79
80impl Study {
81 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 pub fn registered() -> Self {
94 Self::new().evals(registered_evals())
95 }
96
97 pub fn eval(mut self, eval: Eval) -> Self {
99 self.evals.push(eval);
100 self
101 }
102
103 pub fn evals(mut self, evals: impl IntoIterator<Item = Eval>) -> Self {
105 self.evals.extend(evals);
106 self
107 }
108
109 pub fn named(mut self, name: impl Into<String>) -> Self {
111 self.name = name.into();
112 self
113 }
114
115 pub fn page_size(mut self, size: usize) -> Self {
120 self.page_size = (size > 0).then_some(size);
121 self
122 }
123
124 pub async fn serve(self) -> std::io::Result<()> {
127 self.serve_io(tokio::io::stdin(), tokio::io::stdout()).await
128 }
129
130 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 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 write_line(&out, &Notification::log(format!("bad request: {e}"), 0)).await?;
191 continue;
192 }
193 };
194
195 if request.method == "cancel" {
198 let response = cancel(&request, &inflight);
199 write_line(&out, &response).await?;
200 continue;
201 }
202
203 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 _ = 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 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 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(¶ms) {
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 let _ = write_line(stdout, &case_event(request.id, ¶ms, event::STARTED)).await;
289 let result = self.run(¶ms).await;
290 let _ = write_line(stdout, &case_event(request.id, ¶ms, 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, ¶ms, event::STARTED)).await;
308 let result = self.execute(¶ms).await;
309 let _ = write_line(stdout, &case_event(request.id, ¶ms, 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(¶ms).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 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 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 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(¶ms.eval, ¶ms.sample, ¶ms.target)?;
427
428 if !target.available {
430 return Ok(skipped_result(params, sample));
431 }
432
433 let outcome = run_case(eval, sample, target, ¶ms.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 async fn execute(&self, params: &RunParams) -> Result<ExecuteResult, String> {
455 let (eval, sample, target) = self.locate(¶ms.eval, ¶ms.sample, ¶ms.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, ¶ms.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 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 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 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
551fn 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
571fn 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(¶ms.id)
589 .is_some_and(|tx| tx.send(()).is_ok());
590 Response::ok(request.id, json(&CancelResult { cancelled }))
591}
592
593fn 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
610fn 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 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
637fn 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
650async 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 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 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 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 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 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 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(¶ms).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 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(¶ms).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 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 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 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(¶ms).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(¶ms).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 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 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 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 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 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 #[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 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 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 _ => {} }
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 #[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 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 #[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 #[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 #[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}