use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use crate::error::WyrdError;
use super::condition::EvalCondition;
use super::ids::{ScenarioId, TaskId};
use super::operator::ComparisonOperator;
pub const MAX_SCENARIOS_PER_COLLECTION: usize = 10_000;
pub const MAX_TURNS_HARD_CAP: u32 = 256;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct EvalScenario {
pub id: ScenarioId,
pub initial_query: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_outcome: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub predefined_turns: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub simulated_user_persona: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub termination_signal: Option<String>,
#[serde(default = "default_max_turns")]
pub max_turns: u32,
#[serde(default)]
pub tasks: Vec<ScenarioTask>,
}
fn default_max_turns() -> u32 {
8
}
impl EvalScenario {
pub fn validate(&self) -> Result<(), WyrdError> {
if self.initial_query.is_empty() {
return Err(WyrdError::Validation {
message: "eval_scenario.initial_query must be non-empty".to_string(),
details: serde_json::Value::Null,
});
}
if self.max_turns == 0 || self.max_turns > MAX_TURNS_HARD_CAP {
return Err(WyrdError::Validation {
message: format!(
"eval_scenario.max_turns must be 1..={MAX_TURNS_HARD_CAP}, got {}",
self.max_turns,
),
details: serde_json::Value::Null,
});
}
let mut seen: BTreeSet<&str> = BTreeSet::new();
for task in &self.tasks {
if !seen.insert(task.id.as_str()) {
return Err(WyrdError::Validation {
message: format!(
"eval_scenario.tasks contains duplicate id {:?}",
task.id.as_str(),
),
details: serde_json::Value::Null,
});
}
if let Some(condition) = &task.condition {
condition.validate()?;
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ScenarioTask {
pub id: TaskId,
pub operator: ComparisonOperator,
pub expected: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub condition: Option<EvalCondition>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct EvalScenarioCollection {
pub collection_id: String,
pub scenarios: Vec<EvalScenario>,
}
impl EvalScenarioCollection {
pub fn validate(&self) -> Result<(), WyrdError> {
if self.collection_id.is_empty() {
return Err(WyrdError::Validation {
message: "eval_scenario_collection.collection_id must be non-empty".to_string(),
details: serde_json::Value::Null,
});
}
if self.scenarios.len() > MAX_SCENARIOS_PER_COLLECTION {
return Err(WyrdError::Validation {
message: format!(
"eval_scenario_collection.scenarios.len() {} exceeds cap {}",
self.scenarios.len(),
MAX_SCENARIOS_PER_COLLECTION,
),
details: serde_json::Value::Null,
});
}
let mut seen: BTreeSet<&str> = BTreeSet::new();
for scenario in &self.scenarios {
if !seen.insert(scenario.id.as_str()) {
return Err(WyrdError::Validation {
message: format!(
"eval_scenario_collection has duplicate scenario id {:?}",
scenario.id.as_str(),
),
details: serde_json::Value::Null,
});
}
scenario.validate()?;
}
Ok(())
}
}