1use crate::judge::JudgeGate;
11use crate::provider::{Auth, ModelRequest, ModelResponse, ProviderRegistry};
12use crate::subprocess::SubprocessGate;
13use async_trait::async_trait;
14use firstpass_core::{GateDef, GateResult, Verdict};
15use std::collections::VecDeque;
16use std::sync::Mutex;
17use std::time::Duration;
18
19#[async_trait]
21pub trait Gate: Send + Sync + std::fmt::Debug {
22 fn id(&self) -> &str;
24 async fn evaluate(&self, req: &ModelRequest, resp: &ModelResponse) -> GateResult;
26}
27
28#[derive(Debug, Clone, Copy)]
30pub struct NonEmptyGate;
31
32#[async_trait]
33impl Gate for NonEmptyGate {
34 fn id(&self) -> &str {
35 "non-empty"
36 }
37 async fn evaluate(&self, _req: &ModelRequest, resp: &ModelResponse) -> GateResult {
38 let verdict = if resp.text.trim().is_empty() {
39 Verdict::Fail
40 } else {
41 Verdict::Pass
42 };
43 GateResult::deterministic(self.id(), verdict, 0)
44 }
45}
46
47#[derive(Debug, Clone, Copy)]
49pub struct JsonValidGate;
50
51#[async_trait]
52impl Gate for JsonValidGate {
53 fn id(&self) -> &str {
54 "json-valid"
55 }
56 async fn evaluate(&self, _req: &ModelRequest, resp: &ModelResponse) -> GateResult {
57 let ok = serde_json::from_str::<serde_json::Value>(resp.text.trim()).is_ok();
58 GateResult::deterministic(self.id(), if ok { Verdict::Pass } else { Verdict::Fail }, 0)
59 }
60}
61
62#[derive(Debug, Clone)]
68pub struct SchemaGate {
69 schema: serde_json::Value,
70}
71
72impl SchemaGate {
73 #[must_use]
75 pub fn new(schema: serde_json::Value) -> Self {
76 Self { schema }
77 }
78
79 fn violation(&self, value: &serde_json::Value) -> Option<String> {
81 use serde_json::Value;
82 let type_ok = |v: &Value, ty: &str| match ty {
83 "object" => v.is_object(),
84 "array" => v.is_array(),
85 "string" => v.is_string(),
86 "number" => v.is_number(),
87 "integer" => v.is_i64() || v.is_u64(),
88 "boolean" => v.is_boolean(),
89 "null" => v.is_null(),
90 _ => true, };
92 if let Some(ty) = self.schema.get("type").and_then(Value::as_str)
93 && !type_ok(value, ty)
94 {
95 return Some(format!("root is not of type {ty}"));
96 }
97 if let Some(req) = self.schema.get("required").and_then(Value::as_array) {
98 for field in req.iter().filter_map(Value::as_str) {
99 if value.get(field).is_none() {
100 return Some(format!("missing required field {field:?}"));
101 }
102 }
103 }
104 if let Some(props) = self.schema.get("properties").and_then(Value::as_object) {
105 for (name, subschema) in props {
106 if let (Some(actual), Some(ty)) = (
107 value.get(name),
108 subschema.get("type").and_then(Value::as_str),
109 ) && !type_ok(actual, ty)
110 {
111 return Some(format!("property {name:?} is not of type {ty}"));
112 }
113 }
114 }
115 None
116 }
117}
118
119#[async_trait]
120impl Gate for SchemaGate {
121 fn id(&self) -> &str {
122 "schema"
123 }
124 async fn evaluate(&self, _req: &ModelRequest, resp: &ModelResponse) -> GateResult {
125 let Ok(value) = serde_json::from_str::<serde_json::Value>(resp.text.trim()) else {
126 let mut r = GateResult::deterministic(self.id(), Verdict::Fail, 0);
127 r.reason = Some("candidate is not valid JSON".to_owned());
128 return r;
129 };
130 match self.violation(&value) {
131 None => GateResult::deterministic(self.id(), Verdict::Pass, 0),
132 Some(reason) => {
133 let mut r = GateResult::deterministic(self.id(), Verdict::Fail, 0);
134 r.reason = Some(reason);
135 r
136 }
137 }
138 }
139}
140
141#[derive(Debug)]
146pub struct GateHealth {
147 window: usize,
148 max_error_rate: f64,
149 outcomes: Mutex<VecDeque<bool>>, disabled: std::sync::atomic::AtomicBool,
151}
152
153impl GateHealth {
154 #[must_use]
157 pub fn new(window: usize, max_error_rate: f64) -> Self {
158 Self {
159 window: window.max(1),
160 max_error_rate,
161 outcomes: Mutex::new(VecDeque::new()),
162 disabled: std::sync::atomic::AtomicBool::new(false),
163 }
164 }
165
166 #[must_use]
168 pub fn is_enabled(&self) -> bool {
169 !self.disabled.load(std::sync::atomic::Ordering::Relaxed)
170 }
171
172 pub fn record(&self, gate_id: &str, errored: bool) {
174 let Ok(mut q) = self.outcomes.lock() else {
177 return; };
179 q.push_back(errored);
180 while q.len() > self.window {
181 q.pop_front();
182 }
183 if q.len() == self.window {
184 let errors = q.iter().filter(|e| **e).count();
185 let rate = errors as f64 / self.window as f64;
186 if rate > self.max_error_rate && self.is_enabled() {
187 self.disabled
188 .store(true, std::sync::atomic::Ordering::Relaxed);
189 tracing::error!(
190 gate = %gate_id,
191 error_rate = rate,
192 "gate exceeded its error budget — auto-disabled (ALARM)"
193 );
194 }
195 }
196 }
197}
198
199#[derive(Debug, Default)]
209pub struct GateHealthRegistry {
210 budgets: std::collections::HashMap<String, (usize, f64)>,
211 state: Mutex<std::collections::HashMap<(String, String), GateHealth>>,
212}
213
214impl GateHealthRegistry {
215 #[must_use]
217 pub fn new() -> Self {
218 Self::default()
219 }
220
221 #[must_use]
223 pub fn with_budget(
224 mut self,
225 gate_id: impl Into<String>,
226 window: usize,
227 max_error_rate: f64,
228 ) -> Self {
229 self.budgets
230 .insert(gate_id.into(), (window, max_error_rate));
231 self
232 }
233
234 #[must_use]
237 pub fn enabled(&self, tenant: &str, gate_id: &str) -> bool {
238 let Some(&(window, max_error_rate)) = self.budgets.get(gate_id) else {
239 return true;
240 };
241 let Ok(mut state) = self.state.lock() else {
242 return true;
243 };
244 state
245 .entry((tenant.to_owned(), gate_id.to_owned()))
246 .or_insert_with(|| GateHealth::new(window, max_error_rate))
247 .is_enabled()
248 }
249
250 pub fn record(&self, tenant: &str, gate_id: &str, errored: bool) {
253 let Some(&(window, max_error_rate)) = self.budgets.get(gate_id) else {
254 return;
255 };
256 let Ok(mut state) = self.state.lock() else {
257 return;
258 };
259 state
260 .entry((tenant.to_owned(), gate_id.to_owned()))
261 .or_insert_with(|| GateHealth::new(window, max_error_rate))
262 .record(gate_id, errored);
263 }
264}
265
266#[must_use]
273pub fn resolve_gates(
274 names: &[String],
275 defs: &[GateDef],
276 registry: &ProviderRegistry,
277 auth: &Auth,
278) -> Vec<Box<dyn Gate>> {
279 let mut gates: Vec<Box<dyn Gate>> = Vec::new();
280 for name in names {
281 match name.as_str() {
282 "non-empty" => gates.push(Box::new(NonEmptyGate)),
283 "json-valid" => gates.push(Box::new(JsonValidGate)),
284 other => match defs.iter().find(|d| d.id == other) {
285 Some(def) if def.judge.is_some() => {
286 if let Some(judge) = def.judge.as_ref() {
288 let provider_id = judge.model.split('/').next().unwrap_or_default();
289 match registry.get(provider_id) {
290 Some(provider) => gates.push(Box::new(JudgeGate::new(
291 def.id.clone(),
292 provider,
293 judge.model.clone(),
294 auth.clone(),
295 judge.threshold,
296 judge.rubric.clone().unwrap_or_default(),
297 ))),
298 None => tracing::warn!(
299 gate = %other, provider = %provider_id,
300 "judge gate provider not registered — skipped"
301 ),
302 }
303 }
304 }
305 Some(def) => {
306 let Some((program, args)) = def.cmd.split_first() else {
307 tracing::warn!(gate = %other, "configured gate has empty cmd — skipped");
308 continue;
309 };
310 gates.push(Box::new(SubprocessGate::new(
311 def.id.clone(),
312 program.clone(),
313 args.to_vec(),
314 Duration::from_millis(def.timeout_ms),
315 )));
316 }
317 None => tracing::warn!(
318 gate = %other,
319 "unknown gate id — not a built-in and not defined in [[gate]]; skipped"
320 ),
321 },
322 }
323 }
324 gates
325}
326
327#[must_use]
334pub fn aggregate(results: &[GateResult]) -> Verdict {
335 if results.iter().any(|r| r.verdict == Verdict::Fail) {
336 Verdict::Fail
337 } else {
338 Verdict::Pass
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use serde_json::{Value, json};
346
347 fn resp(text: &str) -> ModelResponse {
348 ModelResponse {
349 model: "anthropic/claude-haiku-4-5".to_owned(),
350 text: text.to_owned(),
351 in_tokens: 1,
352 out_tokens: 1,
353 raw: Value::Null,
354 }
355 }
356
357 fn req() -> ModelRequest {
358 ModelRequest {
359 model: "anthropic/claude-haiku-4-5".to_owned(),
360 system: None,
361 messages: vec![],
362 max_tokens: 16,
363 tools: Value::Null,
364 }
365 }
366
367 #[tokio::test]
368 async fn non_empty_gate() {
369 assert_eq!(
370 NonEmptyGate.evaluate(&req(), &resp("hi")).await.verdict,
371 Verdict::Pass
372 );
373 assert_eq!(
374 NonEmptyGate.evaluate(&req(), &resp(" ")).await.verdict,
375 Verdict::Fail
376 );
377 }
378
379 #[tokio::test]
380 async fn json_valid_gate() {
381 assert_eq!(
382 JsonValidGate
383 .evaluate(&req(), &resp(r#"{"ok":true}"#))
384 .await
385 .verdict,
386 Verdict::Pass
387 );
388 assert_eq!(
389 JsonValidGate.evaluate(&req(), &resp("nope")).await.verdict,
390 Verdict::Fail
391 );
392 }
393
394 #[tokio::test]
395 async fn schema_gate_type_and_required() {
396 let g = SchemaGate::new(json!({
397 "type": "object",
398 "required": ["name", "age"],
399 "properties": { "name": {"type": "string"}, "age": {"type": "integer"} }
400 }));
401 assert_eq!(
402 g.evaluate(&req(), &resp(r#"{"name":"a","age":3}"#))
403 .await
404 .verdict,
405 Verdict::Pass
406 );
407 assert_eq!(
409 g.evaluate(&req(), &resp(r#"{"name":"a"}"#)).await.verdict,
410 Verdict::Fail
411 );
412 assert_eq!(
414 g.evaluate(&req(), &resp(r#"{"name":"a","age":"x"}"#))
415 .await
416 .verdict,
417 Verdict::Fail
418 );
419 assert_eq!(g.evaluate(&req(), &resp("[]")).await.verdict, Verdict::Fail);
421 assert_eq!(
423 g.evaluate(&req(), &resp("plain text")).await.verdict,
424 Verdict::Fail
425 );
426 }
427
428 fn empty_registry() -> ProviderRegistry {
429 ProviderRegistry::new("http://localhost", "http://localhost")
430 }
431
432 #[test]
433 fn resolve_skips_unknown_and_keeps_known() {
434 let gates = resolve_gates(
435 &[
436 "non-empty".to_owned(),
437 "judge-diff".to_owned(),
438 "json-valid".to_owned(),
439 ],
440 &[],
441 &empty_registry(),
442 &Auth::default(),
443 );
444 let ids: Vec<_> = gates.iter().map(|g| g.id()).collect();
445 assert_eq!(ids, ["non-empty", "json-valid"]);
446 }
447
448 #[test]
449 fn resolve_builds_configured_subprocess_gate() {
450 let defs = vec![GateDef {
452 id: "my-tests".to_owned(),
453 cmd: vec!["true".to_owned()],
454 timeout_ms: 1000,
455 judge: None,
456 }];
457 let gates = resolve_gates(
458 &["my-tests".to_owned(), "undefined".to_owned()],
459 &defs,
460 &empty_registry(),
461 &Auth::default(),
462 );
463 let ids: Vec<_> = gates.iter().map(|g| g.id()).collect();
464 assert_eq!(
465 ids,
466 ["my-tests"],
467 "configured id resolves; unknown id skipped"
468 );
469 }
470
471 #[tokio::test]
472 async fn configured_subprocess_gate_runs_end_to_end() {
473 let script = r#"c=$(cat); case "$c" in *BAD*) echo '{"verdict":"fail"}';; *) echo '{"verdict":"pass"}';; esac"#;
476 let defs = vec![GateDef {
477 id: "no-bad".to_owned(),
478 cmd: vec!["bash".to_owned(), "-c".to_owned(), script.to_owned()],
479 timeout_ms: 5000,
480 judge: None,
481 }];
482 let gates = resolve_gates(
483 &["no-bad".to_owned()],
484 &defs,
485 &empty_registry(),
486 &Auth::default(),
487 );
488 assert_eq!(gates.len(), 1);
489 let good = gates[0].evaluate(&req(), &resp("all good")).await;
490 assert_eq!(good.verdict, Verdict::Pass);
491 let bad = gates[0].evaluate(&req(), &resp("this is BAD")).await;
492 assert_eq!(bad.verdict, Verdict::Fail);
493 }
494
495 #[test]
496 fn resolve_builds_configured_judge_gate() {
497 use crate::provider::{MockProvider, Provider};
498 use std::collections::HashMap;
499 use std::sync::Arc;
500
501 let defs = vec![GateDef {
502 id: "quality".to_owned(),
503 cmd: vec![],
504 timeout_ms: 30_000,
505 judge: Some(firstpass_core::JudgeDef {
506 model: "anthropic/judge".to_owned(),
507 threshold: 0.7,
508 rubric: None,
509 }),
510 }];
511
512 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
514 map.insert(
515 "anthropic".to_owned(),
516 Arc::new(MockProvider::new("anthropic", HashMap::new())),
517 );
518 let gates = resolve_gates(
519 &["quality".to_owned()],
520 &defs,
521 &ProviderRegistry::from_map(map),
522 &Auth::default(),
523 );
524 assert_eq!(
525 gates.iter().map(|g| g.id()).collect::<Vec<_>>(),
526 ["quality"]
527 );
528
529 let skipped = resolve_gates(
531 &["quality".to_owned()],
532 &defs,
533 &ProviderRegistry::from_map(HashMap::new()),
534 &Auth::default(),
535 );
536 assert!(
537 skipped.is_empty(),
538 "judge with no registered provider is skipped"
539 );
540 }
541
542 #[test]
543 fn aggregate_semantics() {
544 let pass = GateResult::deterministic("a", Verdict::Pass, 0);
545 let fail = GateResult::deterministic("b", Verdict::Fail, 0);
546 let abstain = GateResult::abstain("c", "x", 0);
547 assert_eq!(aggregate(&[]), Verdict::Pass);
548 assert_eq!(aggregate(std::slice::from_ref(&pass)), Verdict::Pass);
549 assert_eq!(aggregate(&[pass.clone(), fail]), Verdict::Fail);
550 assert_eq!(aggregate(&[pass, abstain]), Verdict::Pass);
551 }
552
553 #[test]
554 fn error_budget_auto_disables_past_threshold() {
555 let h = GateHealth::new(10, 0.5); for _ in 0..5 {
558 h.record("g", true);
559 }
560 for _ in 0..5 {
561 h.record("g", false);
562 }
563 assert!(h.is_enabled(), "50% is at, not above, the budget");
564 for _ in 0..6 {
566 h.record("g", true);
567 }
568 assert!(
569 !h.is_enabled(),
570 "gate should auto-disable once error rate exceeds budget"
571 );
572 }
573
574 #[test]
575 fn healthy_gate_stays_enabled() {
576 let h = GateHealth::new(20, 0.5);
577 for _ in 0..100 {
578 h.record("g", false);
579 }
580 assert!(h.is_enabled());
581 }
582
583 #[test]
584 fn gate_health_registry_default_tenant_is_a_single_bucket() {
585 let registry = GateHealthRegistry::new().with_budget("g", 4, 0.5);
588 for _ in 0..4 {
589 registry.record("default", "g", true);
590 }
591 assert!(!registry.enabled("default", "g"));
592 }
593
594 #[test]
595 fn gate_health_registry_scopes_budget_per_tenant() {
596 let registry = GateHealthRegistry::new().with_budget("g", 4, 0.5);
598 for _ in 0..4 {
599 registry.record("tenant-a", "g", true);
600 }
601 assert!(!registry.enabled("tenant-a", "g"), "A should be disabled");
602 assert!(registry.enabled("tenant-b", "g"), "B must be unaffected");
603 }
604
605 #[test]
606 fn gate_health_registry_unregistered_gate_always_enabled() {
607 let registry = GateHealthRegistry::new();
608 assert!(registry.enabled("tenant-a", "unknown-gate"));
609 registry.record("tenant-a", "unknown-gate", true); assert!(registry.enabled("tenant-a", "unknown-gate"));
611 }
612}