ricecoder_workflows/
risk_scoring.rs

1//! Risk scoring and assessment for workflow steps
2
3use crate::models::{
4    ApprovalDecisionRecord, RiskAssessment, RiskAssessmentReport, WorkflowState, WorkflowStep,
5};
6use chrono::Utc;
7
8/// Risk score calculator for workflow steps
9#[derive(Debug, Clone)]
10pub struct RiskScorer {
11    /// Threshold above which approval is required (0-100)
12    pub approval_threshold: u8,
13    /// Maximum execution timeout for high-risk operations (milliseconds)
14    pub max_timeout_ms: u64,
15}
16
17impl RiskScorer {
18    /// Create a new risk scorer with default settings
19    pub fn new() -> Self {
20        Self {
21            approval_threshold: 70,
22            max_timeout_ms: 300_000, // 5 minutes
23        }
24    }
25
26    /// Create a new risk scorer with custom settings
27    pub fn with_threshold(approval_threshold: u8) -> Self {
28        Self {
29            approval_threshold,
30            max_timeout_ms: 300_000,
31        }
32    }
33
34    /// Calculate risk score for a workflow step
35    ///
36    /// The risk score is calculated based on three factors:
37    /// - Impact (0-100): potential for data loss or system damage
38    /// - Reversibility (0-100): ability to undo the operation (inverted: lower is riskier)
39    /// - Complexity (0-100): number of dependencies and interactions
40    ///
41    /// Formula: (impact + (100 - reversibility) + complexity) / 3
42    pub fn calculate_risk_score(&self, step: &WorkflowStep) -> u8 {
43        let factors = &step.risk_factors;
44
45        // Validate inputs are in range [0, 100]
46        let impact = factors.impact.min(100);
47        let reversibility = factors.reversibility.min(100);
48        let complexity = factors.complexity.min(100);
49
50        // Calculate weighted score
51        // Impact: 40% weight
52        // Reversibility: 40% weight (inverted: lower reversibility = higher risk)
53        // Complexity: 20% weight
54        let impact_contribution = (impact as f32) * 0.4;
55        let reversibility_contribution = ((100 - reversibility as u16) as f32) * 0.4;
56        let complexity_contribution = (complexity as f32) * 0.2;
57
58        let score =
59            (impact_contribution + reversibility_contribution + complexity_contribution) as u8;
60        score.min(100)
61    }
62
63    /// Check if a step requires approval based on risk score
64    pub fn requires_approval(&self, risk_score: u8) -> bool {
65        risk_score > self.approval_threshold
66    }
67
68    /// Generate a risk assessment for a step
69    pub fn assess_step(&self, step: &WorkflowStep) -> RiskAssessment {
70        let risk_score = self.calculate_risk_score(step);
71        let approval_required = self.requires_approval(risk_score);
72
73        RiskAssessment {
74            step_id: step.id.clone(),
75            step_name: step.name.clone(),
76            risk_score,
77            risk_factors: step.risk_factors.clone(),
78            approval_required,
79            approval_decision: None,
80        }
81    }
82
83    /// Generate a risk assessment report for a completed workflow
84    pub fn generate_report(
85        &self,
86        workflow_id: &str,
87        workflow_name: &str,
88        steps: &[WorkflowStep],
89        state: &WorkflowState,
90    ) -> RiskAssessmentReport {
91        let mut step_assessments = Vec::new();
92        let mut total_score = 0u32;
93
94        for step in steps {
95            let mut assessment = self.assess_step(step);
96
97            // Check if step was executed and has approval decision
98            if state.step_results.contains_key(&step.id) {
99                // In a real implementation, we would look up the actual approval decision
100                // For now, we mark it as approved if the step completed
101                if let Some(result) = state.step_results.get(&step.id) {
102                    if result.status == crate::models::StepStatus::Completed
103                        && assessment.approval_required
104                    {
105                        assessment.approval_decision = Some(ApprovalDecisionRecord {
106                            approved: true,
107                            timestamp: Utc::now(),
108                            approver: None,
109                            comments: None,
110                        });
111                    }
112                }
113            }
114
115            total_score += assessment.risk_score as u32;
116            step_assessments.push(assessment);
117        }
118
119        let overall_risk_score = if step_assessments.is_empty() {
120            0
121        } else {
122            (total_score / step_assessments.len() as u32) as u8
123        };
124
125        RiskAssessmentReport {
126            workflow_id: workflow_id.to_string(),
127            workflow_name: workflow_name.to_string(),
128            overall_risk_score,
129            step_assessments,
130            safety_violations: Vec::new(),
131            generated_at: Utc::now(),
132        }
133    }
134}
135
136impl Default for RiskScorer {
137    fn default() -> Self {
138        Self::new()
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::models::{AgentStep, ErrorAction, RiskFactors, StepConfig, StepType};
146
147    fn create_test_step(id: &str, impact: u8, reversibility: u8, complexity: u8) -> WorkflowStep {
148        WorkflowStep {
149            id: id.to_string(),
150            name: format!("Step {}", id),
151            step_type: StepType::Agent(AgentStep {
152                agent_id: "test-agent".to_string(),
153                task: "test-task".to_string(),
154            }),
155            config: StepConfig {
156                config: serde_json::json!({}),
157            },
158            dependencies: Vec::new(),
159            approval_required: false,
160            on_error: ErrorAction::Fail,
161            risk_score: None,
162            risk_factors: RiskFactors {
163                impact,
164                reversibility,
165                complexity,
166            },
167        }
168    }
169
170    #[test]
171    fn test_risk_score_calculation_low_risk() {
172        let scorer = RiskScorer::new();
173        let step = create_test_step("1", 10, 90, 10);
174        let score = scorer.calculate_risk_score(&step);
175        assert!(
176            score < 30,
177            "Low risk step should have score < 30, got {}",
178            score
179        );
180    }
181
182    #[test]
183    fn test_risk_score_calculation_high_risk() {
184        let scorer = RiskScorer::new();
185        let step = create_test_step("1", 90, 10, 90);
186        let score = scorer.calculate_risk_score(&step);
187        assert!(
188            score > 70,
189            "High risk step should have score > 70, got {}",
190            score
191        );
192    }
193
194    #[test]
195    fn test_risk_score_in_range() {
196        let scorer = RiskScorer::new();
197        for impact in [0, 25, 50, 75, 100] {
198            for reversibility in [0, 25, 50, 75, 100] {
199                for complexity in [0, 25, 50, 75, 100] {
200                    let step = create_test_step("1", impact, reversibility, complexity);
201                    let score = scorer.calculate_risk_score(&step);
202                    assert!(score <= 100, "Risk score should be <= 100, got {}", score);
203                }
204            }
205        }
206    }
207
208    #[test]
209    fn test_approval_threshold() {
210        let scorer = RiskScorer::with_threshold(70);
211        assert!(!scorer.requires_approval(69));
212        assert!(!scorer.requires_approval(70)); // 70 is not > 70, so no approval required
213        assert!(scorer.requires_approval(71)); // 71 > 70, so approval required
214    }
215
216    #[test]
217    fn test_risk_assessment() {
218        let scorer = RiskScorer::new();
219        let step = create_test_step("1", 80, 20, 80);
220        let assessment = scorer.assess_step(&step);
221        assert_eq!(assessment.step_id, "1");
222        assert!(assessment.approval_required);
223        assert!(assessment.risk_score > 70);
224    }
225}