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