Skip to main content

agentforge_gatekeeper/
gatekeeper.rs

1use agentforge_core::{Result, Scorecard, Trace, TraceStatus};
2use uuid::Uuid;
3
4/// Configuration for all promotion gates.
5#[derive(Debug, Clone)]
6pub struct GatekeeperConfig {
7    /// Minimum improvement over champion aggregate score to pass (default: 0.03 = +3%)
8    pub score_gate_delta: f64,
9    /// Minimum fraction of champion-passing scenarios the challenger must also pass (default: 0.99)
10    pub regression_gate_ratio: f64,
11    /// Number of random seeds that must all pass before promotion (default: 3)
12    pub stability_seeds: u32,
13}
14
15impl Default for GatekeeperConfig {
16    fn default() -> Self {
17        Self {
18            score_gate_delta: std::env::var("AGENTFORGE_SCORE_GATE_DELTA")
19                .ok()
20                .and_then(|v| v.parse().ok())
21                .unwrap_or(0.03),
22            regression_gate_ratio: std::env::var("AGENTFORGE_REGRESSION_GATE_RATIO")
23                .ok()
24                .and_then(|v| v.parse().ok())
25                .unwrap_or(0.99),
26            stability_seeds: std::env::var("AGENTFORGE_STABILITY_SEEDS")
27                .ok()
28                .and_then(|v| v.parse().ok())
29                .unwrap_or(3),
30        }
31    }
32}
33
34/// Pass/fail result for a single gate.
35#[derive(Debug, Clone, PartialEq)]
36pub struct GateResult {
37    pub gate: GateKind,
38    pub status: GateStatus,
39    pub message: String,
40    /// Numeric delta (actual vs threshold) for display
41    pub delta: Option<f64>,
42}
43
44#[derive(Debug, Clone, PartialEq)]
45pub enum GateKind {
46    Score,
47    Regression,
48    Stability,
49}
50
51impl std::fmt::Display for GateKind {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        match self {
54            GateKind::Score => write!(f, "Score Gate"),
55            GateKind::Regression => write!(f, "Regression Gate"),
56            GateKind::Stability => write!(f, "Stability Gate"),
57        }
58    }
59}
60
61#[derive(Debug, Clone, PartialEq)]
62pub enum GateStatus {
63    Pass,
64    Fail,
65    Waived, // No champion exists yet
66}
67
68/// The overall promotion decision.
69#[derive(Debug, Clone)]
70pub struct PromotionDecision {
71    pub run_id: Uuid,
72    pub agent_id: Uuid,
73    pub approved: bool,
74    pub gates: Vec<GateResult>,
75    pub changelog: String,
76}
77
78/// The gatekeeper evaluates a challenger scorecard against the champion.
79pub struct Gatekeeper {
80    pub config: GatekeeperConfig,
81}
82
83impl Gatekeeper {
84    pub fn new(config: GatekeeperConfig) -> Self {
85        Self { config }
86    }
87
88    /// Evaluate all gates and return a promotion decision.
89    ///
90    /// `champion_scorecard`: None if no champion exists yet (first promotion is auto-approved).
91    /// `challenger_scorecard`: The scorecard from the candidate run.
92    /// `champion_passing_scenario_ids`: The set of scenario IDs the champion passed.
93    /// `challenger_traces`: The challenger's traces (for regression analysis).
94    /// `challenger_seed_scores`: Aggregate scores from each seed run (for stability).
95    #[allow(clippy::too_many_arguments)]
96    pub fn evaluate(
97        &self,
98        run_id: Uuid,
99        agent_id: Uuid,
100        champion_scorecard: Option<&Scorecard>,
101        challenger_scorecard: &Scorecard,
102        champion_passing_scenario_ids: &[Uuid],
103        challenger_traces: &[Trace],
104        challenger_seed_scores: &[f64],
105    ) -> Result<PromotionDecision> {
106        let mut gates = Vec::new();
107
108        // 1. Score Gate
109        let score_gate = self.check_score_gate(champion_scorecard, challenger_scorecard);
110        let score_passed =
111            score_gate.status == GateStatus::Pass || score_gate.status == GateStatus::Waived;
112        gates.push(score_gate);
113
114        // 2. Regression Gate
115        let regression_gate =
116            self.check_regression_gate(champion_passing_scenario_ids, challenger_traces);
117        let regression_passed = regression_gate.status == GateStatus::Pass
118            || regression_gate.status == GateStatus::Waived;
119        gates.push(regression_gate);
120
121        // 3. Stability Gate
122        let stability_gate = self.check_stability_gate(challenger_seed_scores);
123        let stability_passed = stability_gate.status == GateStatus::Pass
124            || stability_gate.status == GateStatus::Waived;
125        gates.push(stability_gate);
126
127        let approved = score_passed && regression_passed && stability_passed;
128
129        let changelog = build_changelog(champion_scorecard, challenger_scorecard, &gates);
130
131        tracing::info!(
132            run_id = %run_id,
133            approved = approved,
134            score_gate = %gates[0].status == GateStatus::Pass,
135            regression_gate = %gates[1].status == GateStatus::Pass,
136            stability_gate = %gates[2].status == GateStatus::Pass,
137            "Gatekeeper evaluation complete"
138        );
139
140        if !approved {
141            // Return gates info but surface the primary reason as an error type
142            let failed_gates: Vec<String> = gates
143                .iter()
144                .filter(|g| g.status == GateStatus::Fail)
145                .map(|g| format!("{}: {}", g.gate, g.message))
146                .collect();
147
148            return Ok(PromotionDecision {
149                run_id,
150                agent_id,
151                approved: false,
152                gates,
153                changelog: format!(
154                    "Promotion DENIED. Failed gates:\n{}",
155                    failed_gates.join("\n")
156                ),
157            });
158        }
159
160        Ok(PromotionDecision {
161            run_id,
162            agent_id,
163            approved: true,
164            gates,
165            changelog,
166        })
167    }
168
169    fn check_score_gate(&self, champion: Option<&Scorecard>, challenger: &Scorecard) -> GateResult {
170        let Some(champ) = champion else {
171            return GateResult {
172                gate: GateKind::Score,
173                status: GateStatus::Waived,
174                message: "No existing champion — score gate waived for first promotion".to_string(),
175                delta: None,
176            };
177        };
178
179        let delta = challenger.aggregate_score - champ.aggregate_score;
180        let threshold = self.config.score_gate_delta;
181
182        if delta >= threshold {
183            GateResult {
184                gate: GateKind::Score,
185                status: GateStatus::Pass,
186                message: format!(
187                    "Challenger aggregate {:.3} vs champion {:.3} (+{:.3}, required +{:.3})",
188                    challenger.aggregate_score, champ.aggregate_score, delta, threshold
189                ),
190                delta: Some(delta),
191            }
192        } else {
193            GateResult {
194                gate: GateKind::Score,
195                status: GateStatus::Fail,
196                message: format!(
197                    "Score gate failed: delta {:.3} < required {:.3} (challenger {:.3} vs champion {:.3})",
198                    delta, threshold, challenger.aggregate_score, champ.aggregate_score
199                ),
200                delta: Some(delta),
201            }
202        }
203    }
204
205    fn check_regression_gate(
206        &self,
207        champion_passing: &[Uuid],
208        challenger_traces: &[Trace],
209    ) -> GateResult {
210        if champion_passing.is_empty() {
211            return GateResult {
212                gate: GateKind::Regression,
213                status: GateStatus::Waived,
214                message: "No champion traces — regression gate waived".to_string(),
215                delta: None,
216            };
217        }
218
219        // Build a set of scenario IDs the challenger passed
220        let challenger_passed: std::collections::HashSet<Uuid> = challenger_traces
221            .iter()
222            .filter(|t| t.status == TraceStatus::Pass)
223            .map(|t| t.scenario_id)
224            .collect();
225
226        let champion_total = champion_passing.len() as f64;
227        let still_passing = champion_passing
228            .iter()
229            .filter(|id| challenger_passed.contains(id))
230            .count() as f64;
231
232        let retention_rate = still_passing / champion_total;
233        let threshold = self.config.regression_gate_ratio;
234
235        if retention_rate >= threshold {
236            GateResult {
237                gate: GateKind::Regression,
238                status: GateStatus::Pass,
239                message: format!(
240                    "Challenger retains {:.1}% of champion-passing scenarios (>= {:.1}% required)",
241                    retention_rate * 100.0,
242                    threshold * 100.0
243                ),
244                delta: Some(retention_rate - threshold),
245            }
246        } else {
247            let regressions = (champion_total - still_passing) as u32;
248            GateResult {
249                gate: GateKind::Regression,
250                status: GateStatus::Fail,
251                message: format!(
252                    "Regression gate failed: {regressions} regressions detected. Retention {:.1}% < {:.1}% required",
253                    retention_rate * 100.0,
254                    threshold * 100.0
255                ),
256                delta: Some(retention_rate - threshold),
257            }
258        }
259    }
260
261    fn check_stability_gate(&self, seed_scores: &[f64]) -> GateResult {
262        let required = self.config.stability_seeds as usize;
263
264        if seed_scores.len() < required {
265            return GateResult {
266                gate: GateKind::Stability,
267                status: GateStatus::Fail,
268                message: format!(
269                    "Stability gate failed: only {} seed run(s) provided, {} required",
270                    seed_scores.len(),
271                    required
272                ),
273                delta: None,
274            };
275        }
276
277        // Check that variance is not too high (all seeds within 5% of each other)
278        let min = seed_scores.iter().cloned().fold(f64::MAX, f64::min);
279        let max = seed_scores.iter().cloned().fold(f64::MIN, f64::max);
280        let variance = max - min;
281
282        if variance > 0.05 {
283            return GateResult {
284                gate: GateKind::Stability,
285                status: GateStatus::Fail,
286                message: format!(
287                    "Stability gate failed: score variance {:.3} across seeds (max allowed: 0.05)",
288                    variance
289                ),
290                delta: Some(-variance),
291            };
292        }
293
294        GateResult {
295            gate: GateKind::Stability,
296            status: GateStatus::Pass,
297            message: format!(
298                "{} seed runs passed with score variance {:.3} (< 0.05 required)",
299                seed_scores.len(),
300                variance
301            ),
302            delta: Some(0.05 - variance),
303        }
304    }
305}
306
307fn build_changelog(
308    champion: Option<&Scorecard>,
309    challenger: &Scorecard,
310    gates: &[GateResult],
311) -> String {
312    let mut lines = vec![
313        format!(
314            "# Promotion: {} v{}",
315            challenger.agent_name, challenger.agent_version
316        ),
317        String::new(),
318        "## Score Summary".to_string(),
319    ];
320
321    if let Some(champ) = champion {
322        let agg_delta = challenger.aggregate_score - champ.aggregate_score;
323        lines.push(format!(
324            "- Aggregate: {:.3} → {:.3} ({:+.3})",
325            champ.aggregate_score, challenger.aggregate_score, agg_delta
326        ));
327        let d = &challenger.dimension_scores;
328        let cd = &champ.dimension_scores;
329        lines.push(format!(
330            "- Task Completion: {:.3} → {:.3} ({:+.3})",
331            cd.task_completion,
332            d.task_completion,
333            d.task_completion - cd.task_completion
334        ));
335        lines.push(format!(
336            "- Tool Selection: {:.3} → {:.3} ({:+.3})",
337            cd.tool_selection,
338            d.tool_selection,
339            d.tool_selection - cd.tool_selection
340        ));
341        lines.push(format!(
342            "- Argument Correctness: {:.3} → {:.3} ({:+.3})",
343            cd.argument_correctness,
344            d.argument_correctness,
345            d.argument_correctness - cd.argument_correctness
346        ));
347        lines.push(format!(
348            "- Schema Compliance: {:.3} → {:.3} ({:+.3})",
349            cd.schema_compliance,
350            d.schema_compliance,
351            d.schema_compliance - cd.schema_compliance
352        ));
353        lines.push(format!(
354            "- Instruction Adherence: {:.3} → {:.3} ({:+.3})",
355            cd.instruction_adherence,
356            d.instruction_adherence,
357            d.instruction_adherence - cd.instruction_adherence
358        ));
359    } else {
360        lines.push(format!(
361            "- First promotion — aggregate score: {:.3}",
362            challenger.aggregate_score
363        ));
364    }
365
366    lines.push(String::new());
367    lines.push("## Gate Results".to_string());
368    for gate in gates {
369        let status = match &gate.status {
370            GateStatus::Pass => "✅ PASS",
371            GateStatus::Fail => "❌ FAIL",
372            GateStatus::Waived => "⏭ WAIVED",
373        };
374        lines.push(format!("- {}: {} — {}", gate.gate, status, gate.message));
375    }
376
377    lines.join("\n")
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use agentforge_core::{DimensionScores, Scorecard};
384    use chrono::Utc;
385    use uuid::Uuid;
386
387    fn make_scorecard(agg: f64) -> Scorecard {
388        Scorecard {
389            run_id: Uuid::new_v4(),
390            agent_id: Uuid::new_v4(),
391            agent_name: "test-agent".to_string(),
392            agent_version: "1.0.0".to_string(),
393            aggregate_score: agg,
394            pass_rate: agg,
395            total_scenarios: 100,
396            passed: (agg * 100.0) as u32,
397            failed: 100 - (agg * 100.0) as u32,
398            errors: 0,
399            review_needed: 0,
400            dimension_scores: DimensionScores {
401                task_completion: agg,
402                tool_selection: agg,
403                argument_correctness: agg,
404                schema_compliance: agg,
405                instruction_adherence: agg,
406                path_efficiency: agg,
407            },
408            failure_clusters: vec![],
409            duration_seconds: 60,
410            total_input_tokens: 1000,
411            total_output_tokens: 500,
412        }
413    }
414
415    fn make_trace(scenario_id: Uuid, status: TraceStatus) -> Trace {
416        Trace {
417            id: Uuid::new_v4(),
418            run_id: Uuid::new_v4(),
419            scenario_id,
420            status,
421            steps: vec![],
422            final_output: None,
423            scores: None,
424            aggregate_score: None,
425            failure_cluster: agentforge_core::FailureCluster::NoFailure,
426            failure_reason: None,
427            review_needed: false,
428            llm_calls: 1,
429            tool_invocations: 0,
430            input_tokens: 50,
431            output_tokens: 30,
432            latency_ms: 500,
433            retry_count: 0,
434            seed: 0,
435            created_at: Utc::now(),
436        }
437    }
438
439    #[test]
440    fn score_gate_passes_with_sufficient_delta() {
441        let gk = Gatekeeper::new(GatekeeperConfig::default());
442        let champion = make_scorecard(0.70);
443        let challenger = make_scorecard(0.74);
444        let result = gk
445            .evaluate(
446                Uuid::new_v4(),
447                Uuid::new_v4(),
448                Some(&champion),
449                &challenger,
450                &[],
451                &[],
452                &[0.74, 0.73, 0.75],
453            )
454            .unwrap();
455        assert!(result.approved);
456    }
457
458    #[test]
459    fn score_gate_fails_below_delta() {
460        let gk = Gatekeeper::new(GatekeeperConfig::default());
461        let champion = make_scorecard(0.70);
462        let challenger = make_scorecard(0.71); // only +1%, needs +3%
463        let result = gk
464            .evaluate(
465                Uuid::new_v4(),
466                Uuid::new_v4(),
467                Some(&champion),
468                &challenger,
469                &[],
470                &[],
471                &[0.71, 0.71, 0.71],
472            )
473            .unwrap();
474        assert!(!result.approved);
475        assert_eq!(result.gates[0].status, GateStatus::Fail);
476    }
477
478    #[test]
479    fn waived_when_no_champion() {
480        let gk = Gatekeeper::new(GatekeeperConfig::default());
481        let challenger = make_scorecard(0.80);
482        let result = gk
483            .evaluate(
484                Uuid::new_v4(),
485                Uuid::new_v4(),
486                None,
487                &challenger,
488                &[],
489                &[],
490                &[0.80, 0.79, 0.81],
491            )
492            .unwrap();
493        // All gates should be waived or pass for first promotion
494        assert!(result.approved, "First promotion should be approved");
495        assert_eq!(result.gates[0].status, GateStatus::Waived);
496        assert_eq!(result.gates[1].status, GateStatus::Waived);
497    }
498
499    #[test]
500    fn regression_gate_detects_failures() {
501        let gk = Gatekeeper::new(GatekeeperConfig {
502            regression_gate_ratio: 0.99,
503            score_gate_delta: 0.0, // disable score gate for this test
504            stability_seeds: 1,
505        });
506        let champion = make_scorecard(0.80);
507
508        // 100 champion-passing scenarios
509        let scenario_ids: Vec<Uuid> = (0..100).map(|_| Uuid::new_v4()).collect();
510
511        // Challenger only passes 90 of them
512        let challenger_traces: Vec<Trace> = scenario_ids
513            .iter()
514            .enumerate()
515            .map(|(i, &id)| {
516                if i < 90 {
517                    make_trace(id, TraceStatus::Pass)
518                } else {
519                    make_trace(id, TraceStatus::Fail)
520                }
521            })
522            .collect();
523
524        let challenger = make_scorecard(0.85);
525        let result = gk
526            .evaluate(
527                Uuid::new_v4(),
528                Uuid::new_v4(),
529                Some(&champion),
530                &challenger,
531                &scenario_ids,
532                &challenger_traces,
533                &[0.85],
534            )
535            .unwrap();
536        assert!(!result.approved);
537        assert_eq!(result.gates[1].status, GateStatus::Fail);
538    }
539
540    #[test]
541    fn stability_gate_fails_high_variance() {
542        let gk = Gatekeeper::new(GatekeeperConfig {
543            stability_seeds: 3,
544            score_gate_delta: 0.0,
545            regression_gate_ratio: 0.0,
546        });
547        let challenger = make_scorecard(0.80);
548        // Scores with high variance: 0.80 vs 0.70 = 0.10 delta (> 0.05 threshold)
549        let result = gk
550            .evaluate(
551                Uuid::new_v4(),
552                Uuid::new_v4(),
553                None,
554                &challenger,
555                &[],
556                &[],
557                &[0.80, 0.74, 0.79],
558            )
559            .unwrap();
560        assert!(!result.approved);
561        assert_eq!(result.gates[2].status, GateStatus::Fail);
562    }
563}