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