use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MetricClass {
Goal,
Guardrail,
#[default]
Diagnostic,
}
impl MetricClass {
pub const fn affects_case_verdict(self) -> bool {
matches!(self, Self::Goal | Self::Guardrail)
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Goal => "goal",
Self::Guardrail => "guardrail",
Self::Diagnostic => "diagnostic",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MetricDecision {
BlockRelease,
RollBack,
Investigate,
#[default]
ObserveOnly,
}
impl MetricDecision {
pub const fn is_blocking(self) -> bool {
matches!(self, Self::BlockRelease | Self::RollBack)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct MetricSpec {
#[serde(default)]
pub class: MetricClass,
#[serde(default)]
pub decision: MetricDecision,
#[serde(default)]
pub owner: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retire_after_idle_runs: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "snake_case",
rename_all_fields = "camelCase",
deny_unknown_fields
)]
pub enum TypedAssertion {
OutputContains {
turn: usize,
values: Vec<String>,
#[serde(default)]
any: bool,
},
WorkspaceFile {
path: String,
#[serde(default)]
contains: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max_bytes: Option<usize>,
},
CommandExit {
name: String,
expected_exit_code: i32,
},
ArtifactExists {
artifact_kind: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "snake_case",
rename_all_fields = "camelCase",
deny_unknown_fields
)]
pub enum TraceMetricSpec {
EventCount {
event_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
min: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max: Option<u64>,
},
ToolCallCount {
#[serde(default, skip_serializing_if = "Option::is_none")]
tool: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
min: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max: Option<u64>,
},
ToolErrorRate {
#[serde(default, skip_serializing_if = "Option::is_none")]
max: Option<f64>,
},
TurnCount {
#[serde(default, skip_serializing_if = "Option::is_none")]
min: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max: Option<u64>,
},
LatencyMs {
#[serde(default, skip_serializing_if = "Option::is_none")]
max: Option<u64>,
},
TokenCostMicros {
#[serde(default, skip_serializing_if = "Option::is_none")]
max: Option<u64>,
},
RetryCount {
#[serde(default, skip_serializing_if = "Option::is_none")]
max: Option<u64>,
},
LoopDetected { max_repeats: u64 },
EventSequence { event_types: Vec<String> },
}
impl TraceMetricSpec {
pub fn metric_id(&self) -> String {
match self {
Self::EventCount { event_type, .. } => format!("event_count:{event_type}"),
Self::ToolCallCount { tool, .. } => match tool {
Some(tool) => format!("tool_call_count:{tool}"),
None => "tool_call_count".to_string(),
},
Self::ToolErrorRate { .. } => "tool_error_rate".to_string(),
Self::TurnCount { .. } => "turn_count".to_string(),
Self::LatencyMs { .. } => "latency_ms".to_string(),
Self::TokenCostMicros { .. } => "token_cost_micros".to_string(),
Self::RetryCount { .. } => "retry_count".to_string(),
Self::LoopDetected { .. } => "loop_detected".to_string(),
Self::EventSequence { .. } => "event_sequence".to_string(),
}
}
pub const fn requires_runtime_trace(&self) -> bool {
matches!(
self,
Self::ToolCallCount { .. }
| Self::ToolErrorRate { .. }
| Self::TokenCostMicros { .. }
| Self::RetryCount { .. }
| Self::LoopDetected { .. }
| Self::EventSequence { .. }
| Self::EventCount { .. }
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum JudgeKind {
Rubric,
Pairwise,
Reference,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum JudgeAggregation {
Median,
WeightedMean,
Majority,
Unanimous,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct JudgeModel {
pub model_ref: String,
#[serde(default = "default_weight")]
pub weight: f64,
}
fn default_weight() -> f64 {
1.0
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RubricCriterion {
pub id: String,
pub weight: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RubricScale {
pub min: f64,
pub max: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Rubric {
pub criteria: Vec<RubricCriterion>,
pub scale: RubricScale,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct JudgeInputs {
#[serde(default)]
pub output: bool,
#[serde(default)]
pub trace_summary: bool,
#[serde(default)]
pub workspace_files: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct LlmJudgeSpec {
pub judge: JudgeKind,
pub judges: Vec<JudgeModel>,
pub aggregation: JudgeAggregation,
#[serde(default = "default_min_agreement")]
pub min_agreement: f64,
pub prompt_digest: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rubric: Option<Rubric>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pass_threshold: Option<f64>,
#[serde(default = "default_samples")]
pub samples: u32,
#[serde(default)]
pub temperature: f64,
#[serde(default)]
pub position_swap: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reference_ref: Option<String>,
#[serde(default)]
pub max_cost_micros: u64,
#[serde(default)]
pub inputs: JudgeInputs,
}
fn default_min_agreement() -> f64 {
0.66
}
fn default_samples() -> u32 {
1
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum EvaluatorImplementation {
Assertions { assertions: Vec<TypedAssertion> },
TraceMetrics { metrics: Vec<TraceMetricSpec> },
LlmJudge(LlmJudgeSpec),
Plugin {
plugin_ref: String,
#[serde(default)]
config: Value,
},
}
impl EvaluatorImplementation {
pub const fn is_asynchronous(&self) -> bool {
matches!(self, Self::LlmJudge(_) | Self::Plugin { .. })
}
pub const fn kind_str(&self) -> &'static str {
match self {
Self::Assertions { .. } => "assertions",
Self::TraceMetrics { .. } => "trace_metrics",
Self::LlmJudge(_) => "llm_judge",
Self::Plugin { .. } => "plugin",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CaseEvaluator {
pub evaluator_id: String,
#[serde(default)]
pub digest: String,
#[serde(default)]
pub metric: MetricSpec,
pub implementation: EvaluatorImplementation,
}
pub const JUDGE_PROMOTION_MIN_LABELS: u64 = 50;
pub const JUDGE_PROMOTION_MIN_AGREEMENT: f64 = 0.9;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct JudgeAllowlistEntry {
pub model_ref: String,
#[serde(default)]
pub gate_approved: bool,
#[serde(default)]
pub labeled_samples: u64,
#[serde(default)]
pub human_agreement: f64,
#[serde(default)]
pub cost_tier: String,
#[serde(default)]
pub notes: String,
pub updated_at_ms: i64,
}
#[cfg(test)]
mod tests {
use super::{MetricClass, TraceMetricSpec, TypedAssertion};
use serde_json::json;
#[test]
fn assertions_use_snake_case_tags_and_camel_case_fields() {
let value = serde_json::to_value(TypedAssertion::OutputContains {
turn: 0,
values: vec!["ok".into()],
any: false,
})
.unwrap();
assert_eq!(
value,
json!({ "kind": "output_contains", "turn": 0, "values": ["ok"], "any": false })
);
}
#[test]
fn trace_metric_ids_are_stable_and_distinguish_per_tool_slices() {
let all = TraceMetricSpec::ToolCallCount {
tool: None,
min: None,
max: None,
};
let one = TraceMetricSpec::ToolCallCount {
tool: Some("bash".into()),
min: None,
max: None,
};
assert_eq!(all.metric_id(), "tool_call_count");
assert_eq!(one.metric_id(), "tool_call_count:bash");
}
#[test]
fn diagnostic_is_the_default_class_so_new_signals_cannot_gate_by_accident() {
assert_eq!(MetricClass::default(), MetricClass::Diagnostic);
assert!(!MetricClass::Diagnostic.affects_case_verdict());
}
}