Skip to main content

oris_governor/
lib.rs

1//! Policy-only governor contracts for Oris EvoKernel.
2
3use serde::{Deserialize, Serialize};
4
5use oris_evolution::{AssetState, BlastRadius, CandidateSource};
6
7#[derive(Clone, Debug, Serialize, Deserialize)]
8pub struct GovernorConfig {
9    pub promote_after_successes: u64,
10    pub max_files_changed: usize,
11    pub max_lines_changed: usize,
12    pub cooldown_secs: u64,
13    pub retry_cooldown_secs: u64,
14    pub revoke_after_replay_failures: u64,
15    pub max_mutations_per_window: u64,
16    pub mutation_window_secs: u64,
17    pub confidence_decay_rate_per_hour: f32,
18    pub max_confidence_drop: f32,
19}
20
21impl Default for GovernorConfig {
22    fn default() -> Self {
23        Self {
24            promote_after_successes: 3,
25            max_files_changed: 5,
26            max_lines_changed: 300,
27            cooldown_secs: 30 * 60,
28            retry_cooldown_secs: 0,
29            revoke_after_replay_failures: 2,
30            max_mutations_per_window: 100,
31            mutation_window_secs: 60 * 60,
32            confidence_decay_rate_per_hour: 0.05,
33            max_confidence_drop: 0.35,
34        }
35    }
36}
37
38#[derive(Clone, Debug, Serialize, Deserialize)]
39pub struct CoolingWindow {
40    pub cooldown_secs: u64,
41}
42
43#[derive(Clone, Debug, Serialize, Deserialize)]
44pub enum RevocationReason {
45    ReplayRegression,
46    ValidationFailure,
47    Manual(String),
48}
49
50#[derive(Clone, Debug, Serialize, Deserialize)]
51pub struct GovernorInput {
52    pub candidate_source: CandidateSource,
53    pub success_count: u64,
54    pub blast_radius: BlastRadius,
55    pub replay_failures: u64,
56    pub recent_mutation_ages_secs: Vec<u64>,
57    pub current_confidence: f32,
58    pub historical_peak_confidence: f32,
59    pub confidence_last_updated_secs: Option<u64>,
60}
61
62#[derive(Clone, Debug, Serialize, Deserialize)]
63pub struct GovernorDecision {
64    pub target_state: AssetState,
65    pub reason: String,
66    pub cooling_window: Option<CoolingWindow>,
67    pub revocation_reason: Option<RevocationReason>,
68}
69
70pub trait Governor: Send + Sync {
71    fn evaluate(&self, input: GovernorInput) -> GovernorDecision;
72}
73
74#[derive(Clone, Debug, Default)]
75pub struct DefaultGovernor {
76    config: GovernorConfig,
77}
78
79impl DefaultGovernor {
80    pub fn new(config: GovernorConfig) -> Self {
81        Self { config }
82    }
83
84    fn cooling_window_for(&self, cooldown_secs: u64) -> Option<CoolingWindow> {
85        if cooldown_secs == 0 {
86            None
87        } else {
88            Some(CoolingWindow { cooldown_secs })
89        }
90    }
91
92    fn rate_limit_cooldown(&self, input: &GovernorInput) -> Option<u64> {
93        if self.config.max_mutations_per_window == 0 || self.config.mutation_window_secs == 0 {
94            return None;
95        }
96
97        let in_window = input
98            .recent_mutation_ages_secs
99            .iter()
100            .copied()
101            .filter(|age| *age < self.config.mutation_window_secs)
102            .collect::<Vec<_>>();
103        if in_window.len() as u64 >= self.config.max_mutations_per_window {
104            let oldest_in_window = in_window.into_iter().max().unwrap_or(0);
105            Some(
106                self.config
107                    .mutation_window_secs
108                    .saturating_sub(oldest_in_window),
109            )
110        } else {
111            None
112        }
113    }
114
115    fn cooling_remaining(&self, input: &GovernorInput) -> Option<u64> {
116        if self.config.retry_cooldown_secs == 0 {
117            return None;
118        }
119
120        let most_recent = input.recent_mutation_ages_secs.iter().copied().min()?;
121        if most_recent < self.config.retry_cooldown_secs {
122            Some(self.config.retry_cooldown_secs.saturating_sub(most_recent))
123        } else {
124            None
125        }
126    }
127
128    fn decayed_confidence(&self, input: &GovernorInput) -> f32 {
129        if self.config.confidence_decay_rate_per_hour <= 0.0 {
130            return input.current_confidence;
131        }
132
133        let age_hours = input.confidence_last_updated_secs.unwrap_or(0) as f32 / 3600.0;
134        let decay = (-self.config.confidence_decay_rate_per_hour * age_hours).exp();
135        input.current_confidence * decay
136    }
137}
138
139impl Governor for DefaultGovernor {
140    fn evaluate(&self, input: GovernorInput) -> GovernorDecision {
141        if input.replay_failures >= self.config.revoke_after_replay_failures {
142            return GovernorDecision {
143                target_state: AssetState::Revoked,
144                reason: "replay validation failures exceeded threshold".into(),
145                cooling_window: self.cooling_window_for(self.config.retry_cooldown_secs),
146                revocation_reason: Some(RevocationReason::ReplayRegression),
147            };
148        }
149
150        let decayed_confidence = self.decayed_confidence(&input);
151        if self.config.max_confidence_drop > 0.0
152            && input.historical_peak_confidence > 0.0
153            && (input.historical_peak_confidence - decayed_confidence)
154                >= self.config.max_confidence_drop
155        {
156            return GovernorDecision {
157                target_state: AssetState::Revoked,
158                reason: "confidence regression exceeded threshold".into(),
159                cooling_window: self.cooling_window_for(self.config.retry_cooldown_secs),
160                revocation_reason: Some(RevocationReason::ReplayRegression),
161            };
162        }
163
164        if let Some(cooldown_secs) = self.rate_limit_cooldown(&input) {
165            return GovernorDecision {
166                target_state: AssetState::Candidate,
167                reason: "mutation rate limit exceeded".into(),
168                cooling_window: self.cooling_window_for(cooldown_secs),
169                revocation_reason: None,
170            };
171        }
172
173        if let Some(cooldown_secs) = self.cooling_remaining(&input) {
174            return GovernorDecision {
175                target_state: AssetState::Candidate,
176                reason: "cooling window active after recent mutation".into(),
177                cooling_window: self.cooling_window_for(cooldown_secs),
178                revocation_reason: None,
179            };
180        }
181
182        if input.blast_radius.files_changed > self.config.max_files_changed
183            || input.blast_radius.lines_changed > self.config.max_lines_changed
184        {
185            return GovernorDecision {
186                target_state: AssetState::Candidate,
187                reason: "blast radius exceeds promotion threshold".into(),
188                cooling_window: self.cooling_window_for(self.config.retry_cooldown_secs),
189                revocation_reason: None,
190            };
191        }
192
193        if input.success_count >= self.config.promote_after_successes {
194            return GovernorDecision {
195                target_state: AssetState::Promoted,
196                reason: "success threshold reached".into(),
197                cooling_window: Some(CoolingWindow {
198                    cooldown_secs: self.config.cooldown_secs,
199                }),
200                revocation_reason: None,
201            };
202        }
203
204        GovernorDecision {
205            target_state: AssetState::Candidate,
206            reason: "collecting more successful executions".into(),
207            cooling_window: None,
208            revocation_reason: None,
209        }
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    fn create_test_input(
218        success_count: u64,
219        files_changed: usize,
220        lines_changed: usize,
221        replay_failures: u64,
222    ) -> GovernorInput {
223        GovernorInput {
224            candidate_source: CandidateSource::Local,
225            success_count,
226            blast_radius: BlastRadius {
227                files_changed,
228                lines_changed,
229            },
230            replay_failures,
231            recent_mutation_ages_secs: Vec::new(),
232            current_confidence: 0.7,
233            historical_peak_confidence: 0.7,
234            confidence_last_updated_secs: Some(0),
235        }
236    }
237
238    #[test]
239    fn test_promote_after_successes_threshold() {
240        let governor = DefaultGovernor::new(GovernorConfig::default());
241        // Should promote after 3 successes
242        let result = governor.evaluate(create_test_input(3, 1, 100, 0));
243        assert_eq!(result.target_state, AssetState::Promoted);
244    }
245
246    #[test]
247    fn test_revoke_after_replay_failures() {
248        let governor = DefaultGovernor::new(GovernorConfig {
249            retry_cooldown_secs: 45,
250            ..Default::default()
251        });
252        // Should revoke after 2 replay failures
253        let result = governor.evaluate(create_test_input(5, 1, 100, 2));
254        assert_eq!(result.target_state, AssetState::Revoked);
255        assert!(matches!(
256            result.revocation_reason,
257            Some(RevocationReason::ReplayRegression)
258        ));
259        assert_eq!(result.cooling_window.unwrap().cooldown_secs, 45);
260    }
261
262    #[test]
263    fn test_blast_radius_exceeds_threshold() {
264        let governor = DefaultGovernor::new(GovernorConfig {
265            retry_cooldown_secs: 90,
266            ..Default::default()
267        });
268        // Blast radius exceeds max_files_changed
269        let result = governor.evaluate(create_test_input(5, 10, 100, 0));
270        assert_eq!(result.target_state, AssetState::Candidate);
271        assert!(result.reason.contains("blast radius"));
272        assert_eq!(result.cooling_window.unwrap().cooldown_secs, 90);
273    }
274
275    #[test]
276    fn test_cooling_window_applied_on_promotion() {
277        let governor = DefaultGovernor::new(GovernorConfig::default());
278        let result = governor.evaluate(create_test_input(3, 1, 100, 0));
279        assert!(result.cooling_window.is_some());
280        assert_eq!(result.cooling_window.unwrap().cooldown_secs, 30 * 60);
281    }
282
283    #[test]
284    fn test_default_config_values() {
285        let config = GovernorConfig::default();
286        assert_eq!(config.promote_after_successes, 3);
287        assert_eq!(config.max_files_changed, 5);
288        assert_eq!(config.max_lines_changed, 300);
289        assert_eq!(config.cooldown_secs, 30 * 60);
290        assert_eq!(config.retry_cooldown_secs, 0);
291        assert_eq!(config.revoke_after_replay_failures, 2);
292        assert_eq!(config.max_mutations_per_window, 100);
293        assert_eq!(config.mutation_window_secs, 60 * 60);
294        assert_eq!(config.confidence_decay_rate_per_hour, 0.05);
295        assert_eq!(config.max_confidence_drop, 0.35);
296    }
297
298    #[test]
299    fn test_rate_limit_blocks_when_window_is_full() {
300        let governor = DefaultGovernor::new(GovernorConfig {
301            max_mutations_per_window: 2,
302            mutation_window_secs: 60,
303            cooldown_secs: 0,
304            ..Default::default()
305        });
306        let mut input = create_test_input(3, 1, 100, 0);
307        input.recent_mutation_ages_secs = vec![5, 30];
308
309        let result = governor.evaluate(input);
310
311        assert_eq!(result.target_state, AssetState::Candidate);
312        assert!(result.reason.contains("rate limit"));
313        assert_eq!(result.cooling_window.unwrap().cooldown_secs, 30);
314    }
315
316    #[test]
317    fn test_cooling_window_blocks_rapid_retry() {
318        let governor = DefaultGovernor::new(GovernorConfig {
319            retry_cooldown_secs: 60,
320            ..Default::default()
321        });
322        let mut input = create_test_input(3, 1, 100, 0);
323        input.recent_mutation_ages_secs = vec![15];
324
325        let result = governor.evaluate(input);
326
327        assert_eq!(result.target_state, AssetState::Candidate);
328        assert!(result.reason.contains("cooling"));
329        assert_eq!(result.cooling_window.unwrap().cooldown_secs, 45);
330    }
331
332    #[test]
333    fn test_confidence_decay_triggers_regression_revocation() {
334        let governor = DefaultGovernor::new(GovernorConfig {
335            confidence_decay_rate_per_hour: 1.0,
336            max_confidence_drop: 0.2,
337            retry_cooldown_secs: 30,
338            ..Default::default()
339        });
340        let mut input = create_test_input(1, 1, 100, 0);
341        input.current_confidence = 0.9;
342        input.historical_peak_confidence = 0.9;
343        input.confidence_last_updated_secs = Some(60 * 60);
344
345        let result = governor.evaluate(input);
346
347        assert_eq!(result.target_state, AssetState::Revoked);
348        assert!(result.reason.contains("confidence regression"));
349        assert!(matches!(
350            result.revocation_reason,
351            Some(RevocationReason::ReplayRegression)
352        ));
353        assert_eq!(result.cooling_window.unwrap().cooldown_secs, 30);
354    }
355}