1use 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
41type SharedWriter = Arc<Mutex<Box<dyn AsyncWrite + Send + Unpin>>>;
44
45type Inflight = Arc<std::sync::Mutex<HashMap<u64, oneshot::Sender<()>>>>;
49
50pub const DEFAULT_PAGE_SIZE: usize = 500;
55
56pub struct Study {
59 name: String,
61 evals: Vec<Eval>,
62 page_size: Option<usize>,
65}
66
67impl Default for Study {
68 fn default() -> Self {
69 Self::new()
70 }
71}
72
73impl Study {
74 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 pub fn registered() -> Self {
87 Self::new().evals(registered_evals())
88 }
89
90 pub fn eval(mut self, eval: Eval) -> Self {
92 self.evals.push(eval);
93 self
94 }
95
96 pub fn evals(mut self, evals: impl IntoIterator<Item = Eval>) -> Self {
98 self.evals.extend(evals);
99 self
100 }
101
102 pub fn named(mut self, name: impl Into<String>) -> Self {
104 self.name = name.into();
105 self
106 }
107
108 pub fn page_size(mut self, size: usize) -> Self {
113 self.page_size = (size > 0).then_some(size);
114 self
115 }
116
117 pub async fn serve(self) -> std::io::Result<()> {
120 self.serve_io(tokio::io::stdin(), tokio::io::stdout()).await
121 }
122
123 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 write_line(&out, &Notification::log(format!("bad request: {e}"), 0)).await?;
157 continue;
158 }
159 };
160
161 if request.method == "cancel" {
164 let response = cancel(&request, &inflight);
165 write_line(&out, &response).await?;
166 continue;
167 }
168
169 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 _ = 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 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 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(¶ms) {
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 let _ = write_line(stdout, &case_event(request.id, ¶ms, event::STARTED)).await;
255 let result = self.run(¶ms).await;
256 let _ = write_line(stdout, &case_event(request.id, ¶ms, 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, ¶ms, event::STARTED)).await;
274 let result = self.execute(¶ms).await;
275 let _ = write_line(stdout, &case_event(request.id, ¶ms, 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(¶ms).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 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 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 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(¶ms.eval, ¶ms.sample, ¶ms.target)?;
393
394 if !target.available {
396 return Ok(skipped_result(params, sample));
397 }
398
399 let outcome = run_case(eval, sample, target, ¶ms.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 async fn execute(&self, params: &RunParams) -> Result<ExecuteResult, String> {
421 let (eval, sample, target) = self.locate(¶ms.eval, ¶ms.sample, ¶ms.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, ¶ms.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 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 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 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
517fn 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
537fn 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(¶ms.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
563fn 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 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
590fn 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
603async 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 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 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 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 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 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 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(¶ms).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 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(¶ms).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 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 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 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(¶ms).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(¶ms).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 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 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 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 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 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 #[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 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 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 _ => {} }
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 #[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}