Skip to main content

trybench_sdk/
evaluation.rs

1use crate::{Bench, SpanInput, TraceContext};
2use serde::{Deserialize, Serialize};
3use serde_json::{json, Value};
4use sha2::{Digest, Sha256};
5use std::{
6    collections::HashSet,
7    future::Future,
8    pin::Pin,
9    sync::{Arc, Mutex},
10    time::Duration,
11};
12use tokio::sync::watch;
13
14type CallbackFuture = Pin<Box<dyn Future<Output = Result<Value, String>> + Send>>;
15type Run = Arc<dyn Fn(Value, EvaluationContext) -> CallbackFuture + Send + Sync>;
16type Observe = Arc<dyn Fn(EvaluationContext) -> CallbackFuture + Send + Sync>;
17
18#[derive(Clone)]
19pub struct EvaluationContext {
20    pub case_id: String,
21    pub trace: TraceContext,
22    cancellation: watch::Receiver<bool>,
23}
24impl EvaluationContext {
25    pub fn is_cancelled(&self) -> bool {
26        *self.cancellation.borrow() || self.cancellation.has_changed().is_err()
27    }
28    pub async fn cancelled(&self) {
29        let mut signal = self.cancellation.clone();
30        if !*signal.borrow() {
31            let _ = signal.changed().await;
32        }
33    }
34}
35
36#[derive(Clone)]
37pub struct Application {
38    run: Run,
39    observe: Option<Observe>,
40}
41impl Application {
42    pub fn new<F, Fut>(run: F) -> Self
43    where
44        F: Fn(Value, EvaluationContext) -> Fut + Send + Sync + 'static,
45        Fut: Future<Output = Result<Value, String>> + Send + 'static,
46    {
47        Self {
48            run: Arc::new(move |input, ctx| Box::pin(run(input, ctx))),
49            observe: None,
50        }
51    }
52    pub fn with_observer<F, Fut>(mut self, observe: F) -> Self
53    where
54        F: Fn(EvaluationContext) -> Fut + Send + Sync + 'static,
55        Fut: Future<Output = Result<Value, String>> + Send + 'static,
56    {
57        self.observe = Some(Arc::new(move |ctx| Box::pin(observe(ctx))));
58        self
59    }
60}
61
62fn present<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<Value>, D::Error> {
63    Value::deserialize(d).map(Some)
64}
65#[derive(Clone, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct SystemCase {
68    pub id: String,
69    pub input: Value,
70    #[serde(default, skip_serializing_if = "String::is_empty")]
71    pub split: String,
72    #[serde(
73        default,
74        skip_serializing_if = "Option::is_none",
75        deserialize_with = "present"
76    )]
77    pub expected_output: Option<Value>,
78    #[serde(
79        default,
80        skip_serializing_if = "Option::is_none",
81        deserialize_with = "present"
82    )]
83    pub expected_state: Option<Value>,
84    #[serde(default, skip_serializing_if = "String::is_empty")]
85    pub business_outcome: String,
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub required_tools: Vec<String>,
88    #[serde(default, skip_serializing_if = "Vec::is_empty")]
89    pub forbidden_tools: Vec<String>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub max_tool_calls: Option<usize>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub max_model_calls: Option<usize>,
94}
95impl SystemCase {
96    pub fn new(id: impl Into<String>, input: Value) -> Self {
97        Self {
98            id: id.into(),
99            input,
100            split: String::new(),
101            expected_output: None,
102            expected_state: None,
103            business_outcome: String::new(),
104            required_tools: vec![],
105            forbidden_tools: vec![],
106            max_tool_calls: None,
107            max_model_calls: None,
108        }
109    }
110}
111#[derive(Clone)]
112pub struct EvaluationOptions {
113    pub source_revision: String,
114    pub context_revision: String,
115    pub cases: Vec<SystemCase>,
116    pub timeout: Duration,
117}
118impl EvaluationOptions {
119    pub fn new(
120        source: impl Into<String>,
121        context: impl Into<String>,
122        cases: Vec<SystemCase>,
123    ) -> Self {
124        Self {
125            source_revision: source.into(),
126            context_revision: context.into(),
127            cases,
128            timeout: Duration::from_secs(30),
129        }
130    }
131}
132#[derive(Clone, Debug, Serialize, Deserialize)]
133pub struct EvaluationSummary {
134    pub status: String,
135    pub score: Option<f64>,
136    pub passed: usize,
137    pub failed: usize,
138    pub errors: usize,
139    pub unscored: usize,
140}
141#[derive(Clone, Debug, Serialize, Deserialize)]
142pub struct EvaluationReport {
143    pub schema_version: u8,
144    pub execution_mode: String,
145    pub evidence_origin: String,
146    pub environment: String,
147    pub source_revision: String,
148    pub context_revision: String,
149    pub suite_hash: String,
150    pub planned_case_count: usize,
151    pub coverage: Value,
152    pub cases: Vec<Value>,
153    pub summary: EvaluationSummary,
154}
155impl EvaluationReport {
156    pub fn passed(&self) -> bool {
157        self.summary.status == "completed"
158            && self.planned_case_count > 0
159            && self.cases.len() == self.planned_case_count
160            && self.cases.iter().all(|c| c["status"] == "passed")
161    }
162}
163
164pub(crate) struct Capture {
165    active: bool,
166    limited: bool,
167    pending: usize,
168    spans: Vec<Value>,
169}
170impl Capture {
171    fn new() -> Self {
172        Self {
173            active: true,
174            limited: false,
175            pending: 0,
176            spans: vec![],
177        }
178    }
179    pub(crate) fn start(&mut self) {
180        self.pending += 1;
181    }
182    pub(crate) fn finish(&mut self, row: Option<Value>) {
183        self.pending -= 1;
184        if self.active {
185            match row {
186                Some(row) if self.spans.len() < 100 => self.spans.push(row),
187                _ => self.limited = true,
188            }
189        }
190    }
191    fn freeze(&mut self) -> (Vec<Value>, bool, usize) {
192        self.active = false;
193        (self.spans.clone(), self.limited, self.pending)
194    }
195}
196
197fn validate(options: &EvaluationOptions) -> Result<(), String> {
198    if options.source_revision.len() != 40
199        || !options
200            .source_revision
201            .bytes()
202            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
203        || options.context_revision.is_empty()
204        || options.context_revision.len() > 200
205    {
206        return Err("Pin source and context revisions.".into());
207    }
208    if options.cases.is_empty()
209        || options.cases.len() > 100
210        || options.timeout.is_zero()
211        || options.timeout > Duration::from_secs(300)
212    {
213        return Err("Use 1 to 100 cases and a timeout up to 300 seconds.".into());
214    }
215    let raw = serde_json::to_vec(&options.cases).map_err(|_| "Invalid case suite.")?;
216    if raw.len() > 500000 {
217        return Err("Case suite exceeds 500 KB.".into());
218    }
219    let mut ids = HashSet::new();
220    for c in &options.cases {
221        if c.id.is_empty() || c.id.len() > 100 || !ids.insert(&c.id) {
222            return Err("Case IDs must be unique and nonempty.".into());
223        }
224        if !matches!(
225            c.split.as_str(),
226            "" | "capability" | "regression" | "incident" | "holdout"
227        ) {
228            return Err("Invalid case split.".into());
229        }
230        if c.business_outcome.len() > 2000
231            || c.required_tools.len() + c.forbidden_tools.len() > 90
232            || c.required_tools
233                .iter()
234                .chain(&c.forbidden_tools)
235                .any(|n| n.is_empty() || n.len() > 200)
236            || c.max_tool_calls.is_some_and(|n| n > 10000)
237            || c.max_model_calls.is_some_and(|n| n > 10000)
238        {
239            return Err("Case assertions exceed limits.".into());
240        }
241    }
242    Ok(())
243}
244
245fn score(
246    c: &SystemCase,
247    output: &Value,
248    state: Option<&Value>,
249    spans: Vec<Value>,
250    mut error: Option<String>,
251) -> Value {
252    if !spans
253        .iter()
254        .any(|s| s.get("parent_span_id").is_none() && s["kind"] == "AGENT" && s["status"] == "ok")
255    {
256        error.get_or_insert("Application root was not recorded. No complete score.".into());
257    }
258    if c.expected_state.is_some() && state.is_none() {
259        error.get_or_insert("Application state was not observed. No complete score.".into());
260    }
261    let tools: Vec<_> = spans.iter().filter(|s| s["kind"] == "TOOL").collect();
262    let mut checks = vec![];
263    let mut add = |id: String, passed: bool, reason: &str| {
264        checks.push(json!({"id":id,"passed":passed,"reason":reason}))
265    };
266    if let Some(expected) = &c.expected_output {
267        add(
268            "expected-output".into(),
269            output == expected,
270            "Compare actual output with the expected outcome.",
271        );
272    }
273    if let (Some(expected), Some(state)) = (&c.expected_state, state) {
274        add(
275            "expected-state".into(),
276            state == expected,
277            "Compare observed tool effects with the expected business state.",
278        );
279    }
280    for name in &c.required_tools {
281        add(
282            format!("required-tool:{name}"),
283            tools
284                .iter()
285                .any(|s| s["name"] == *name && s["status"] == "ok"),
286            "A successful recorded tool call is required.",
287        );
288    }
289    for name in &c.forbidden_tools {
290        add(
291            format!("forbidden-tool:{name}"),
292            !tools.iter().any(|s| s["name"] == *name),
293            "This tool must not be invoked.",
294        );
295    }
296    if let Some(n) = c.max_tool_calls {
297        add(
298            "tool-call-limit".into(),
299            tools.len() <= n,
300            "Recorded tool calls must stay within the limit.",
301        );
302    }
303    if let Some(n) = c.max_model_calls {
304        add(
305            "model-call-limit".into(),
306            spans.iter().filter(|s| s["kind"] == "LLM").count() <= n,
307            "Recorded model calls must stay within the limit.",
308        );
309    }
310    let ids: Vec<_> = spans.iter().map(|s| s["span_id"].clone()).collect();
311    let mut findings = vec![];
312    for tool in &tools {
313        if tool["status"] == "error" {
314            findings.push(json!({"category":"tool","title":format!("{} failed",tool["name"].as_str().unwrap_or("Tool")),"confidence":"observed_failure","evidence_span_ids":[tool["span_id"]],"fix_brief":"Inspect this tool's contract and dependencies. Reproduce the failure and rerun incident and regression cases."}));
315        }
316    }
317    for check in &checks {
318        if check["passed"] == false {
319            let category = if matches!(
320                check["id"].as_str(),
321                Some("expected-output" | "expected-state")
322            ) {
323                "quality"
324            } else {
325                "harness"
326            };
327            findings.push(json!({"category":category,"title":check["id"],"confidence":"hypothesis","evidence_span_ids":ids,"fix_brief":"Inspect the real application path, tools, state, routing and retries. Rerun unchanged incident, regression and holdout cases."}));
328        }
329    }
330    let status = if error.is_some() {
331        "error"
332    } else if checks.is_empty() {
333        "unscored"
334    } else if checks.iter().all(|c| c["passed"] == true) {
335        "passed"
336    } else {
337        "failed"
338    };
339    let mut row = json!({"id":c.id,"split":if c.split.is_empty(){"regression"}else{&c.split},"status":status,"spans":spans,"checks":checks,"findings":findings});
340    if let Some(error) = error {
341        row["error"] = json!(error)
342    }
343    row
344}
345
346impl Bench {
347    /// Execute the real app against pinned cases. No report is uploaded implicitly.
348    /// Async callbacks must yield and await all child work; this is not a process sandbox.
349    pub async fn evaluate_system(
350        &self,
351        options: EvaluationOptions,
352        application: Application,
353    ) -> Result<EvaluationReport, String> {
354        validate(&options)?;
355        if self
356            .inner
357            .queue
358            .lock()
359            .unwrap_or_else(|e| e.into_inner())
360            .closed
361        {
362            return Err("Bench is shut down.".into());
363        }
364        let raw =
365            serde_json::to_vec(&json!({"context":options.context_revision,"cases":options.cases}))
366                .map_err(|_| "Invalid suite.")?;
367        let hash = format!("{:x}", Sha256::digest(&raw));
368        let mut results = vec![];
369        for case in &options.cases {
370            let capture = Arc::new(Mutex::new(Capture::new()));
371            let (cancel, signal) = watch::channel(false);
372            let client = self.clone();
373            let app = application.clone();
374            let input = case.input.clone();
375            let id = case.id.clone();
376            let recorded = capture.clone();
377            let mut tasks = tokio::task::JoinSet::new();
378            tasks.spawn(async move {
379                let seed = TraceContext {
380                    client: client.inner.id.clone(),
381                    trace_id: uuid::Uuid::new_v4().simple().to_string(),
382                    span_id: String::new(),
383                    sampled: true,
384                    evaluation: Some(recorded),
385                };
386                let mut root = client.start_span(
387                    Some(&seed),
388                    SpanInput::new("system-entrypoint")
389                        .kind("AGENT")
390                        .input(input.clone()),
391                );
392                let context = EvaluationContext {
393                    case_id: id,
394                    trace: root.context(),
395                    cancellation: signal,
396                };
397                let output = (app.run)(input, context.clone()).await?;
398                let state = if let Some(observe) = &app.observe {
399                    Some(observe(context).await?)
400                } else {
401                    None
402                };
403                root.set_output(output.clone());
404                root.end();
405                Ok::<_, String>((output, state))
406            });
407            let (mut failure, output, state, mut stopped) =
408                match tokio::time::timeout(options.timeout, tasks.join_next()).await {
409                    Ok(Some(Ok(Ok((output, state))))) => (None, output, state, false),
410                    Ok(_) => (
411                        Some("Application execution failed. Inspect recorded spans.".to_string()),
412                        Value::Null,
413                        None,
414                        false,
415                    ),
416                    Err(_) => {
417                        let _ = cancel.send(true);
418                        tasks.abort_all();
419                        (
420                            Some("Application timed out or was stopped. No complete score.".into()),
421                            Value::Null,
422                            None,
423                            true,
424                        )
425                    }
426                };
427            let _ = cancel.send(true);
428            let (spans, limited, pending) =
429                capture.lock().unwrap_or_else(|e| e.into_inner()).freeze();
430            if limited || pending > 0 {
431                failure=Some("Application evidence is incomplete. Await all tools and stay within capture limits.".into());
432                stopped = stopped || pending > 0;
433            }
434            let mut row = score(case, &output, state.as_ref(), spans, failure);
435            let filtered =
436                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<(), String> {
437                    row["case_definition"] =
438                        self.safe(serde_json::to_value(case).map_err(|_| "Invalid case.")?)?;
439                    row["output"] = self.safe(output)?;
440                    if let Some(state) = state {
441                        row["observedState"] = self.safe(state)?;
442                    }
443                    Ok(())
444                }));
445            if !matches!(filtered, Ok(Ok(()))) {
446                row["status"] = json!("error");
447                row["error"] = json!("Application evidence could not be redacted.");
448            }
449            results.push(row);
450            if stopped {
451                break;
452            }
453        }
454        let mut summary = EvaluationSummary {
455            status: "incomplete".into(),
456            score: None,
457            passed: 0,
458            failed: 0,
459            errors: 0,
460            unscored: 0,
461        };
462        for row in &results {
463            match row["status"].as_str() {
464                Some("passed") => summary.passed += 1,
465                Some("failed") => summary.failed += 1,
466                Some("error") => summary.errors += 1,
467                _ => summary.unscored += 1,
468            }
469        }
470        if results.len() == options.cases.len() && summary.errors == 0 && summary.unscored == 0 {
471            summary.status = "completed".into();
472            summary.score = Some(100.0 * summary.passed as f64 / results.len() as f64)
473        }
474        Ok(EvaluationReport {
475            schema_version: 1,
476            execution_mode: "application_runtime".into(),
477            evidence_origin: "sdk_client_reported".into(),
478            environment: self
479                .inner
480                .options
481                .environment
482                .clone()
483                .unwrap_or_else(|| "unspecified".into()),
484            source_revision: options.source_revision,
485            context_revision: options.context_revision,
486            suite_hash: hash,
487            planned_case_count: options.cases.len(),
488            coverage: json!({"instrumentation":"explicit_spans","tool_dependencies":"application_configured","hosted_validation":false}),
489            cases: results,
490            summary,
491        })
492    }
493}
494
495impl Bench {
496    /// Save client-reported evidence explicitly. This does not run a paid check.
497    pub async fn publish_system_evaluation(
498        &self,
499        system_id: u64,
500        report: &EvaluationReport,
501    ) -> Result<(), String> {
502        if system_id == 0 || system_id > 9007199254740991 {
503            return Err("Select a system.".into());
504        }
505        let filtered =
506            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<Value, String> {
507                let value =
508                    serde_json::to_value(report).map_err(|_| "Invalid application report.")?;
509                let value = if let Some(redact) = &self.inner.options.redact {
510                    redact(value)?
511                } else {
512                    value
513                };
514                Ok(crate::privacy::redact_limit(value, 0, 300))
515            }))
516            .map_err(|_| "Application report could not be redacted.")??;
517        let body = serde_json::to_vec(&filtered).map_err(|_| "Invalid application report.")?;
518        if body.len() > 500000 {
519            return Err(
520                "Runtime report exceeds 500 KB. Retain it locally or split the suite.".into(),
521            );
522        }
523        let response = self
524            .inner
525            .http
526            .post(format!(
527                "{}/api/ai-systems/{system_id}/runtime-evaluations",
528                self.inner.options.endpoint.trim_end_matches('/')
529            ))
530            .bearer_auth(&self.inner.options.api_key)
531            .header("Content-Type", "application/json")
532            .body(body)
533            .send()
534            .await
535            .map_err(|_| "Could not save application results. Local results remain available.")?;
536        if !response.status().is_success() {
537            return Err(format!(
538                "Could not save application results (HTTP {}). Local results remain available.",
539                response.status().as_u16()
540            ));
541        }
542        Ok(())
543    }
544}