1use 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 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 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 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}