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 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 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}