use std::collections::{BTreeMap, HashMap};
use agent_infra_sdk::evaluate_contract::{
EvalBudget, EvalTurn, EvaluatorImplementation, MetricSpec, RegisterEnvironmentProfileRequest,
RegisterEvalCaseRequest, RegisterEvaluatorVersionRequest, RegisterSuiteRequest, TypedAssertion,
VerifyCommand, VersionedResourceRef,
};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct EvalBundle {
pub suite_id: String,
#[serde(default)]
pub environment_profiles: Vec<BundleEnvironmentProfile>,
#[serde(default)]
pub evaluators: Vec<BundleEvaluator>,
pub cases: Vec<BundleCase>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct BundleEnvironmentProfile {
pub profile_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub space_id: Option<String>,
#[serde(default)]
pub metadata: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct BundleEvaluator {
pub evaluator_id: String,
pub implementation: EvaluatorImplementation,
#[serde(default)]
pub metric: MetricSpec,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct BundleCase {
pub id: String,
pub category: String,
#[serde(default)]
pub turns: Vec<EvalTurn>,
#[serde(default)]
pub workspace_files: BTreeMap<String, String>,
#[serde(default)]
pub assertions: Vec<TypedAssertion>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment_profile: Option<String>,
#[serde(default)]
pub evaluators: Vec<String>,
#[serde(default)]
pub verify_commands: Vec<VerifyCommand>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fixture_ref: Option<String>,
#[serde(default)]
pub budget: EvalBudget,
}
#[derive(Debug, Clone)]
pub struct ResolvedBundle {
pub environment_profiles: Vec<RegisterEnvironmentProfileRequest>,
pub evaluators: Vec<RegisterEvaluatorVersionRequest>,
pub cases: Vec<RegisterEvalCaseRequest>,
pub suite: RegisterSuiteRequest,
}
pub fn resolve(bundle: &EvalBundle) -> Result<ResolvedBundle> {
if bundle.suite_id.trim().is_empty() {
bail!("bundle requires a suiteId");
}
if bundle.cases.is_empty() {
bail!("bundle requires at least one case");
}
let mut profile_versions = HashMap::with_capacity(bundle.environment_profiles.len());
let environment_profiles = bundle
.environment_profiles
.iter()
.map(|profile| {
let version = content_version(profile)?;
if profile_versions
.insert(profile.profile_id.as_str(), version.clone())
.is_some()
{
bail!("duplicate environment profile '{}'", profile.profile_id);
}
Ok(RegisterEnvironmentProfileRequest {
profile_id: profile.profile_id.clone(),
version,
space_id: profile.space_id.clone(),
metadata: profile.metadata.clone(),
})
})
.collect::<Result<Vec<_>>>()?;
let mut evaluator_versions = HashMap::with_capacity(bundle.evaluators.len());
let evaluators = bundle
.evaluators
.iter()
.map(|evaluator| {
let version = content_version(evaluator)?;
if evaluator_versions
.insert(evaluator.evaluator_id.as_str(), version.clone())
.is_some()
{
bail!("duplicate evaluator '{}'", evaluator.evaluator_id);
}
Ok(RegisterEvaluatorVersionRequest {
evaluator_id: evaluator.evaluator_id.clone(),
version,
implementation: evaluator.implementation.clone(),
metric: evaluator.metric.clone(),
})
})
.collect::<Result<Vec<_>>>()?;
let cases = bundle
.cases
.iter()
.map(|case| {
let environment_profile = case
.environment_profile
.as_ref()
.map(|id| {
profile_versions
.get(id.as_str())
.map(|version| VersionedResourceRef {
id: id.clone(),
version: version.clone(),
})
.with_context(|| {
format!(
"case '{}' references unknown environment profile '{id}'",
case.id
)
})
})
.transpose()?;
let evaluator_refs = case
.evaluators
.iter()
.map(|id| {
evaluator_versions
.get(id.as_str())
.map(|version| VersionedResourceRef {
id: id.clone(),
version: version.clone(),
})
.with_context(|| {
format!("case '{}' references unknown evaluator '{id}'", case.id)
})
})
.collect::<Result<Vec<_>>>()?;
let mut case = RegisterEvalCaseRequest {
case_id: case.id.clone(),
version: String::new(),
category: case.category.clone(),
turns: case.turns.clone(),
workspace_files: case.workspace_files.clone(),
assertions: case.assertions.clone(),
environment_profile,
evaluator_versions: evaluator_refs,
verify_commands: case.verify_commands.clone(),
fixture_ref: case.fixture_ref.clone(),
budget: case.budget.clone(),
};
case.version = content_version(&case)?;
Ok(case)
})
.collect::<Result<Vec<_>>>()?;
let mut case_ids = HashMap::with_capacity(cases.len());
let case_refs = cases
.iter()
.map(|case| {
if case_ids.insert(case.case_id.as_str(), ()).is_some() {
bail!("duplicate case '{}'", case.case_id);
}
Ok(VersionedResourceRef {
id: case.case_id.clone(),
version: case.version.clone(),
})
})
.collect::<Result<Vec<_>>>()?;
let manifest_digest = digest(&case_refs)?;
let version = short_version(&manifest_digest);
Ok(ResolvedBundle {
environment_profiles,
evaluators,
cases,
suite: RegisterSuiteRequest {
suite_id: bundle.suite_id.clone(),
version,
manifest_digest,
schema_version: 1,
cases: case_refs,
},
})
}
fn content_version(value: &impl Serialize) -> Result<String> {
Ok(short_version(&digest(value)?))
}
fn digest(value: &impl Serialize) -> Result<String> {
Ok(format!(
"sha256:{:x}",
Sha256::digest(serde_json::to_vec(value)?)
))
}
fn short_version(digest: &str) -> String {
format!("sha256-{}", &digest["sha256:".len()..][..16])
}
#[cfg(test)]
mod tests {
use super::*;
fn bundle() -> EvalBundle {
EvalBundle {
suite_id: "core".into(),
environment_profiles: vec![BundleEnvironmentProfile {
profile_id: "coding".into(),
space_id: Some("space-1".into()),
metadata: BTreeMap::new(),
}],
evaluators: vec![BundleEvaluator {
evaluator_id: "grounded".into(),
implementation: EvaluatorImplementation::BuiltinAssertions {
assertions: vec![TypedAssertion::OutputContains {
turn: 0,
values: vec!["ok".into()],
any: false,
}],
},
metric: MetricSpec::default(),
}],
cases: vec![BundleCase {
id: "answer".into(),
category: "correctness".into(),
turns: vec![EvalTurn {
user: "say ok".into(),
}],
workspace_files: BTreeMap::new(),
assertions: Vec::new(),
environment_profile: Some("coding".into()),
evaluators: vec!["grounded".into()],
verify_commands: Vec::new(),
fixture_ref: None,
budget: EvalBudget::default(),
}],
}
}
#[test]
fn identical_content_resolves_to_identical_versions() {
let first = resolve(&bundle()).unwrap();
let second = resolve(&bundle()).unwrap();
assert_eq!(first.suite.version, second.suite.version);
assert_eq!(first.evaluators[0].version, second.evaluators[0].version);
}
#[test]
fn changing_an_evaluator_changes_both_its_version_and_the_suite_version() {
let baseline = resolve(&bundle()).unwrap();
let mut changed = bundle();
changed.evaluators[0].implementation = EvaluatorImplementation::BuiltinAssertions {
assertions: vec![TypedAssertion::OutputContains {
turn: 0,
values: vec!["different".into()],
any: false,
}],
};
let resolved = resolve(&changed).unwrap();
assert_ne!(
baseline.evaluators[0].version,
resolved.evaluators[0].version
);
assert_ne!(baseline.suite.version, resolved.suite.version);
}
#[test]
fn changing_a_metric_class_changes_the_evaluator_version() {
let baseline = resolve(&bundle()).unwrap();
let mut changed = bundle();
changed.evaluators[0].metric.class =
agent_infra_sdk::evaluate_contract::MetricClass::Guardrail;
let resolved = resolve(&changed).unwrap();
assert_ne!(
baseline.evaluators[0].version,
resolved.evaluators[0].version
);
}
#[test]
fn case_references_are_rewritten_into_versioned_references() {
let resolved = resolve(&bundle()).unwrap();
let case = &resolved.cases[0];
assert_eq!(
case.evaluator_versions[0].version,
resolved.evaluators[0].version
);
assert_eq!(
case.environment_profile.as_ref().unwrap().version,
resolved.environment_profiles[0].version
);
assert_eq!(
resolved.suite.cases[0],
VersionedResourceRef {
id: case.case_id.clone(),
version: case.version.clone(),
}
);
}
#[test]
fn changing_one_case_leaves_the_other_case_versions_untouched() {
let mut two_cases = bundle();
let mut second = two_cases.cases[0].clone();
second.id = "second".into();
two_cases.cases.push(second);
let baseline = resolve(&two_cases).unwrap();
let mut changed = two_cases.clone();
changed.cases[0].turns = vec![EvalTurn {
user: "say something else".into(),
}];
let resolved = resolve(&changed).unwrap();
assert_ne!(baseline.cases[0].version, resolved.cases[0].version);
assert_eq!(baseline.cases[1].version, resolved.cases[1].version);
assert_ne!(baseline.suite.version, resolved.suite.version);
}
#[test]
fn an_unknown_reference_is_rejected_before_anything_is_registered() {
let mut invalid = bundle();
invalid.cases[0].evaluators = vec!["missing".into()];
let error = resolve(&invalid).unwrap_err().to_string();
assert!(error.contains("unknown evaluator 'missing'"), "{error}");
}
#[test]
fn duplicate_declarations_are_rejected() {
let mut invalid = bundle();
let duplicate = invalid.evaluators[0].clone();
invalid.evaluators.push(duplicate);
assert!(
resolve(&invalid)
.unwrap_err()
.to_string()
.contains("duplicate evaluator")
);
}
}