use crate::judge::JudgeGate;
use crate::provider::{Auth, ModelRequest, ModelResponse, ProviderRegistry};
use crate::subprocess::SubprocessGate;
use async_trait::async_trait;
use firstpass_core::{GateDef, GateResult, Verdict};
use std::collections::VecDeque;
use std::sync::Mutex;
use std::time::Duration;
#[async_trait]
pub trait Gate: Send + Sync + std::fmt::Debug {
fn id(&self) -> &str;
async fn evaluate(&self, req: &ModelRequest, resp: &ModelResponse) -> GateResult;
}
#[derive(Debug, Clone, Copy)]
pub struct NonEmptyGate;
#[async_trait]
impl Gate for NonEmptyGate {
fn id(&self) -> &str {
"non-empty"
}
async fn evaluate(&self, _req: &ModelRequest, resp: &ModelResponse) -> GateResult {
let verdict = if resp.text.trim().is_empty() {
Verdict::Fail
} else {
Verdict::Pass
};
GateResult::deterministic(self.id(), verdict, 0)
}
}
#[derive(Debug, Clone, Copy)]
pub struct JsonValidGate;
#[async_trait]
impl Gate for JsonValidGate {
fn id(&self) -> &str {
"json-valid"
}
async fn evaluate(&self, _req: &ModelRequest, resp: &ModelResponse) -> GateResult {
let ok = serde_json::from_str::<serde_json::Value>(resp.text.trim()).is_ok();
GateResult::deterministic(self.id(), if ok { Verdict::Pass } else { Verdict::Fail }, 0)
}
}
#[derive(Debug, Clone)]
pub struct SchemaGate {
schema: serde_json::Value,
}
impl SchemaGate {
#[must_use]
pub fn new(schema: serde_json::Value) -> Self {
Self { schema }
}
fn violation(&self, value: &serde_json::Value) -> Option<String> {
use serde_json::Value;
let type_ok = |v: &Value, ty: &str| match ty {
"object" => v.is_object(),
"array" => v.is_array(),
"string" => v.is_string(),
"number" => v.is_number(),
"integer" => v.is_i64() || v.is_u64(),
"boolean" => v.is_boolean(),
"null" => v.is_null(),
_ => true, };
if let Some(ty) = self.schema.get("type").and_then(Value::as_str)
&& !type_ok(value, ty)
{
return Some(format!("root is not of type {ty}"));
}
if let Some(req) = self.schema.get("required").and_then(Value::as_array) {
for field in req.iter().filter_map(Value::as_str) {
if value.get(field).is_none() {
return Some(format!("missing required field {field:?}"));
}
}
}
if let Some(props) = self.schema.get("properties").and_then(Value::as_object) {
for (name, subschema) in props {
if let (Some(actual), Some(ty)) = (
value.get(name),
subschema.get("type").and_then(Value::as_str),
) && !type_ok(actual, ty)
{
return Some(format!("property {name:?} is not of type {ty}"));
}
}
}
None
}
}
#[async_trait]
impl Gate for SchemaGate {
fn id(&self) -> &str {
"schema"
}
async fn evaluate(&self, _req: &ModelRequest, resp: &ModelResponse) -> GateResult {
let Ok(value) = serde_json::from_str::<serde_json::Value>(resp.text.trim()) else {
let mut r = GateResult::deterministic(self.id(), Verdict::Fail, 0);
r.reason = Some("candidate is not valid JSON".to_owned());
return r;
};
match self.violation(&value) {
None => GateResult::deterministic(self.id(), Verdict::Pass, 0),
Some(reason) => {
let mut r = GateResult::deterministic(self.id(), Verdict::Fail, 0);
r.reason = Some(reason);
r
}
}
}
}
#[derive(Debug)]
pub struct GateHealth {
window: usize,
max_error_rate: f64,
outcomes: Mutex<VecDeque<bool>>, disabled: std::sync::atomic::AtomicBool,
}
impl GateHealth {
#[must_use]
pub fn new(window: usize, max_error_rate: f64) -> Self {
Self {
window: window.max(1),
max_error_rate,
outcomes: Mutex::new(VecDeque::new()),
disabled: std::sync::atomic::AtomicBool::new(false),
}
}
#[must_use]
pub fn is_enabled(&self) -> bool {
!self.disabled.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn record(&self, gate_id: &str, errored: bool) {
let Ok(mut q) = self.outcomes.lock() else {
return; };
q.push_back(errored);
while q.len() > self.window {
q.pop_front();
}
if q.len() == self.window {
let errors = q.iter().filter(|e| **e).count();
let rate = errors as f64 / self.window as f64;
if rate > self.max_error_rate && self.is_enabled() {
self.disabled
.store(true, std::sync::atomic::Ordering::Relaxed);
tracing::error!(
gate = %gate_id,
error_rate = rate,
"gate exceeded its error budget — auto-disabled (ALARM)"
);
}
}
}
}
#[derive(Debug, Default)]
pub struct GateHealthRegistry {
gates: std::collections::HashMap<String, GateHealth>,
}
impl GateHealthRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_budget(
mut self,
gate_id: impl Into<String>,
window: usize,
max_error_rate: f64,
) -> Self {
self.gates
.insert(gate_id.into(), GateHealth::new(window, max_error_rate));
self
}
#[must_use]
pub fn enabled(&self, gate_id: &str) -> bool {
self.gates.get(gate_id).is_none_or(GateHealth::is_enabled)
}
pub fn record(&self, gate_id: &str, errored: bool) {
if let Some(h) = self.gates.get(gate_id) {
h.record(gate_id, errored);
}
}
}
#[must_use]
pub fn resolve_gates(
names: &[String],
defs: &[GateDef],
registry: &ProviderRegistry,
auth: &Auth,
) -> Vec<Box<dyn Gate>> {
let mut gates: Vec<Box<dyn Gate>> = Vec::new();
for name in names {
match name.as_str() {
"non-empty" => gates.push(Box::new(NonEmptyGate)),
"json-valid" => gates.push(Box::new(JsonValidGate)),
other => match defs.iter().find(|d| d.id == other) {
Some(def) if def.judge.is_some() => {
if let Some(judge) = def.judge.as_ref() {
let provider_id = judge.model.split('/').next().unwrap_or_default();
match registry.get(provider_id) {
Some(provider) => gates.push(Box::new(JudgeGate::new(
def.id.clone(),
provider,
judge.model.clone(),
auth.clone(),
judge.threshold,
judge.rubric.clone().unwrap_or_default(),
))),
None => tracing::warn!(
gate = %other, provider = %provider_id,
"judge gate provider not registered — skipped"
),
}
}
}
Some(def) => {
let Some((program, args)) = def.cmd.split_first() else {
tracing::warn!(gate = %other, "configured gate has empty cmd — skipped");
continue;
};
gates.push(Box::new(SubprocessGate::new(
def.id.clone(),
program.clone(),
args.to_vec(),
Duration::from_millis(def.timeout_ms),
)));
}
None => tracing::warn!(
gate = %other,
"unknown gate id — not a built-in and not defined in [[gate]]; skipped"
),
},
}
}
gates
}
#[must_use]
pub fn aggregate(results: &[GateResult]) -> Verdict {
if results.iter().any(|r| r.verdict == Verdict::Fail) {
Verdict::Fail
} else {
Verdict::Pass
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Value, json};
fn resp(text: &str) -> ModelResponse {
ModelResponse {
model: "anthropic/claude-haiku-4-5".to_owned(),
text: text.to_owned(),
in_tokens: 1,
out_tokens: 1,
raw: Value::Null,
}
}
fn req() -> ModelRequest {
ModelRequest {
model: "anthropic/claude-haiku-4-5".to_owned(),
system: None,
messages: vec![],
max_tokens: 16,
tools: Value::Null,
}
}
#[tokio::test]
async fn non_empty_gate() {
assert_eq!(
NonEmptyGate.evaluate(&req(), &resp("hi")).await.verdict,
Verdict::Pass
);
assert_eq!(
NonEmptyGate.evaluate(&req(), &resp(" ")).await.verdict,
Verdict::Fail
);
}
#[tokio::test]
async fn json_valid_gate() {
assert_eq!(
JsonValidGate
.evaluate(&req(), &resp(r#"{"ok":true}"#))
.await
.verdict,
Verdict::Pass
);
assert_eq!(
JsonValidGate.evaluate(&req(), &resp("nope")).await.verdict,
Verdict::Fail
);
}
#[tokio::test]
async fn schema_gate_type_and_required() {
let g = SchemaGate::new(json!({
"type": "object",
"required": ["name", "age"],
"properties": { "name": {"type": "string"}, "age": {"type": "integer"} }
}));
assert_eq!(
g.evaluate(&req(), &resp(r#"{"name":"a","age":3}"#))
.await
.verdict,
Verdict::Pass
);
assert_eq!(
g.evaluate(&req(), &resp(r#"{"name":"a"}"#)).await.verdict,
Verdict::Fail
);
assert_eq!(
g.evaluate(&req(), &resp(r#"{"name":"a","age":"x"}"#))
.await
.verdict,
Verdict::Fail
);
assert_eq!(g.evaluate(&req(), &resp("[]")).await.verdict, Verdict::Fail);
assert_eq!(
g.evaluate(&req(), &resp("plain text")).await.verdict,
Verdict::Fail
);
}
fn empty_registry() -> ProviderRegistry {
ProviderRegistry::new("http://localhost", "http://localhost")
}
#[test]
fn resolve_skips_unknown_and_keeps_known() {
let gates = resolve_gates(
&[
"non-empty".to_owned(),
"judge-diff".to_owned(),
"json-valid".to_owned(),
],
&[],
&empty_registry(),
&Auth::default(),
);
let ids: Vec<_> = gates.iter().map(|g| g.id()).collect();
assert_eq!(ids, ["non-empty", "json-valid"]);
}
#[test]
fn resolve_builds_configured_subprocess_gate() {
let defs = vec![GateDef {
id: "my-tests".to_owned(),
cmd: vec!["true".to_owned()],
timeout_ms: 1000,
judge: None,
}];
let gates = resolve_gates(
&["my-tests".to_owned(), "undefined".to_owned()],
&defs,
&empty_registry(),
&Auth::default(),
);
let ids: Vec<_> = gates.iter().map(|g| g.id()).collect();
assert_eq!(
ids,
["my-tests"],
"configured id resolves; unknown id skipped"
);
}
#[tokio::test]
async fn configured_subprocess_gate_runs_end_to_end() {
let script = r#"c=$(cat); case "$c" in *BAD*) echo '{"verdict":"fail"}';; *) echo '{"verdict":"pass"}';; esac"#;
let defs = vec![GateDef {
id: "no-bad".to_owned(),
cmd: vec!["bash".to_owned(), "-c".to_owned(), script.to_owned()],
timeout_ms: 5000,
judge: None,
}];
let gates = resolve_gates(
&["no-bad".to_owned()],
&defs,
&empty_registry(),
&Auth::default(),
);
assert_eq!(gates.len(), 1);
let good = gates[0].evaluate(&req(), &resp("all good")).await;
assert_eq!(good.verdict, Verdict::Pass);
let bad = gates[0].evaluate(&req(), &resp("this is BAD")).await;
assert_eq!(bad.verdict, Verdict::Fail);
}
#[test]
fn resolve_builds_configured_judge_gate() {
use crate::provider::{MockProvider, Provider};
use std::collections::HashMap;
use std::sync::Arc;
let defs = vec![GateDef {
id: "quality".to_owned(),
cmd: vec![],
timeout_ms: 30_000,
judge: Some(firstpass_core::JudgeDef {
model: "anthropic/judge".to_owned(),
threshold: 0.7,
rubric: None,
}),
}];
let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
map.insert(
"anthropic".to_owned(),
Arc::new(MockProvider::new("anthropic", HashMap::new())),
);
let gates = resolve_gates(
&["quality".to_owned()],
&defs,
&ProviderRegistry::from_map(map),
&Auth::default(),
);
assert_eq!(
gates.iter().map(|g| g.id()).collect::<Vec<_>>(),
["quality"]
);
let skipped = resolve_gates(
&["quality".to_owned()],
&defs,
&ProviderRegistry::from_map(HashMap::new()),
&Auth::default(),
);
assert!(
skipped.is_empty(),
"judge with no registered provider is skipped"
);
}
#[test]
fn aggregate_semantics() {
let pass = GateResult::deterministic("a", Verdict::Pass, 0);
let fail = GateResult::deterministic("b", Verdict::Fail, 0);
let abstain = GateResult::abstain("c", "x", 0);
assert_eq!(aggregate(&[]), Verdict::Pass);
assert_eq!(aggregate(std::slice::from_ref(&pass)), Verdict::Pass);
assert_eq!(aggregate(&[pass.clone(), fail]), Verdict::Fail);
assert_eq!(aggregate(&[pass, abstain]), Verdict::Pass);
}
#[test]
fn error_budget_auto_disables_past_threshold() {
let h = GateHealth::new(10, 0.5); for _ in 0..5 {
h.record("g", true);
}
for _ in 0..5 {
h.record("g", false);
}
assert!(h.is_enabled(), "50% is at, not above, the budget");
for _ in 0..6 {
h.record("g", true);
}
assert!(
!h.is_enabled(),
"gate should auto-disable once error rate exceeds budget"
);
}
#[test]
fn healthy_gate_stays_enabled() {
let h = GateHealth::new(20, 0.5);
for _ in 0..100 {
h.record("g", false);
}
assert!(h.is_enabled());
}
}