1use crate::consistency::ConsistencyGate;
11use crate::judge::JudgeGate;
12use crate::provider::{Auth, ModelRequest, ModelResponse, ProviderRegistry};
13use crate::subprocess::SubprocessGate;
14use async_trait::async_trait;
15use firstpass_core::{AbstainPolicy, GateDef, GateResult, Verdict, cost::PriceTable};
16use std::collections::VecDeque;
17use std::sync::Mutex;
18use std::time::Duration;
19
20#[async_trait]
22pub trait Gate: Send + Sync + std::fmt::Debug {
23 fn id(&self) -> &str;
25 async fn evaluate(&self, req: &ModelRequest, resp: &ModelResponse) -> GateResult;
27 fn abstain_fails_closed(&self) -> bool {
32 false
33 }
34}
35
36#[derive(Debug, Clone, Copy)]
38pub struct NonEmptyGate;
39
40#[async_trait]
41impl Gate for NonEmptyGate {
42 fn id(&self) -> &str {
43 "non-empty"
44 }
45 async fn evaluate(&self, _req: &ModelRequest, resp: &ModelResponse) -> GateResult {
46 let verdict = if resp.text.trim().is_empty() {
47 Verdict::Fail
48 } else {
49 Verdict::Pass
50 };
51 GateResult::deterministic(self.id(), verdict, 0)
52 }
53}
54
55#[derive(Debug, Clone, Copy)]
57pub struct JsonValidGate;
58
59#[async_trait]
60impl Gate for JsonValidGate {
61 fn id(&self) -> &str {
62 "json-valid"
63 }
64 async fn evaluate(&self, _req: &ModelRequest, resp: &ModelResponse) -> GateResult {
65 let ok = serde_json::from_str::<serde_json::Value>(resp.text.trim()).is_ok();
66 GateResult::deterministic(self.id(), if ok { Verdict::Pass } else { Verdict::Fail }, 0)
67 }
68}
69
70#[derive(Debug, Clone)]
76pub struct SchemaGate {
77 id: String,
78 schema: serde_json::Value,
79}
80
81impl SchemaGate {
82 #[must_use]
84 pub fn new(id: impl Into<String>, schema: serde_json::Value) -> Self {
85 Self {
86 id: id.into(),
87 schema,
88 }
89 }
90
91 fn violation(&self, value: &serde_json::Value) -> Option<String> {
93 use serde_json::Value;
94 let type_ok = |v: &Value, ty: &str| match ty {
95 "object" => v.is_object(),
96 "array" => v.is_array(),
97 "string" => v.is_string(),
98 "number" => v.is_number(),
99 "integer" => v.is_i64() || v.is_u64(),
100 "boolean" => v.is_boolean(),
101 "null" => v.is_null(),
102 _ => true, };
104 if let Some(ty) = self.schema.get("type").and_then(Value::as_str)
105 && !type_ok(value, ty)
106 {
107 return Some(format!("root is not of type {ty}"));
108 }
109 if let Some(req) = self.schema.get("required").and_then(Value::as_array) {
110 for field in req.iter().filter_map(Value::as_str) {
111 if value.get(field).is_none() {
112 return Some(format!("missing required field {field:?}"));
113 }
114 }
115 }
116 if let Some(props) = self.schema.get("properties").and_then(Value::as_object) {
117 for (name, subschema) in props {
118 if let (Some(actual), Some(ty)) = (
119 value.get(name),
120 subschema.get("type").and_then(Value::as_str),
121 ) && !type_ok(actual, ty)
122 {
123 return Some(format!("property {name:?} is not of type {ty}"));
124 }
125 }
126 }
127 None
128 }
129}
130
131#[async_trait]
132impl Gate for SchemaGate {
133 fn id(&self) -> &str {
134 &self.id
135 }
136 async fn evaluate(&self, _req: &ModelRequest, resp: &ModelResponse) -> GateResult {
137 let Ok(value) = serde_json::from_str::<serde_json::Value>(resp.text.trim()) else {
138 let mut r = GateResult::deterministic(self.id(), Verdict::Fail, 0);
139 r.reason = Some("candidate is not valid JSON".to_owned());
140 return r;
141 };
142 match self.violation(&value) {
143 None => GateResult::deterministic(self.id(), Verdict::Pass, 0),
144 Some(reason) => {
145 let mut r = GateResult::deterministic(self.id(), Verdict::Fail, 0);
146 r.reason = Some(reason);
147 r
148 }
149 }
150 }
151}
152
153#[derive(Debug)]
158pub struct GateHealth {
159 window: usize,
160 max_error_rate: f64,
161 outcomes: Mutex<VecDeque<bool>>, disabled: std::sync::atomic::AtomicBool,
163}
164
165impl GateHealth {
166 #[must_use]
169 pub fn new(window: usize, max_error_rate: f64) -> Self {
170 Self {
171 window: window.max(1),
172 max_error_rate,
173 outcomes: Mutex::new(VecDeque::new()),
174 disabled: std::sync::atomic::AtomicBool::new(false),
175 }
176 }
177
178 #[must_use]
180 pub fn is_enabled(&self) -> bool {
181 !self.disabled.load(std::sync::atomic::Ordering::Relaxed)
182 }
183
184 pub fn record(&self, gate_id: &str, errored: bool) {
186 let Ok(mut q) = self.outcomes.lock() else {
189 return; };
191 q.push_back(errored);
192 while q.len() > self.window {
193 q.pop_front();
194 }
195 if q.len() == self.window {
196 let errors = q.iter().filter(|e| **e).count();
197 let rate = errors as f64 / self.window as f64;
198 if rate > self.max_error_rate && self.is_enabled() {
199 self.disabled
200 .store(true, std::sync::atomic::Ordering::Relaxed);
201 tracing::error!(
202 gate = %gate_id,
203 error_rate = rate,
204 "gate exceeded its error budget — auto-disabled (ALARM)"
205 );
206 }
207 }
208 }
209}
210
211#[derive(Debug, Default)]
221pub struct GateHealthRegistry {
222 budgets: std::collections::HashMap<String, (usize, f64)>,
223 state: Mutex<std::collections::HashMap<(String, String), GateHealth>>,
224}
225
226impl GateHealthRegistry {
227 #[must_use]
229 pub fn new() -> Self {
230 Self::default()
231 }
232
233 #[must_use]
235 pub fn with_budget(
236 mut self,
237 gate_id: impl Into<String>,
238 window: usize,
239 max_error_rate: f64,
240 ) -> Self {
241 self.budgets
242 .insert(gate_id.into(), (window, max_error_rate));
243 self
244 }
245
246 #[must_use]
249 pub fn enabled(&self, tenant: &str, gate_id: &str) -> bool {
250 let Some(&(window, max_error_rate)) = self.budgets.get(gate_id) else {
251 return true;
252 };
253 let Ok(mut state) = self.state.lock() else {
254 return true;
255 };
256 state
257 .entry((tenant.to_owned(), gate_id.to_owned()))
258 .or_insert_with(|| GateHealth::new(window, max_error_rate))
259 .is_enabled()
260 }
261
262 pub fn record(&self, tenant: &str, gate_id: &str, errored: bool) {
265 let Some(&(window, max_error_rate)) = self.budgets.get(gate_id) else {
266 return;
267 };
268 let Ok(mut state) = self.state.lock() else {
269 return;
270 };
271 state
272 .entry((tenant.to_owned(), gate_id.to_owned()))
273 .or_insert_with(|| GateHealth::new(window, max_error_rate))
274 .record(gate_id, errored);
275 }
276}
277
278#[derive(Debug)]
282struct FailClosed(Box<dyn Gate>);
283
284#[async_trait]
285impl Gate for FailClosed {
286 fn id(&self) -> &str {
287 self.0.id()
288 }
289 async fn evaluate(&self, req: &ModelRequest, resp: &ModelResponse) -> GateResult {
290 self.0.evaluate(req, resp).await
291 }
292 fn abstain_fails_closed(&self) -> bool {
293 true
294 }
295}
296
297#[must_use]
306pub fn resolve_gates(
307 names: &[String],
308 defs: &[GateDef],
309 registry: &ProviderRegistry,
310 auth: &Auth,
311 prices: &PriceTable,
312) -> Vec<Box<dyn Gate>> {
313 let mut gates: Vec<Box<dyn Gate>> = Vec::new();
314 let mut push = |gate: Box<dyn Gate>, def: Option<&GateDef>| {
315 if def.is_some_and(|d| d.on_abstain == AbstainPolicy::FailClosed) {
316 gates.push(Box::new(FailClosed(gate)));
317 } else {
318 gates.push(gate);
319 }
320 };
321 for name in names {
322 match name.as_str() {
323 "non-empty" => push(Box::new(NonEmptyGate), None),
324 "json-valid" => push(Box::new(JsonValidGate), None),
325 other => match defs.iter().find(|d| d.id == other) {
326 Some(def) if def.judge.is_some() => {
327 if let Some(judge) = def.judge.as_ref() {
329 let provider_id = judge.model.split('/').next().unwrap_or_default();
330 match registry.get(provider_id) {
331 Some(provider) => push(
332 Box::new(JudgeGate::new(
333 def.id.clone(),
334 provider,
335 judge.model.clone(),
336 auth.clone(),
337 judge.threshold,
338 judge.rubric.clone().unwrap_or_default(),
339 prices.clone(),
340 )),
341 Some(def),
342 ),
343 None => tracing::warn!(
344 gate = %other, provider = %provider_id,
345 "judge gate provider not registered — skipped"
346 ),
347 }
348 }
349 }
350 Some(def) if def.consistency.is_some() => {
351 if let Some(cons) = def.consistency.as_ref() {
353 let provider_id = cons.model.split('/').next().unwrap_or_default();
354 match registry.get(provider_id) {
355 Some(provider) => push(
356 Box::new(ConsistencyGate::new(
357 def.id.clone(),
358 provider,
359 cons.model.clone(),
360 auth.clone(),
361 cons.k,
362 cons.threshold,
363 prices.clone(),
364 )),
365 Some(def),
366 ),
367 None => tracing::warn!(
368 gate = %other, provider = %provider_id,
369 "consistency gate provider not registered — skipped"
370 ),
371 }
372 }
373 }
374 Some(def) if def.schema.is_some() => {
375 if let Some(schema) = def.schema.as_ref() {
377 push(
378 Box::new(SchemaGate::new(def.id.clone(), schema.clone())),
379 Some(def),
380 );
381 }
382 }
383 Some(def) => {
384 let Some((program, args)) = def.cmd.split_first() else {
385 tracing::warn!(gate = %other, "configured gate has empty cmd — skipped");
386 continue;
387 };
388 push(
389 Box::new(SubprocessGate::new(
390 def.id.clone(),
391 program.clone(),
392 args.to_vec(),
393 Duration::from_millis(def.timeout_ms),
394 )),
395 Some(def),
396 );
397 }
398 None => tracing::warn!(
399 gate = %other,
400 "unknown gate id — not a built-in and not defined in [[gate]]; skipped"
401 ),
402 },
403 }
404 }
405 gates
406}
407
408#[must_use]
414pub fn aggregate_with_policy(
415 results: &[GateResult],
416 fail_closed_ids: &std::collections::HashSet<&str>,
417) -> Verdict {
418 let blocking = |r: &GateResult| {
419 r.verdict == Verdict::Fail
420 || (r.verdict == Verdict::Abstain && fail_closed_ids.contains(r.gate_id.as_str()))
421 };
422 if results.iter().any(blocking) {
423 Verdict::Fail
424 } else {
425 Verdict::Pass
426 }
427}
428
429#[must_use]
431pub fn aggregate(results: &[GateResult]) -> Verdict {
432 aggregate_with_policy(results, &std::collections::HashSet::new())
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438 use serde_json::{Value, json};
439
440 fn resp(text: &str) -> ModelResponse {
441 ModelResponse {
442 model: "anthropic/claude-haiku-4-5".to_owned(),
443 text: text.to_owned(),
444 in_tokens: 1,
445 out_tokens: 1,
446 raw: Value::Null,
447 }
448 }
449
450 fn req() -> ModelRequest {
451 ModelRequest {
452 model: "anthropic/claude-haiku-4-5".to_owned(),
453 system: None,
454 messages: vec![],
455 max_tokens: 16,
456 tools: Value::Null,
457 raw: Value::Null,
458 }
459 }
460
461 #[tokio::test]
462 async fn non_empty_gate() {
463 assert_eq!(
464 NonEmptyGate.evaluate(&req(), &resp("hi")).await.verdict,
465 Verdict::Pass
466 );
467 assert_eq!(
468 NonEmptyGate.evaluate(&req(), &resp(" ")).await.verdict,
469 Verdict::Fail
470 );
471 }
472
473 #[tokio::test]
474 async fn json_valid_gate() {
475 assert_eq!(
476 JsonValidGate
477 .evaluate(&req(), &resp(r#"{"ok":true}"#))
478 .await
479 .verdict,
480 Verdict::Pass
481 );
482 assert_eq!(
483 JsonValidGate.evaluate(&req(), &resp("nope")).await.verdict,
484 Verdict::Fail
485 );
486 }
487
488 #[tokio::test]
489 async fn schema_gate_type_and_required() {
490 let g = SchemaGate::new(
491 "schema",
492 json!({
493 "type": "object",
494 "required": ["name", "age"],
495 "properties": { "name": {"type": "string"}, "age": {"type": "integer"} }
496 }),
497 );
498 assert_eq!(
499 g.evaluate(&req(), &resp(r#"{"name":"a","age":3}"#))
500 .await
501 .verdict,
502 Verdict::Pass
503 );
504 assert_eq!(
506 g.evaluate(&req(), &resp(r#"{"name":"a"}"#)).await.verdict,
507 Verdict::Fail
508 );
509 assert_eq!(
511 g.evaluate(&req(), &resp(r#"{"name":"a","age":"x"}"#))
512 .await
513 .verdict,
514 Verdict::Fail
515 );
516 assert_eq!(g.evaluate(&req(), &resp("[]")).await.verdict, Verdict::Fail);
518 assert_eq!(
520 g.evaluate(&req(), &resp("plain text")).await.verdict,
521 Verdict::Fail
522 );
523 }
524
525 fn empty_registry() -> ProviderRegistry {
526 ProviderRegistry::new("http://localhost", "http://localhost")
527 }
528
529 #[test]
530 fn resolve_skips_unknown_and_keeps_known() {
531 let gates = resolve_gates(
532 &[
533 "non-empty".to_owned(),
534 "judge-diff".to_owned(),
535 "json-valid".to_owned(),
536 ],
537 &[],
538 &empty_registry(),
539 &Auth::default(),
540 &PriceTable::default(),
541 );
542 let ids: Vec<_> = gates.iter().map(|g| g.id()).collect();
543 assert_eq!(ids, ["non-empty", "json-valid"]);
544 }
545
546 #[test]
547 fn resolve_builds_configured_subprocess_gate() {
548 let defs = vec![GateDef {
550 id: "my-tests".to_owned(),
551 cmd: vec!["true".to_owned()],
552 timeout_ms: 1000,
553 judge: None,
554 consistency: None,
555 schema: None,
556 on_abstain: AbstainPolicy::FailOpen,
557 }];
558 let gates = resolve_gates(
559 &["my-tests".to_owned(), "undefined".to_owned()],
560 &defs,
561 &empty_registry(),
562 &Auth::default(),
563 &PriceTable::default(),
564 );
565 let ids: Vec<_> = gates.iter().map(|g| g.id()).collect();
566 assert_eq!(
567 ids,
568 ["my-tests"],
569 "configured id resolves; unknown id skipped"
570 );
571 }
572
573 #[tokio::test]
574 async fn configured_subprocess_gate_runs_end_to_end() {
575 let script = r#"c=$(cat); case "$c" in *BAD*) echo '{"verdict":"fail"}';; *) echo '{"verdict":"pass"}';; esac"#;
578 let defs = vec![GateDef {
579 id: "no-bad".to_owned(),
580 cmd: vec!["bash".to_owned(), "-c".to_owned(), script.to_owned()],
581 timeout_ms: 5000,
582 judge: None,
583 consistency: None,
584 schema: None,
585 on_abstain: AbstainPolicy::FailOpen,
586 }];
587 let gates = resolve_gates(
588 &["no-bad".to_owned()],
589 &defs,
590 &empty_registry(),
591 &Auth::default(),
592 &PriceTable::default(),
593 );
594 assert_eq!(gates.len(), 1);
595 let good = gates[0].evaluate(&req(), &resp("all good")).await;
596 assert_eq!(good.verdict, Verdict::Pass);
597 let bad = gates[0].evaluate(&req(), &resp("this is BAD")).await;
598 assert_eq!(bad.verdict, Verdict::Fail);
599 }
600
601 #[test]
602 fn resolve_builds_configured_judge_gate() {
603 use crate::provider::{MockProvider, Provider};
604 use std::collections::HashMap;
605 use std::sync::Arc;
606
607 let defs = vec![GateDef {
608 id: "quality".to_owned(),
609 cmd: vec![],
610 timeout_ms: 30_000,
611 judge: Some(firstpass_core::JudgeDef {
612 model: "anthropic/judge".to_owned(),
613 threshold: 0.7,
614 rubric: None,
615 }),
616 consistency: None,
617 schema: None,
618 on_abstain: AbstainPolicy::FailOpen,
619 }];
620
621 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
623 map.insert(
624 "anthropic".to_owned(),
625 Arc::new(MockProvider::new("anthropic", HashMap::new())),
626 );
627 let gates = resolve_gates(
628 &["quality".to_owned()],
629 &defs,
630 &ProviderRegistry::from_map(map),
631 &Auth::default(),
632 &PriceTable::default(),
633 );
634 assert_eq!(
635 gates.iter().map(|g| g.id()).collect::<Vec<_>>(),
636 ["quality"]
637 );
638
639 let skipped = resolve_gates(
641 &["quality".to_owned()],
642 &defs,
643 &ProviderRegistry::from_map(HashMap::new()),
644 &Auth::default(),
645 &PriceTable::default(),
646 );
647 assert!(
648 skipped.is_empty(),
649 "judge with no registered provider is skipped"
650 );
651 }
652
653 #[test]
654 fn resolve_builds_configured_consistency_gate() {
655 use crate::provider::{MockProvider, Provider};
656 use std::collections::HashMap;
657 use std::sync::Arc;
658
659 let defs = vec![GateDef {
660 id: "uncertainty".to_owned(),
661 cmd: vec![],
662 timeout_ms: 30_000,
663 judge: None,
664 consistency: Some(firstpass_core::ConsistencyDef {
665 model: "anthropic/claude-haiku-4-5".to_owned(),
666 k: 3,
667 threshold: 0.6,
668 }),
669 schema: None,
670 on_abstain: AbstainPolicy::FailOpen,
671 }];
672
673 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
675 map.insert(
676 "anthropic".to_owned(),
677 Arc::new(MockProvider::new("anthropic", HashMap::new())),
678 );
679 let gates = resolve_gates(
680 &["uncertainty".to_owned()],
681 &defs,
682 &ProviderRegistry::from_map(map),
683 &Auth::default(),
684 &PriceTable::default(),
685 );
686 assert_eq!(
687 gates.iter().map(|g| g.id()).collect::<Vec<_>>(),
688 ["uncertainty"]
689 );
690
691 let skipped = resolve_gates(
693 &["uncertainty".to_owned()],
694 &defs,
695 &ProviderRegistry::from_map(HashMap::new()),
696 &Auth::default(),
697 &PriceTable::default(),
698 );
699 assert!(
700 skipped.is_empty(),
701 "consistency gate with no registered provider is skipped"
702 );
703 }
704
705 #[test]
706 fn aggregate_semantics() {
707 let pass = GateResult::deterministic("a", Verdict::Pass, 0);
708 let fail = GateResult::deterministic("b", Verdict::Fail, 0);
709 let abstain = GateResult::abstain("c", "x", 0);
710 assert_eq!(aggregate(&[]), Verdict::Pass);
711 assert_eq!(aggregate(std::slice::from_ref(&pass)), Verdict::Pass);
712 assert_eq!(aggregate(&[pass.clone(), fail]), Verdict::Fail);
713 assert_eq!(aggregate(&[pass, abstain]), Verdict::Pass);
714 }
715
716 #[test]
717 fn error_budget_auto_disables_past_threshold() {
718 let h = GateHealth::new(10, 0.5); for _ in 0..5 {
721 h.record("g", true);
722 }
723 for _ in 0..5 {
724 h.record("g", false);
725 }
726 assert!(h.is_enabled(), "50% is at, not above, the budget");
727 for _ in 0..6 {
729 h.record("g", true);
730 }
731 assert!(
732 !h.is_enabled(),
733 "gate should auto-disable once error rate exceeds budget"
734 );
735 }
736
737 #[test]
738 fn healthy_gate_stays_enabled() {
739 let h = GateHealth::new(20, 0.5);
740 for _ in 0..100 {
741 h.record("g", false);
742 }
743 assert!(h.is_enabled());
744 }
745
746 #[test]
747 fn gate_health_registry_default_tenant_is_a_single_bucket() {
748 let registry = GateHealthRegistry::new().with_budget("g", 4, 0.5);
751 for _ in 0..4 {
752 registry.record("default", "g", true);
753 }
754 assert!(!registry.enabled("default", "g"));
755 }
756
757 #[test]
758 fn gate_health_registry_scopes_budget_per_tenant() {
759 let registry = GateHealthRegistry::new().with_budget("g", 4, 0.5);
761 for _ in 0..4 {
762 registry.record("tenant-a", "g", true);
763 }
764 assert!(!registry.enabled("tenant-a", "g"), "A should be disabled");
765 assert!(registry.enabled("tenant-b", "g"), "B must be unaffected");
766 }
767
768 #[test]
769 fn gate_health_registry_unregistered_gate_always_enabled() {
770 let registry = GateHealthRegistry::new();
771 assert!(registry.enabled("tenant-a", "unknown-gate"));
772 registry.record("tenant-a", "unknown-gate", true); assert!(registry.enabled("tenant-a", "unknown-gate"));
774 }
775
776 #[tokio::test]
777 async fn resolve_builds_configured_schema_gate() {
778 let defs = vec![GateDef {
779 id: "extract-shape".to_owned(),
780 cmd: vec![],
781 timeout_ms: 30_000,
782 judge: None,
783 consistency: None,
784 schema: Some(json!({"type": "object", "required": ["name"]})),
785 on_abstain: firstpass_core::AbstainPolicy::FailOpen,
786 }];
787 let gates = resolve_gates(
788 &["extract-shape".to_owned()],
789 &defs,
790 &empty_registry(),
791 &Auth::default(),
792 &PriceTable::default(),
793 );
794 assert_eq!(
795 gates.iter().map(|g| g.id()).collect::<Vec<_>>(),
796 ["extract-shape"],
797 "a [[gate]] def with `schema` resolves to a runnable SchemaGate"
798 );
799 let ok = gates[0].evaluate(&req(), &resp(r#"{"name":"a"}"#)).await;
800 assert_eq!(ok.verdict, Verdict::Pass);
801 let missing = gates[0].evaluate(&req(), &resp(r#"{}"#)).await;
802 assert_eq!(missing.verdict, Verdict::Fail);
803 }
804
805 #[test]
806 fn resolve_wraps_fail_closed_gate() {
807 let mk = |on_abstain| {
808 vec![GateDef {
809 id: "tests".to_owned(),
810 cmd: vec!["true".to_owned()],
811 timeout_ms: 1000,
812 judge: None,
813 consistency: None,
814 schema: None,
815 on_abstain,
816 }]
817 };
818 let open = resolve_gates(
819 &["tests".to_owned()],
820 &mk(firstpass_core::AbstainPolicy::FailOpen),
821 &empty_registry(),
822 &Auth::default(),
823 &PriceTable::default(),
824 );
825 assert!(!open[0].abstain_fails_closed(), "default stays fail-open");
826 let closed = resolve_gates(
827 &["tests".to_owned()],
828 &mk(firstpass_core::AbstainPolicy::FailClosed),
829 &empty_registry(),
830 &Auth::default(),
831 &PriceTable::default(),
832 );
833 assert!(
834 closed[0].abstain_fails_closed(),
835 "on_abstain = fail_closed wraps the gate"
836 );
837 assert_eq!(closed[0].id(), "tests", "wrapper preserves the id");
838 }
839
840 #[test]
841 fn aggregate_with_policy_fail_closed_abstain_blocks() {
842 use std::collections::HashSet;
843 let pass = GateResult::deterministic("a", Verdict::Pass, 0);
844 let abstain = GateResult::abstain("c", "timeout", 0);
845 assert_eq!(
847 aggregate_with_policy(&[pass.clone(), abstain.clone()], &HashSet::new()),
848 Verdict::Pass
849 );
850 let closed: HashSet<&str> = ["c"].into_iter().collect();
852 assert_eq!(
853 aggregate_with_policy(&[pass, abstain], &closed),
854 Verdict::Fail
855 );
856 }
857}