use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::yes;
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Baseline {
#[serde(default)]
pub completion_rate: Option<f64>,
#[serde(default)]
pub validation_pass_rate: Option<f64>,
#[serde(default)]
pub cost_usd: Option<f64>,
#[serde(default)]
pub latency_seconds: Option<f64>,
#[serde(default)]
pub iterations_to_success: Option<f64>,
#[serde(default)]
pub measured_at: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ProposalKind {
NewSkill,
SkillUpdate,
PromptChange,
GraphChange,
ProviderRouting,
ValidationChange,
SuccessCriteria,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Evolution {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub baseline: Option<Baseline>,
#[serde(default = "default_max_regression")]
pub max_regression: f64,
#[serde(default = "default_kinds")]
pub allowed_kinds: Vec<ProposalKind>,
#[serde(default = "yes")]
pub require_sandbox: bool,
#[serde(default = "yes")]
pub require_approval: bool,
#[serde(default = "yes")]
pub keep_rollback: bool,
}
fn default_max_regression() -> f64 {
0.02
}
fn default_kinds() -> Vec<ProposalKind> {
use ProposalKind::*;
vec![NewSkill, SkillUpdate, PromptChange, ValidationChange]
}
impl Default for Evolution {
fn default() -> Self {
Self {
enabled: false,
baseline: None,
max_regression: default_max_regression(),
allowed_kinds: default_kinds(),
require_sandbox: true,
require_approval: true,
keep_rollback: true,
}
}
}
impl Evolution {
pub fn allows(&self, kind: ProposalKind) -> bool {
self.enabled && self.allowed_kinds.contains(&kind)
}
pub fn is_improvement(&self, measured: &Baseline) -> Option<bool> {
self.regressions(measured).map(|r| r.is_empty())
}
pub fn regressions(&self, measured: &Baseline) -> Option<Vec<String>> {
let base = self.baseline.as_ref()?;
let tol = self.max_regression;
let up = [
("completion_rate", base.completion_rate, measured.completion_rate),
(
"validation_pass_rate",
base.validation_pass_rate,
measured.validation_pass_rate,
),
];
let down = [
("cost_usd", base.cost_usd, measured.cost_usd),
("latency_seconds", base.latency_seconds, measured.latency_seconds),
(
"iterations_to_success",
base.iterations_to_success,
measured.iterations_to_success,
),
];
let mut out = Vec::new();
for (name, b, m) in up {
if let (Some(b), Some(m)) = (b, m) {
if m < b - (b.abs() * tol) {
out.push(format!("{name} fell to {m:.3} from a baseline of {b:.3}"));
}
}
}
for (name, b, m) in down {
if let (Some(b), Some(m)) = (b, m) {
if m > b + (b.abs() * tol) {
out.push(format!("{name} rose to {m:.3} from a baseline of {b:.3}"));
}
}
}
Some(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> Baseline {
Baseline {
completion_rate: Some(0.80),
cost_usd: Some(1.00),
..Baseline::default()
}
}
fn with_baseline() -> Evolution {
Evolution {
enabled: true,
baseline: Some(base()),
..Evolution::default()
}
}
#[test]
fn no_baseline_is_not_a_pass() {
let e = Evolution {
enabled: true,
..Evolution::default()
};
assert_eq!(e.is_improvement(&base()), None);
}
#[test]
fn a_real_regression_is_refused() {
let worse = Baseline {
completion_rate: Some(0.50),
..base()
};
assert_eq!(with_baseline().is_improvement(&worse), Some(false));
}
#[test]
fn a_regression_inside_tolerance_is_allowed() {
let slightly_worse = Baseline {
completion_rate: Some(0.792),
cost_usd: Some(0.50),
..base()
};
assert_eq!(with_baseline().is_improvement(&slightly_worse), Some(true));
}
#[test]
fn cost_going_up_counts_against_a_proposal() {
let pricier = Baseline {
cost_usd: Some(2.00),
..base()
};
assert_eq!(with_baseline().is_improvement(&pricier), Some(false));
}
#[test]
fn safety_sections_are_not_a_proposable_kind() {
let names = format!("{:?}", default_kinds());
for forbidden in ["Limit", "Gate", "Permission", "Protected"] {
assert!(!names.contains(forbidden), "{forbidden} must not be proposable");
}
}
}