oris-governor 0.2.0

Promotion, cooldown, and revocation policies for Oris EvoKernel.
Documentation
//! Policy-only governor contracts for Oris EvoKernel.

use serde::{Deserialize, Serialize};

use oris_evolution::{AssetState, BlastRadius, CandidateSource};

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GovernorConfig {
    pub promote_after_successes: u64,
    pub max_files_changed: usize,
    pub max_lines_changed: usize,
    pub cooldown_secs: u64,
    pub retry_cooldown_secs: u64,
    pub revoke_after_replay_failures: u64,
    pub max_mutations_per_window: u64,
    pub mutation_window_secs: u64,
    pub confidence_decay_rate_per_hour: f32,
    pub max_confidence_drop: f32,
}

impl Default for GovernorConfig {
    fn default() -> Self {
        Self {
            promote_after_successes: 3,
            max_files_changed: 5,
            max_lines_changed: 300,
            cooldown_secs: 30 * 60,
            retry_cooldown_secs: 0,
            revoke_after_replay_failures: 2,
            max_mutations_per_window: 100,
            mutation_window_secs: 60 * 60,
            confidence_decay_rate_per_hour: 0.05,
            max_confidence_drop: 0.35,
        }
    }
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CoolingWindow {
    pub cooldown_secs: u64,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum RevocationReason {
    ReplayRegression,
    ValidationFailure,
    Manual(String),
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GovernorInput {
    pub candidate_source: CandidateSource,
    pub success_count: u64,
    pub blast_radius: BlastRadius,
    pub replay_failures: u64,
    pub recent_mutation_ages_secs: Vec<u64>,
    pub current_confidence: f32,
    pub historical_peak_confidence: f32,
    pub confidence_last_updated_secs: Option<u64>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GovernorDecision {
    pub target_state: AssetState,
    pub reason: String,
    pub cooling_window: Option<CoolingWindow>,
    pub revocation_reason: Option<RevocationReason>,
}

pub trait Governor: Send + Sync {
    fn evaluate(&self, input: GovernorInput) -> GovernorDecision;
}

#[derive(Clone, Debug, Default)]
pub struct DefaultGovernor {
    config: GovernorConfig,
}

impl DefaultGovernor {
    pub fn new(config: GovernorConfig) -> Self {
        Self { config }
    }

    fn cooling_window_for(&self, cooldown_secs: u64) -> Option<CoolingWindow> {
        if cooldown_secs == 0 {
            None
        } else {
            Some(CoolingWindow { cooldown_secs })
        }
    }

    fn rate_limit_cooldown(&self, input: &GovernorInput) -> Option<u64> {
        if self.config.max_mutations_per_window == 0 || self.config.mutation_window_secs == 0 {
            return None;
        }

        let in_window = input
            .recent_mutation_ages_secs
            .iter()
            .copied()
            .filter(|age| *age < self.config.mutation_window_secs)
            .collect::<Vec<_>>();
        if in_window.len() as u64 >= self.config.max_mutations_per_window {
            let oldest_in_window = in_window.into_iter().max().unwrap_or(0);
            Some(
                self.config
                    .mutation_window_secs
                    .saturating_sub(oldest_in_window),
            )
        } else {
            None
        }
    }

    fn cooling_remaining(&self, input: &GovernorInput) -> Option<u64> {
        if self.config.retry_cooldown_secs == 0 {
            return None;
        }

        let most_recent = input.recent_mutation_ages_secs.iter().copied().min()?;
        if most_recent < self.config.retry_cooldown_secs {
            Some(self.config.retry_cooldown_secs.saturating_sub(most_recent))
        } else {
            None
        }
    }

    fn decayed_confidence(&self, input: &GovernorInput) -> f32 {
        if self.config.confidence_decay_rate_per_hour <= 0.0 {
            return input.current_confidence;
        }

        let age_hours = input.confidence_last_updated_secs.unwrap_or(0) as f32 / 3600.0;
        let decay = (-self.config.confidence_decay_rate_per_hour * age_hours).exp();
        input.current_confidence * decay
    }
}

impl Governor for DefaultGovernor {
    fn evaluate(&self, input: GovernorInput) -> GovernorDecision {
        if input.replay_failures >= self.config.revoke_after_replay_failures {
            return GovernorDecision {
                target_state: AssetState::Revoked,
                reason: "replay validation failures exceeded threshold".into(),
                cooling_window: self.cooling_window_for(self.config.retry_cooldown_secs),
                revocation_reason: Some(RevocationReason::ReplayRegression),
            };
        }

        let decayed_confidence = self.decayed_confidence(&input);
        if self.config.max_confidence_drop > 0.0
            && input.historical_peak_confidence > 0.0
            && (input.historical_peak_confidence - decayed_confidence)
                >= self.config.max_confidence_drop
        {
            return GovernorDecision {
                target_state: AssetState::Revoked,
                reason: "confidence regression exceeded threshold".into(),
                cooling_window: self.cooling_window_for(self.config.retry_cooldown_secs),
                revocation_reason: Some(RevocationReason::ReplayRegression),
            };
        }

        if let Some(cooldown_secs) = self.rate_limit_cooldown(&input) {
            return GovernorDecision {
                target_state: AssetState::Candidate,
                reason: "mutation rate limit exceeded".into(),
                cooling_window: self.cooling_window_for(cooldown_secs),
                revocation_reason: None,
            };
        }

        if let Some(cooldown_secs) = self.cooling_remaining(&input) {
            return GovernorDecision {
                target_state: AssetState::Candidate,
                reason: "cooling window active after recent mutation".into(),
                cooling_window: self.cooling_window_for(cooldown_secs),
                revocation_reason: None,
            };
        }

        if input.blast_radius.files_changed > self.config.max_files_changed
            || input.blast_radius.lines_changed > self.config.max_lines_changed
        {
            return GovernorDecision {
                target_state: AssetState::Candidate,
                reason: "blast radius exceeds promotion threshold".into(),
                cooling_window: self.cooling_window_for(self.config.retry_cooldown_secs),
                revocation_reason: None,
            };
        }

        if input.success_count >= self.config.promote_after_successes {
            return GovernorDecision {
                target_state: AssetState::Promoted,
                reason: "success threshold reached".into(),
                cooling_window: Some(CoolingWindow {
                    cooldown_secs: self.config.cooldown_secs,
                }),
                revocation_reason: None,
            };
        }

        GovernorDecision {
            target_state: AssetState::Candidate,
            reason: "collecting more successful executions".into(),
            cooling_window: None,
            revocation_reason: None,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn create_test_input(
        success_count: u64,
        files_changed: usize,
        lines_changed: usize,
        replay_failures: u64,
    ) -> GovernorInput {
        GovernorInput {
            candidate_source: CandidateSource::Local,
            success_count,
            blast_radius: BlastRadius {
                files_changed,
                lines_changed,
            },
            replay_failures,
            recent_mutation_ages_secs: Vec::new(),
            current_confidence: 0.7,
            historical_peak_confidence: 0.7,
            confidence_last_updated_secs: Some(0),
        }
    }

    #[test]
    fn test_promote_after_successes_threshold() {
        let governor = DefaultGovernor::new(GovernorConfig::default());
        // Should promote after 3 successes
        let result = governor.evaluate(create_test_input(3, 1, 100, 0));
        assert_eq!(result.target_state, AssetState::Promoted);
    }

    #[test]
    fn test_revoke_after_replay_failures() {
        let governor = DefaultGovernor::new(GovernorConfig {
            retry_cooldown_secs: 45,
            ..Default::default()
        });
        // Should revoke after 2 replay failures
        let result = governor.evaluate(create_test_input(5, 1, 100, 2));
        assert_eq!(result.target_state, AssetState::Revoked);
        assert!(matches!(
            result.revocation_reason,
            Some(RevocationReason::ReplayRegression)
        ));
        assert_eq!(result.cooling_window.unwrap().cooldown_secs, 45);
    }

    #[test]
    fn test_blast_radius_exceeds_threshold() {
        let governor = DefaultGovernor::new(GovernorConfig {
            retry_cooldown_secs: 90,
            ..Default::default()
        });
        // Blast radius exceeds max_files_changed
        let result = governor.evaluate(create_test_input(5, 10, 100, 0));
        assert_eq!(result.target_state, AssetState::Candidate);
        assert!(result.reason.contains("blast radius"));
        assert_eq!(result.cooling_window.unwrap().cooldown_secs, 90);
    }

    #[test]
    fn test_cooling_window_applied_on_promotion() {
        let governor = DefaultGovernor::new(GovernorConfig::default());
        let result = governor.evaluate(create_test_input(3, 1, 100, 0));
        assert!(result.cooling_window.is_some());
        assert_eq!(result.cooling_window.unwrap().cooldown_secs, 30 * 60);
    }

    #[test]
    fn test_default_config_values() {
        let config = GovernorConfig::default();
        assert_eq!(config.promote_after_successes, 3);
        assert_eq!(config.max_files_changed, 5);
        assert_eq!(config.max_lines_changed, 300);
        assert_eq!(config.cooldown_secs, 30 * 60);
        assert_eq!(config.retry_cooldown_secs, 0);
        assert_eq!(config.revoke_after_replay_failures, 2);
        assert_eq!(config.max_mutations_per_window, 100);
        assert_eq!(config.mutation_window_secs, 60 * 60);
        assert_eq!(config.confidence_decay_rate_per_hour, 0.05);
        assert_eq!(config.max_confidence_drop, 0.35);
    }

    #[test]
    fn test_rate_limit_blocks_when_window_is_full() {
        let governor = DefaultGovernor::new(GovernorConfig {
            max_mutations_per_window: 2,
            mutation_window_secs: 60,
            cooldown_secs: 0,
            ..Default::default()
        });
        let mut input = create_test_input(3, 1, 100, 0);
        input.recent_mutation_ages_secs = vec![5, 30];

        let result = governor.evaluate(input);

        assert_eq!(result.target_state, AssetState::Candidate);
        assert!(result.reason.contains("rate limit"));
        assert_eq!(result.cooling_window.unwrap().cooldown_secs, 30);
    }

    #[test]
    fn test_cooling_window_blocks_rapid_retry() {
        let governor = DefaultGovernor::new(GovernorConfig {
            retry_cooldown_secs: 60,
            ..Default::default()
        });
        let mut input = create_test_input(3, 1, 100, 0);
        input.recent_mutation_ages_secs = vec![15];

        let result = governor.evaluate(input);

        assert_eq!(result.target_state, AssetState::Candidate);
        assert!(result.reason.contains("cooling"));
        assert_eq!(result.cooling_window.unwrap().cooldown_secs, 45);
    }

    #[test]
    fn test_confidence_decay_triggers_regression_revocation() {
        let governor = DefaultGovernor::new(GovernorConfig {
            confidence_decay_rate_per_hour: 1.0,
            max_confidence_drop: 0.2,
            retry_cooldown_secs: 30,
            ..Default::default()
        });
        let mut input = create_test_input(1, 1, 100, 0);
        input.current_confidence = 0.9;
        input.historical_peak_confidence = 0.9;
        input.confidence_last_updated_secs = Some(60 * 60);

        let result = governor.evaluate(input);

        assert_eq!(result.target_state, AssetState::Revoked);
        assert!(result.reason.contains("confidence regression"));
        assert!(matches!(
            result.revocation_reason,
            Some(RevocationReason::ReplayRegression)
        ));
        assert_eq!(result.cooling_window.unwrap().cooldown_secs, 30);
    }
}