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)]
203pub struct GateHealthRegistry {
204 gates: std::collections::HashMap<String, GateHealth>,
205}
206
207impl GateHealthRegistry {
208 #[must_use]
210 pub fn new() -> Self {
211 Self::default()
212 }
213
214 #[must_use]
216 pub fn with_budget(
217 mut self,
218 gate_id: impl Into<String>,
219 window: usize,
220 max_error_rate: f64,
221 ) -> Self {
222 self.gates
223 .insert(gate_id.into(), GateHealth::new(window, max_error_rate));
224 self
225 }
226
227 #[must_use]
229 pub fn enabled(&self, gate_id: &str) -> bool {
230 self.gates.get(gate_id).is_none_or(GateHealth::is_enabled)
231 }
232
233 pub fn record(&self, gate_id: &str, errored: bool) {
235 if let Some(h) = self.gates.get(gate_id) {
236 h.record(gate_id, errored);
237 }
238 }
239}
240
241#[must_use]
248pub fn resolve_gates(
249 names: &[String],
250 defs: &[GateDef],
251 registry: &ProviderRegistry,
252 auth: &Auth,
253) -> Vec<Box<dyn Gate>> {
254 let mut gates: Vec<Box<dyn Gate>> = Vec::new();
255 for name in names {
256 match name.as_str() {
257 "non-empty" => gates.push(Box::new(NonEmptyGate)),
258 "json-valid" => gates.push(Box::new(JsonValidGate)),
259 other => match defs.iter().find(|d| d.id == other) {
260 Some(def) if def.judge.is_some() => {
261 if let Some(judge) = def.judge.as_ref() {
263 let provider_id = judge.model.split('/').next().unwrap_or_default();
264 match registry.get(provider_id) {
265 Some(provider) => gates.push(Box::new(JudgeGate::new(
266 def.id.clone(),
267 provider,
268 judge.model.clone(),
269 auth.clone(),
270 judge.threshold,
271 judge.rubric.clone().unwrap_or_default(),
272 ))),
273 None => tracing::warn!(
274 gate = %other, provider = %provider_id,
275 "judge gate provider not registered — skipped"
276 ),
277 }
278 }
279 }
280 Some(def) => {
281 let Some((program, args)) = def.cmd.split_first() else {
282 tracing::warn!(gate = %other, "configured gate has empty cmd — skipped");
283 continue;
284 };
285 gates.push(Box::new(SubprocessGate::new(
286 def.id.clone(),
287 program.clone(),
288 args.to_vec(),
289 Duration::from_millis(def.timeout_ms),
290 )));
291 }
292 None => tracing::warn!(
293 gate = %other,
294 "unknown gate id — not a built-in and not defined in [[gate]]; skipped"
295 ),
296 },
297 }
298 }
299 gates
300}
301
302#[must_use]
309pub fn aggregate(results: &[GateResult]) -> Verdict {
310 if results.iter().any(|r| r.verdict == Verdict::Fail) {
311 Verdict::Fail
312 } else {
313 Verdict::Pass
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use serde_json::{Value, json};
321
322 fn resp(text: &str) -> ModelResponse {
323 ModelResponse {
324 model: "anthropic/claude-haiku-4-5".to_owned(),
325 text: text.to_owned(),
326 in_tokens: 1,
327 out_tokens: 1,
328 raw: Value::Null,
329 }
330 }
331
332 fn req() -> ModelRequest {
333 ModelRequest {
334 model: "anthropic/claude-haiku-4-5".to_owned(),
335 system: None,
336 messages: vec![],
337 max_tokens: 16,
338 tools: Value::Null,
339 }
340 }
341
342 #[tokio::test]
343 async fn non_empty_gate() {
344 assert_eq!(
345 NonEmptyGate.evaluate(&req(), &resp("hi")).await.verdict,
346 Verdict::Pass
347 );
348 assert_eq!(
349 NonEmptyGate.evaluate(&req(), &resp(" ")).await.verdict,
350 Verdict::Fail
351 );
352 }
353
354 #[tokio::test]
355 async fn json_valid_gate() {
356 assert_eq!(
357 JsonValidGate
358 .evaluate(&req(), &resp(r#"{"ok":true}"#))
359 .await
360 .verdict,
361 Verdict::Pass
362 );
363 assert_eq!(
364 JsonValidGate.evaluate(&req(), &resp("nope")).await.verdict,
365 Verdict::Fail
366 );
367 }
368
369 #[tokio::test]
370 async fn schema_gate_type_and_required() {
371 let g = SchemaGate::new(json!({
372 "type": "object",
373 "required": ["name", "age"],
374 "properties": { "name": {"type": "string"}, "age": {"type": "integer"} }
375 }));
376 assert_eq!(
377 g.evaluate(&req(), &resp(r#"{"name":"a","age":3}"#))
378 .await
379 .verdict,
380 Verdict::Pass
381 );
382 assert_eq!(
384 g.evaluate(&req(), &resp(r#"{"name":"a"}"#)).await.verdict,
385 Verdict::Fail
386 );
387 assert_eq!(
389 g.evaluate(&req(), &resp(r#"{"name":"a","age":"x"}"#))
390 .await
391 .verdict,
392 Verdict::Fail
393 );
394 assert_eq!(g.evaluate(&req(), &resp("[]")).await.verdict, Verdict::Fail);
396 assert_eq!(
398 g.evaluate(&req(), &resp("plain text")).await.verdict,
399 Verdict::Fail
400 );
401 }
402
403 fn empty_registry() -> ProviderRegistry {
404 ProviderRegistry::new("http://localhost", "http://localhost")
405 }
406
407 #[test]
408 fn resolve_skips_unknown_and_keeps_known() {
409 let gates = resolve_gates(
410 &[
411 "non-empty".to_owned(),
412 "judge-diff".to_owned(),
413 "json-valid".to_owned(),
414 ],
415 &[],
416 &empty_registry(),
417 &Auth::default(),
418 );
419 let ids: Vec<_> = gates.iter().map(|g| g.id()).collect();
420 assert_eq!(ids, ["non-empty", "json-valid"]);
421 }
422
423 #[test]
424 fn resolve_builds_configured_subprocess_gate() {
425 let defs = vec![GateDef {
427 id: "my-tests".to_owned(),
428 cmd: vec!["true".to_owned()],
429 timeout_ms: 1000,
430 judge: None,
431 }];
432 let gates = resolve_gates(
433 &["my-tests".to_owned(), "undefined".to_owned()],
434 &defs,
435 &empty_registry(),
436 &Auth::default(),
437 );
438 let ids: Vec<_> = gates.iter().map(|g| g.id()).collect();
439 assert_eq!(
440 ids,
441 ["my-tests"],
442 "configured id resolves; unknown id skipped"
443 );
444 }
445
446 #[tokio::test]
447 async fn configured_subprocess_gate_runs_end_to_end() {
448 let script = r#"c=$(cat); case "$c" in *BAD*) echo '{"verdict":"fail"}';; *) echo '{"verdict":"pass"}';; esac"#;
451 let defs = vec![GateDef {
452 id: "no-bad".to_owned(),
453 cmd: vec!["bash".to_owned(), "-c".to_owned(), script.to_owned()],
454 timeout_ms: 5000,
455 judge: None,
456 }];
457 let gates = resolve_gates(
458 &["no-bad".to_owned()],
459 &defs,
460 &empty_registry(),
461 &Auth::default(),
462 );
463 assert_eq!(gates.len(), 1);
464 let good = gates[0].evaluate(&req(), &resp("all good")).await;
465 assert_eq!(good.verdict, Verdict::Pass);
466 let bad = gates[0].evaluate(&req(), &resp("this is BAD")).await;
467 assert_eq!(bad.verdict, Verdict::Fail);
468 }
469
470 #[test]
471 fn resolve_builds_configured_judge_gate() {
472 use crate::provider::{MockProvider, Provider};
473 use std::collections::HashMap;
474 use std::sync::Arc;
475
476 let defs = vec![GateDef {
477 id: "quality".to_owned(),
478 cmd: vec![],
479 timeout_ms: 30_000,
480 judge: Some(firstpass_core::JudgeDef {
481 model: "anthropic/judge".to_owned(),
482 threshold: 0.7,
483 rubric: None,
484 }),
485 }];
486
487 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
489 map.insert(
490 "anthropic".to_owned(),
491 Arc::new(MockProvider::new("anthropic", HashMap::new())),
492 );
493 let gates = resolve_gates(
494 &["quality".to_owned()],
495 &defs,
496 &ProviderRegistry::from_map(map),
497 &Auth::default(),
498 );
499 assert_eq!(
500 gates.iter().map(|g| g.id()).collect::<Vec<_>>(),
501 ["quality"]
502 );
503
504 let skipped = resolve_gates(
506 &["quality".to_owned()],
507 &defs,
508 &ProviderRegistry::from_map(HashMap::new()),
509 &Auth::default(),
510 );
511 assert!(
512 skipped.is_empty(),
513 "judge with no registered provider is skipped"
514 );
515 }
516
517 #[test]
518 fn aggregate_semantics() {
519 let pass = GateResult::deterministic("a", Verdict::Pass, 0);
520 let fail = GateResult::deterministic("b", Verdict::Fail, 0);
521 let abstain = GateResult::abstain("c", "x", 0);
522 assert_eq!(aggregate(&[]), Verdict::Pass);
523 assert_eq!(aggregate(std::slice::from_ref(&pass)), Verdict::Pass);
524 assert_eq!(aggregate(&[pass.clone(), fail]), Verdict::Fail);
525 assert_eq!(aggregate(&[pass, abstain]), Verdict::Pass);
526 }
527
528 #[test]
529 fn error_budget_auto_disables_past_threshold() {
530 let h = GateHealth::new(10, 0.5); for _ in 0..5 {
533 h.record("g", true);
534 }
535 for _ in 0..5 {
536 h.record("g", false);
537 }
538 assert!(h.is_enabled(), "50% is at, not above, the budget");
539 for _ in 0..6 {
541 h.record("g", true);
542 }
543 assert!(
544 !h.is_enabled(),
545 "gate should auto-disable once error rate exceeds budget"
546 );
547 }
548
549 #[test]
550 fn healthy_gate_stays_enabled() {
551 let h = GateHealth::new(20, 0.5);
552 for _ in 0..100 {
553 h.record("g", false);
554 }
555 assert!(h.is_enabled());
556 }
557}