1use agentforge_core::{Result, Scorecard, Trace, TraceStatus};
2use uuid::Uuid;
3
4#[derive(Debug, Clone)]
6pub struct GatekeeperConfig {
7 pub score_gate_delta: f64,
9 pub regression_gate_ratio: f64,
11 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#[derive(Debug, Clone, PartialEq)]
36pub struct GateResult {
37 pub gate: GateKind,
38 pub status: GateStatus,
39 pub message: String,
40 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, }
67
68#[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
78pub struct Gatekeeper {
80 pub config: GatekeeperConfig,
81}
82
83impl Gatekeeper {
84 pub fn new(config: GatekeeperConfig) -> Self {
85 Self { config }
86 }
87
88 #[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 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 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 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 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 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 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); 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 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, stability_seeds: 1,
505 });
506 let champion = make_scorecard(0.80);
507
508 let scenario_ids: Vec<Uuid> = (0..100).map(|_| Uuid::new_v4()).collect();
510
511 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 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}