1use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15
16use super::yes;
17
18#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
24#[serde(deny_unknown_fields)]
25pub struct Baseline {
26 #[serde(default)]
28 pub completion_rate: Option<f64>,
29 #[serde(default)]
31 pub validation_pass_rate: Option<f64>,
32 #[serde(default)]
34 pub cost_usd: Option<f64>,
35 #[serde(default)]
37 pub latency_seconds: Option<f64>,
38 #[serde(default)]
40 pub iterations_to_success: Option<f64>,
41 #[serde(default)]
45 pub measured_at: Option<String>,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
54#[serde(rename_all = "snake_case")]
55pub enum ProposalKind {
56 NewSkill,
57 SkillUpdate,
58 PromptChange,
59 GraphChange,
60 ProviderRouting,
61 ValidationChange,
62 SuccessCriteria,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
67#[serde(deny_unknown_fields)]
68pub struct Evolution {
69 #[serde(default)]
72 pub enabled: bool,
73 #[serde(default)]
76 pub baseline: Option<Baseline>,
77 #[serde(default = "default_max_regression")]
84 pub max_regression: f64,
85 #[serde(default = "default_kinds")]
87 pub allowed_kinds: Vec<ProposalKind>,
88 #[serde(default = "yes")]
90 pub require_sandbox: bool,
91 #[serde(default = "yes")]
94 pub require_approval: bool,
95 #[serde(default = "yes")]
97 pub keep_rollback: bool,
98}
99
100fn default_max_regression() -> f64 {
101 0.02
102}
103
104fn default_kinds() -> Vec<ProposalKind> {
105 use ProposalKind::*;
106 vec![NewSkill, SkillUpdate, PromptChange, ValidationChange]
107}
108
109impl Default for Evolution {
110 fn default() -> Self {
111 Self {
112 enabled: false,
113 baseline: None,
114 max_regression: default_max_regression(),
115 allowed_kinds: default_kinds(),
116 require_sandbox: true,
117 require_approval: true,
118 keep_rollback: true,
119 }
120 }
121}
122
123impl Evolution {
124 pub fn allows(&self, kind: ProposalKind) -> bool {
126 self.enabled && self.allowed_kinds.contains(&kind)
127 }
128
129 pub fn is_improvement(&self, measured: &Baseline) -> Option<bool> {
135 self.regressions(measured).map(|r| r.is_empty())
136 }
137
138 pub fn regressions(&self, measured: &Baseline) -> Option<Vec<String>> {
142 let base = self.baseline.as_ref()?;
143 let tol = self.max_regression;
144
145 let up = [
147 ("completion_rate", base.completion_rate, measured.completion_rate),
148 (
149 "validation_pass_rate",
150 base.validation_pass_rate,
151 measured.validation_pass_rate,
152 ),
153 ];
154 let down = [
156 ("cost_usd", base.cost_usd, measured.cost_usd),
157 ("latency_seconds", base.latency_seconds, measured.latency_seconds),
158 (
159 "iterations_to_success",
160 base.iterations_to_success,
161 measured.iterations_to_success,
162 ),
163 ];
164
165 let mut out = Vec::new();
166 for (name, b, m) in up {
167 if let (Some(b), Some(m)) = (b, m) {
168 if m < b - (b.abs() * tol) {
169 out.push(format!("{name} fell to {m:.3} from a baseline of {b:.3}"));
170 }
171 }
172 }
173 for (name, b, m) in down {
174 if let (Some(b), Some(m)) = (b, m) {
175 if m > b + (b.abs() * tol) {
176 out.push(format!("{name} rose to {m:.3} from a baseline of {b:.3}"));
177 }
178 }
179 }
180 Some(out)
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 fn base() -> Baseline {
189 Baseline {
190 completion_rate: Some(0.80),
191 cost_usd: Some(1.00),
192 ..Baseline::default()
193 }
194 }
195
196 fn with_baseline() -> Evolution {
197 Evolution {
198 enabled: true,
199 baseline: Some(base()),
200 ..Evolution::default()
201 }
202 }
203
204 #[test]
205 fn no_baseline_is_not_a_pass() {
206 let e = Evolution {
209 enabled: true,
210 ..Evolution::default()
211 };
212 assert_eq!(e.is_improvement(&base()), None);
213 }
214
215 #[test]
216 fn a_real_regression_is_refused() {
217 let worse = Baseline {
218 completion_rate: Some(0.50),
219 ..base()
220 };
221 assert_eq!(with_baseline().is_improvement(&worse), Some(false));
222 }
223
224 #[test]
225 fn a_regression_inside_tolerance_is_allowed() {
226 let slightly_worse = Baseline {
228 completion_rate: Some(0.792),
229 cost_usd: Some(0.50),
230 ..base()
231 };
232 assert_eq!(with_baseline().is_improvement(&slightly_worse), Some(true));
233 }
234
235 #[test]
236 fn cost_going_up_counts_against_a_proposal() {
237 let pricier = Baseline {
238 cost_usd: Some(2.00),
239 ..base()
240 };
241 assert_eq!(with_baseline().is_improvement(&pricier), Some(false));
242 }
243
244 #[test]
245 fn safety_sections_are_not_a_proposable_kind() {
246 let names = format!("{:?}", default_kinds());
249 for forbidden in ["Limit", "Gate", "Permission", "Protected"] {
250 assert!(!names.contains(forbidden), "{forbidden} must not be proposable");
251 }
252 }
253}