Skip to main content

loopsmith_core/config/
evolution.rs

1//! How the loop is allowed to improve itself.
2//!
3//! loopsmith could already trial sub-agents and write proposals. What it could
4//! not do was say whether a proposal was an *improvement*, because there was
5//! nothing to compare against. A loop that measures a change only against its
6//! own most recent run will ratchet toward whatever it happened to do last.
7//!
8//! The baseline fixes that: it is a frozen set of numbers a proposal must beat,
9//! and it is a protected component, so the loop cannot move the goalposts it is
10//! being measured against. Everything here is off unless
11//! `features.self_evolution` is on.
12
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15
16use super::yes;
17
18/// The frozen numbers a proposal is measured against.
19///
20/// Every field is optional: a loop that only cares about cost sets only cost.
21/// A metric left unset is not compared, which is different from being compared
22/// against zero.
23#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
24#[serde(deny_unknown_fields)]
25pub struct Baseline {
26    /// Fraction of runs that reached overall success.
27    #[serde(default)]
28    pub completion_rate: Option<f64>,
29    /// Fraction of blocking validations that passed.
30    #[serde(default)]
31    pub validation_pass_rate: Option<f64>,
32    /// Mean cost of a successful run.
33    #[serde(default)]
34    pub cost_usd: Option<f64>,
35    /// Mean wall-clock of a successful run.
36    #[serde(default)]
37    pub latency_seconds: Option<f64>,
38    /// Mean iterations to reach overall success.
39    #[serde(default)]
40    pub iterations_to_success: Option<f64>,
41    /// When these numbers were measured, as an ISO-8601 date. Recorded rather
42    /// than enforced: a baseline nobody can date is a baseline nobody can
43    /// argue with.
44    #[serde(default)]
45    pub measured_at: Option<String>,
46}
47
48/// What kind of change a proposal is asking for.
49///
50/// The list is closed, and deliberately does not include anything under
51/// `safety`. A proposal that wants to relax a limit is not a proposal; it is a
52/// request for a human to edit the config.
53#[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/// The evolution policy.
66#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
67#[serde(deny_unknown_fields)]
68pub struct Evolution {
69    /// Master switch for this section. Even with `features.self_evolution` on,
70    /// a loop can leave evolution off for a single run.
71    #[serde(default)]
72    pub enabled: bool,
73    /// What proposals are measured against. Without one, no proposal can be
74    /// adopted — only recorded.
75    #[serde(default)]
76    pub baseline: Option<Baseline>,
77    /// How much a metric may worsen and still count as an improvement overall.
78    /// Expressed as a fraction: `0.02` allows a two-percent regression.
79    ///
80    /// Nonzero on purpose. A change that trades a hair of accuracy for half the
81    /// cost is usually right, and a zero-tolerance gate refuses every such
82    /// trade while admitting any change that touches nothing measured.
83    #[serde(default = "default_max_regression")]
84    pub max_regression: f64,
85    /// Which kinds of change may be proposed at all.
86    #[serde(default = "default_kinds")]
87    pub allowed_kinds: Vec<ProposalKind>,
88    /// Require a proposal to have been trialled in isolation before adoption.
89    #[serde(default = "yes")]
90    pub require_sandbox: bool,
91    /// Require a human to approve adoption. Turning this off is refused in
92    /// `prod`.
93    #[serde(default = "yes")]
94    pub require_approval: bool,
95    /// Keep the previous known-good configuration so an adoption can be undone.
96    #[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    /// Whether a proposal of this kind may even be written.
125    pub fn allows(&self, kind: ProposalKind) -> bool {
126        self.enabled && self.allowed_kinds.contains(&kind)
127    }
128
129    /// Whether `measured` beats `baseline` on every metric the baseline names,
130    /// within [`Evolution::max_regression`].
131    ///
132    /// Returns `None` when there is no baseline — which is not "pass", and the
133    /// caller must not treat it as one.
134    pub fn is_improvement(&self, measured: &Baseline) -> Option<bool> {
135        self.regressions(measured).map(|r| r.is_empty())
136    }
137
138    /// Every metric on which `measured` is worse than the baseline by more
139    /// than the tolerance, each as a one-line account. Empty means no
140    /// regression; `None` means there is no baseline to regress against.
141    pub fn regressions(&self, measured: &Baseline) -> Option<Vec<String>> {
142        let base = self.baseline.as_ref()?;
143        let tol = self.max_regression;
144
145        // Higher is better.
146        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        // Lower is better.
155        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        // The dangerous failure would be treating "nothing to compare against"
207        // as "compared, and fine".
208        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        // 0.792 is 1% below 0.80, inside the 2% default.
227        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        // The enum is the enforcement: there is no variant that names a limit,
247        // a gate, or a permission, so no proposal can ask to change one.
248        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}