use std::collections::BTreeMap;
use serde::{Deserialize, Deserializer, Serialize};
use crate::envelope::CardKind;
use crate::error::WyrdError;
use crate::reference::{CardRef, Ref};
use super::ids::{JsonPath, TaskId};
use super::operator::{ComparisonOperator, validate_regex_pattern};
use super::plan::{DagError, ExecutionPlan, validate_dag};
use super::result::{EvalContextCapture, EvalPassGate};
use super::task::EvalTask;
use super::workflow::Workflow;
pub const MAX_EVAL_TASKS: usize = 512;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct EvalSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dataset: Option<DatasetRef>,
pub tasks: BTreeMap<TaskId, EvalTask>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow: Option<Workflow>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sampling: Option<EvalSampling>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pass_gate: Option<EvalPassGate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_capture: Option<EvalContextCapture>,
}
impl EvalSpec {
pub fn new(tasks: BTreeMap<TaskId, EvalTask>) -> Result<Self, EvalSpecError> {
if tasks.len() > MAX_EVAL_TASKS {
return Err(EvalSpecError::Wyrd(WyrdError::Validation {
message: format!(
"eval_spec.tasks count {} exceeds MAX_EVAL_TASKS {MAX_EVAL_TASKS}",
tasks.len()
),
details: serde_json::Value::Null,
}));
}
validate_task_keys(&tasks).map_err(EvalSpecError::Wyrd)?;
validate_dag(&tasks).map_err(EvalSpecError::Dag)?;
Ok(Self {
dataset: None,
tasks,
workflow: None,
sampling: None,
pass_gate: None,
context_capture: None,
})
}
pub fn validate(&self) -> Result<(), EvalSpecError> {
if self.tasks.len() > MAX_EVAL_TASKS {
return Err(EvalSpecError::Wyrd(WyrdError::Validation {
message: format!(
"eval_spec.tasks count {} exceeds MAX_EVAL_TASKS {MAX_EVAL_TASKS}",
self.tasks.len()
),
details: serde_json::Value::Null,
}));
}
validate_task_keys(&self.tasks).map_err(EvalSpecError::Wyrd)?;
validate_dag(&self.tasks).map_err(EvalSpecError::Dag)?;
for task in self.tasks.values() {
if let EvalTask::LlmJudge(task) = task {
task.validate().map_err(EvalSpecError::Wyrd)?;
}
if let Some(condition) = task.condition() {
condition.validate().map_err(EvalSpecError::Wyrd)?;
}
validate_task_operator(task).map_err(EvalSpecError::Wyrd)?;
}
if let Some(dataset) = &self.dataset {
dataset.validate().map_err(EvalSpecError::Wyrd)?;
}
if let Some(pass_gate) = &self.pass_gate {
pass_gate.validate().map_err(EvalSpecError::Wyrd)?;
}
if let Some(sampling) = &self.sampling {
sampling.validate().map_err(EvalSpecError::Wyrd)?;
}
Ok(())
}
pub fn execution_plan(&self) -> Result<ExecutionPlan, DagError> {
validate_dag(&self.tasks)
}
}
#[derive(Debug, thiserror::Error)]
pub enum EvalSpecError {
#[error("dag validation failed: {0}")]
Dag(#[from] DagError),
#[error("validation failed: {0}")]
Wyrd(#[from] WyrdError),
}
impl From<EvalSpecError> for WyrdError {
fn from(error: EvalSpecError) -> Self {
match error {
EvalSpecError::Dag(error) => error.into(),
EvalSpecError::Wyrd(error) => error,
}
}
}
fn validate_task_operator(task: &EvalTask) -> Result<(), WyrdError> {
let operator = match task {
EvalTask::Assertion(t) => &t.operator,
EvalTask::LlmJudge(t) => &t.operator,
EvalTask::TraceAssertion(t) => &t.operator,
EvalTask::AgentAssertion(t) => &t.operator,
};
match operator {
ComparisonOperator::MatchesRegex { pattern }
| ComparisonOperator::NotMatchesRegex { pattern } => validate_regex_pattern(pattern),
_ => Ok(()),
}
}
fn validate_task_keys(tasks: &BTreeMap<TaskId, EvalTask>) -> Result<(), WyrdError> {
for (key, task) in tasks {
if key != task.id() {
return Err(WyrdError::Validation {
message: format!(
"eval_spec.tasks key {:?} must match inner task id {:?}",
key.as_str(),
task.id().as_str()
),
details: serde_json::json!({
"field": "tasks",
"key": key.as_str(),
"task_id": task.id().as_str(),
}),
});
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
#[serde(transparent)]
pub struct DatasetRef(pub Ref);
impl<'de> Deserialize<'de> for DatasetRef {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let reference = Ref::deserialize(deserializer)?;
Self::new(reference).map_err(serde::de::Error::custom)
}
}
impl DatasetRef {
pub fn new(reference: Ref) -> Result<Self, WyrdError> {
if let Some(card_ref) = reference.as_card_ref()
&& card_ref.kind != CardKind::Data
{
return Err(Self::kind_mismatch(&card_ref.kind));
}
Ok(Self(reference))
}
pub fn validate(&self) -> Result<(), WyrdError> {
if let Some(card_ref) = self.0.as_card_ref()
&& card_ref.kind != CardKind::Data
{
return Err(Self::kind_mismatch(&card_ref.kind));
}
Ok(())
}
#[must_use]
pub fn as_card_ref(&self) -> Option<&CardRef> {
self.0.as_card_ref()
}
fn kind_mismatch(kind: &CardKind) -> WyrdError {
WyrdError::ValaEvalRefKindMismatch {
message: format!("dataset_ref must reference a Data card; got {kind:?}"),
details: serde_json::json!({
"field": "dataset",
"expected": "Data",
"got": format!("{kind:?}"),
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum EvalSampling {
Ratio {
ratio: f64,
},
DeterministicByHash {
key_path: JsonPath,
modulus: u32,
bucket: u32,
},
EveryNth {
n: u32,
},
}
impl EvalSampling {
pub fn validate(&self) -> Result<(), WyrdError> {
match self {
EvalSampling::Ratio { ratio } => {
if !(0.0..=1.0).contains(ratio) || ratio.is_nan() {
return Err(WyrdError::Validation {
message: format!("eval_sampling.ratio must be in [0.0, 1.0]; got {ratio}"),
details: serde_json::Value::Null,
});
}
}
EvalSampling::DeterministicByHash {
modulus, bucket, ..
} => {
if *modulus == 0 {
return Err(WyrdError::Validation {
message: "eval_sampling.modulus must be >= 1".to_string(),
details: serde_json::Value::Null,
});
}
if bucket >= modulus {
return Err(WyrdError::Validation {
message: format!(
"eval_sampling.bucket {bucket} must be < modulus {modulus}"
),
details: serde_json::Value::Null,
});
}
}
EvalSampling::EveryNth { n } => {
if *n == 0 {
return Err(WyrdError::Validation {
message: "eval_sampling.n must be >= 1".to_string(),
details: serde_json::Value::Null,
});
}
}
}
Ok(())
}
}