1use crate::ast::{Spec, SpecExpr, SpecOp};
23use indexmap::IndexMap;
24use lex_bytecode::{compile_program, vm::Vm, Value};
25use lex_runtime::{DefaultHandler, Policy};
26use lex_syntax::parse_source;
27use serde::{Deserialize, Serialize};
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31#[serde(tag = "verdict", rename_all = "snake_case")]
32pub enum GateVerdict {
33 Allow,
34 Deny { spec_name: String, reason: String },
39 Inconclusive { spec_name: String, reason: String },
44}
45
46pub fn evaluate_gate(
55 specs: &[Spec],
56 bindings: &IndexMap<String, Value>,
57 lex_source: &str,
58) -> GateVerdict {
59 let prog = match parse_source(lex_source) {
60 Ok(p) => p,
61 Err(e) => return GateVerdict::Inconclusive {
62 spec_name: "<parse>".into(),
63 reason: format!("parse: {e}"),
64 },
65 };
66 let stages = lex_ast::canonicalize_program(&prog);
67 if let Err(errs) = lex_types::check_program(&stages) {
68 return GateVerdict::Inconclusive {
69 spec_name: "<typecheck>".into(),
70 reason: format!("typecheck: {errs:?}"),
71 };
72 }
73 let bc = compile_program(&stages);
74 evaluate_gate_compiled(specs, bindings, &bc)
75}
76
77pub fn evaluate_gate_compiled(
81 specs: &[Spec],
82 bindings: &IndexMap<String, Value>,
83 bc: &lex_bytecode::Program,
84) -> GateVerdict {
85 evaluate_gate_compiled_inner(specs, bindings, bc, None)
86}
87
88pub fn evaluate_gate_compiled_traced<F>(
103 specs: &[Spec],
104 bindings: &IndexMap<String, Value>,
105 bc: &lex_bytecode::Program,
106 new_tracer: F,
107) -> GateVerdict
108where
109 F: Fn() -> Box<dyn lex_bytecode::vm::Tracer>,
110{
111 evaluate_gate_compiled_inner(specs, bindings, bc, Some(&new_tracer))
112}
113
114fn evaluate_gate_compiled_inner(
115 specs: &[Spec],
116 bindings: &IndexMap<String, Value>,
117 bc: &lex_bytecode::Program,
118 new_tracer: Option<&dyn Fn() -> Box<dyn lex_bytecode::vm::Tracer>>,
119) -> GateVerdict {
120 let policy = Policy::permissive();
121 for spec in specs {
122 match eval_body(&spec.body, bindings, bc, &policy, new_tracer) {
123 Ok(Value::Bool(true)) => continue,
124 Ok(Value::Bool(false)) => {
125 return GateVerdict::Deny {
126 spec_name: spec.name.clone(),
127 reason: format!(
128 "spec `{}` returned false; bindings: {}",
129 spec.name,
130 format_bindings(bindings),
131 ),
132 };
133 }
134 Ok(other) => return GateVerdict::Inconclusive {
135 spec_name: spec.name.clone(),
136 reason: format!("spec body returned non-bool: {other:?}"),
137 },
138 Err(e) => return GateVerdict::Inconclusive {
139 spec_name: spec.name.clone(),
140 reason: e,
141 },
142 }
143 }
144 GateVerdict::Allow
145}
146
147fn format_bindings(b: &IndexMap<String, Value>) -> String {
148 let mut parts: Vec<String> = Vec::with_capacity(b.len());
149 for (k, v) in b {
150 parts.push(format!("{k}={}", short_value(v)));
151 }
152 parts.join(", ")
153}
154
155fn short_value(v: &Value) -> String {
156 match v {
157 Value::Int(i) => format!("{i}"),
158 Value::Float(f) => format!("{f}"),
159 Value::Bool(b) => format!("{b}"),
160 Value::Str(s) => format!("\"{}\"", s.chars().take(40).collect::<String>()),
161 other => format!("{other:?}"),
162 }
163}
164
165fn eval_body(
176 e: &SpecExpr,
177 bindings: &IndexMap<String, Value>,
178 bc: &lex_bytecode::Program,
179 policy: &Policy,
180 new_tracer: Option<&dyn Fn() -> Box<dyn lex_bytecode::vm::Tracer>>,
181) -> Result<Value, String> {
182 match e {
183 SpecExpr::IntLit { value } => Ok(Value::Int(*value)),
184 SpecExpr::FloatLit { value } => Ok(Value::Float(*value)),
185 SpecExpr::BoolLit { value } => Ok(Value::Bool(*value)),
186 SpecExpr::StrLit { value } => Ok(Value::Str(value.clone().into())),
187 SpecExpr::Var { name } => bindings.get(name).cloned()
188 .ok_or_else(|| format!("unbound spec var `{name}` (provide via gate bindings)")),
189 SpecExpr::Let { name, value, body } => {
190 let v = eval_body(value, bindings, bc, policy, new_tracer)?;
191 let mut next = bindings.clone();
192 next.insert(name.clone(), v);
193 eval_body(body, &next, bc, policy, new_tracer)
194 }
195 SpecExpr::Not { expr } => match eval_body(expr, bindings, bc, policy, new_tracer)? {
196 Value::Bool(b) => Ok(Value::Bool(!b)),
197 other => Err(format!("not on non-bool: {other:?}")),
198 },
199 SpecExpr::BinOp { op, lhs, rhs } => {
200 if matches!(op, SpecOp::And | SpecOp::Or) {
208 let a = eval_body(lhs, bindings, bc, policy, new_tracer)?;
209 let av = match a {
210 Value::Bool(b) => b,
211 other => return Err(format!(
212 "{} on non-bool lhs: {other:?}", op.as_str())),
213 };
214 if matches!(op, SpecOp::And) && !av { return Ok(Value::Bool(false)); }
215 if matches!(op, SpecOp::Or) && av { return Ok(Value::Bool(true)); }
216 let b = eval_body(rhs, bindings, bc, policy, new_tracer)?;
217 return match b {
218 Value::Bool(bb) => Ok(Value::Bool(bb)),
219 other => Err(format!("{} on non-bool rhs: {other:?}", op.as_str())),
220 };
221 }
222 let a = eval_body(lhs, bindings, bc, policy, new_tracer)?;
223 let b = eval_body(rhs, bindings, bc, policy, new_tracer)?;
224 apply_binop(*op, a, b)
225 }
226 SpecExpr::Call { func, args } => {
227 let mut argv = Vec::new();
228 for a in args { argv.push(eval_body(a, bindings, bc, policy, new_tracer)?); }
229 if let Some(v) = list_builtin(func, &argv) { return v; }
238 let handler = DefaultHandler::new(policy.clone());
239 let mut vm = Vm::with_handler(bc, Box::new(handler));
240 if let Some(make_tracer) = new_tracer {
241 vm.set_tracer(make_tracer());
242 }
243 vm.call(func, argv).map_err(|e| format!("call `{func}`: {e}"))
244 }
245 SpecExpr::Index { list, index } => {
246 let xs = eval_body(list, bindings, bc, policy, new_tracer)?;
247 let i = eval_body(index, bindings, bc, policy, new_tracer)?;
248 list_index(xs, i)
249 }
250 SpecExpr::Match { scrutinee, arms } => {
251 let v = eval_body(scrutinee, bindings, bc, policy, new_tracer)?;
255 for arm in arms {
256 if let Some(extra) = pattern_match(&arm.pattern, &v) {
257 let mut next = bindings.clone();
258 for (k, vv) in extra { next.insert(k, vv); }
259 return eval_body(&arm.body, &next, bc, policy, new_tracer);
260 }
261 }
262 Err(format!(
263 "non-exhaustive match: no arm matched value {}",
264 short_value(&v)))
265 }
266 SpecExpr::FieldAccess { value, field } => {
267 let v = eval_body(value, bindings, bc, policy, new_tracer)?;
272 match v {
273 Value::Record { fields, .. } => fields.get(field.as_str()).cloned().ok_or_else(|| {
274 let known: Vec<&str> = fields.keys().map(|k| k.as_str()).collect();
275 format!("field `{field}` missing on record (have: {})", known.join(", "))
276 }),
277 other => Err(format!(
278 "field access `.{field}` on non-record: {}",
279 short_value(&other))),
280 }
281 }
282 }
283}
284
285pub(crate) fn pattern_match(pat: &crate::ast::SpecPattern, v: &Value)
290 -> Option<Vec<(String, Value)>>
291{
292 use crate::ast::SpecPattern;
293 match pat {
294 SpecPattern::Wildcard => Some(Vec::new()),
295 SpecPattern::Variant { name, bindings } => {
296 match v {
297 Value::Variant { name: vn, args } if vn == name && args.len() == bindings.len() => {
298 Some(bindings.iter().cloned()
299 .zip(args.iter().cloned())
300 .collect())
301 }
302 _ => None,
303 }
304 }
305 }
306}
307
308pub(crate) fn list_builtin(func: &str, args: &[Value]) -> Option<Result<Value, String>> {
313 match func {
314 "length" => {
315 if args.len() != 1 {
316 return Some(Err(format!("length: expected 1 arg, got {}", args.len())));
317 }
318 match &args[0] {
319 Value::List(xs) => Some(Ok(Value::Int(xs.len() as i64))),
320 _ => None,
323 }
324 }
325 "head" => {
326 if args.len() != 1 { return None; }
327 match &args[0] {
328 Value::List(xs) => Some(match xs.front() {
329 Some(v) => Ok(v.clone()),
330 None => Err("head: empty list".into()),
331 }),
332 _ => None,
333 }
334 }
335 "tail" => {
336 if args.len() != 1 { return None; }
337 match &args[0] {
338 Value::List(xs) => Some(if xs.is_empty() {
339 Err("tail: empty list".into())
340 } else {
341 Ok(Value::List(xs.iter().skip(1).cloned().collect::<std::collections::VecDeque<_>>().into()))
342 }),
343 _ => None,
344 }
345 }
346 _ => None,
347 }
348}
349
350fn list_index(list: Value, index: Value) -> Result<Value, String> {
351 let xs = match list {
352 Value::List(xs) => xs,
353 other => return Err(format!("index `[..]` on non-list: {}", short_value(&other))),
354 };
355 let i = match index {
356 Value::Int(n) => n,
357 other => return Err(format!("list index must be Int, got {}", short_value(&other))),
358 };
359 if i < 0 || (i as usize) >= xs.len() {
360 return Err(format!(
361 "list index {i} out of bounds (length {})", xs.len()));
362 }
363 Ok(xs[i as usize].clone())
364}
365
366fn apply_binop(op: SpecOp, a: Value, b: Value) -> Result<Value, String> {
367 use SpecOp::*;
368 match (op, &a, &b) {
369 (Add, Value::Int(x), Value::Int(y)) => Ok(Value::Int(x + y)),
370 (Sub, Value::Int(x), Value::Int(y)) => Ok(Value::Int(x - y)),
371 (Mul, Value::Int(x), Value::Int(y)) => Ok(Value::Int(x * y)),
372 (Div, Value::Int(x), Value::Int(y)) if *y != 0 => Ok(Value::Int(x / y)),
373 (Mod, Value::Int(x), Value::Int(y)) if *y != 0 => Ok(Value::Int(x % y)),
374 (Add, Value::Float(x), Value::Float(y)) => Ok(Value::Float(x + y)),
375 (Sub, Value::Float(x), Value::Float(y)) => Ok(Value::Float(x - y)),
376 (Mul, Value::Float(x), Value::Float(y)) => Ok(Value::Float(x * y)),
377 (Div, Value::Float(x), Value::Float(y)) => Ok(Value::Float(x / y)),
378 (Eq, x, y) => Ok(Value::Bool(x == y)),
379 (Neq, x, y) => Ok(Value::Bool(x != y)),
380 (Lt, Value::Int(x), Value::Int(y)) => Ok(Value::Bool(x < y)),
381 (Le, Value::Int(x), Value::Int(y)) => Ok(Value::Bool(x <= y)),
382 (Gt, Value::Int(x), Value::Int(y)) => Ok(Value::Bool(x > y)),
383 (Ge, Value::Int(x), Value::Int(y)) => Ok(Value::Bool(x >= y)),
384 (Lt, Value::Float(x), Value::Float(y)) => Ok(Value::Bool(x < y)),
385 (Le, Value::Float(x), Value::Float(y)) => Ok(Value::Bool(x <= y)),
386 (Gt, Value::Float(x), Value::Float(y)) => Ok(Value::Bool(x > y)),
387 (Ge, Value::Float(x), Value::Float(y)) => Ok(Value::Bool(x >= y)),
388 (And, Value::Bool(x), Value::Bool(y)) => Ok(Value::Bool(*x && *y)),
389 (Or, Value::Bool(x), Value::Bool(y)) => Ok(Value::Bool(*x || *y)),
390 _ => Err(format!("invalid binop {op:?} on {a:?}, {b:?}")),
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use crate::parser::parse_spec;
398
399 fn b<I: IntoIterator<Item = (&'static str, Value)>>(items: I) -> IndexMap<String, Value> {
400 items.into_iter().map(|(k, v)| (k.to_string(), v)).collect()
401 }
402
403 #[test]
404 fn allow_when_spec_returns_true() {
405 let spec = parse_spec("spec ok { forall x :: Int : x + 1 > x }").unwrap();
406 let v = evaluate_gate(&[spec], &b([("x", Value::Int(5))]), "");
407 assert_eq!(v, GateVerdict::Allow);
408 }
409
410 #[test]
411 fn deny_when_spec_returns_false() {
412 let spec = parse_spec("spec budget { forall used :: Int, delta :: Int : (used + delta) <= 100 }").unwrap();
413 let v = evaluate_gate(
414 &[spec],
415 &b([("used", Value::Int(80)), ("delta", Value::Int(30))]),
416 "",
417 );
418 match v {
419 GateVerdict::Deny { spec_name, reason } => {
420 assert_eq!(spec_name, "budget");
421 assert!(reason.contains("used=80"), "reason should include bindings: {reason}");
422 }
423 other => panic!("expected Deny, got {other:?}"),
424 }
425 }
426
427 #[test]
428 fn first_failing_spec_is_reported() {
429 let s1 = parse_spec("spec always { forall x :: Int : x == x }").unwrap();
432 let s2 = parse_spec("spec never { forall x :: Int : x != x }").unwrap();
433 let v = evaluate_gate(&[s1, s2], &b([("x", Value::Int(0))]), "");
434 match v {
435 GateVerdict::Deny { spec_name, .. } => assert_eq!(spec_name, "never"),
436 other => panic!("expected Deny on `never`, got {other:?}"),
437 }
438 }
439
440 #[test]
441 fn missing_binding_is_inconclusive_not_panic() {
442 let spec = parse_spec("spec needs_x { forall x :: Int : x > 0 }").unwrap();
446 let v = evaluate_gate(&[spec], &b([]), "");
447 match v {
448 GateVerdict::Inconclusive { reason, .. } => {
449 assert!(reason.contains("unbound spec var"),
450 "expected unbound-var error, got: {reason}");
451 }
452 other => panic!("expected Inconclusive, got {other:?}"),
453 }
454 }
455
456 #[test]
457 fn grid_budget_phase1_spec() {
458 let spec = parse_spec(r#"
461 spec grid_budget {
462 forall active :: Int, scheduled :: Int, delta :: Int, budget :: Int :
463 (active + scheduled + delta) <= budget
464 }
465 "#).unwrap();
466 let allow = evaluate_gate(std::slice::from_ref(&spec), &b([
467 ("active", Value::Int(40)),
468 ("scheduled", Value::Int(20)),
469 ("delta", Value::Int(15)),
470 ("budget", Value::Int(100)),
471 ]), "");
472 assert_eq!(allow, GateVerdict::Allow);
473 let deny = evaluate_gate(&[spec], &b([
474 ("active", Value::Int(40)),
475 ("scheduled", Value::Int(20)),
476 ("delta", Value::Int(60)),
477 ("budget", Value::Int(100)),
478 ]), "");
479 assert!(matches!(deny, GateVerdict::Deny { .. }));
480 }
481
482 #[test]
483 fn soc_reserve_phase1_spec() {
484 let spec = parse_spec(r#"
487 spec soc_reserve {
488 forall soc :: Int, draw :: Int, reserve :: Int :
489 (soc - draw) >= reserve
490 }
491 "#).unwrap();
492 let allow = evaluate_gate(std::slice::from_ref(&spec), &b([
493 ("soc", Value::Int(80)),
494 ("draw", Value::Int(20)),
495 ("reserve", Value::Int(40)),
496 ]), "");
497 assert_eq!(allow, GateVerdict::Allow);
498 let deny = evaluate_gate(&[spec], &b([
499 ("soc", Value::Int(50)),
500 ("draw", Value::Int(20)),
501 ("reserve", Value::Int(40)),
502 ]), "");
503 assert!(matches!(deny, GateVerdict::Deny { .. }));
504 }
505
506 #[test]
507 fn gate_is_fast_enough_for_synchronous_use() {
508 let s1 = parse_spec(r#"
513 spec grid_budget {
514 forall active :: Int, scheduled :: Int, delta :: Int, budget :: Int :
515 (active + scheduled + delta) <= budget
516 }
517 "#).unwrap();
518 let s2 = parse_spec(r#"
519 spec soc_reserve {
520 forall soc :: Int, draw :: Int, reserve :: Int :
521 (soc - draw) >= reserve
522 }
523 "#).unwrap();
524 let bindings = b([
525 ("active", Value::Int(40)),
526 ("scheduled", Value::Int(20)),
527 ("delta", Value::Int(15)),
528 ("budget", Value::Int(100)),
529 ("soc", Value::Int(80)),
530 ("draw", Value::Int(20)),
531 ("reserve", Value::Int(40)),
532 ]);
533 let prog = parse_source("").unwrap();
534 let stages = lex_ast::canonicalize_program(&prog);
535 let bc = compile_program(&stages);
536
537 let n = 1000;
538 let start = std::time::Instant::now();
539 for _ in 0..n {
540 let v = evaluate_gate_compiled(&[s1.clone(), s2.clone()], &bindings, &bc);
541 assert_eq!(v, GateVerdict::Allow);
542 }
543 let elapsed = start.elapsed();
544 let per_call_us = elapsed.as_micros() / n as u128;
545 assert!(per_call_us < 5_000,
546 "per-gate verdict should be under 5ms; got {per_call_us}μs");
547 }
548
549 struct CallRecorder {
555 captured: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
556 }
557 impl lex_bytecode::vm::Tracer for CallRecorder {
558 fn enter_call(&mut self, _node_id: &str, name: &str, _args: &[Value]) {
559 self.captured.lock().unwrap().push(name.to_string());
560 }
561 fn enter_effect(&mut self, _: &str, _: &str, _: &str, _: &[Value]) {}
562 fn exit_ok(&mut self, _: &Value) {}
563 fn exit_err(&mut self, _: &str) {}
564 fn exit_call_tail(&mut self) {}
565 fn override_effect(&mut self, _: &str) -> Option<Value> { None }
566 }
567
568 #[test]
569 fn traced_gate_captures_nested_call_events() {
570 let host_src = r#"
575 fn projected_load(active :: Int, delta :: Int) -> Int {
576 active + delta
577 }
578 fn budget_total(budget :: Int, headroom :: Int) -> Int {
579 budget + headroom
580 }
581 fn under_budget(active :: Int, delta :: Int, budget :: Int, headroom :: Int) -> Bool {
582 projected_load(active, delta) <= budget_total(budget, headroom)
583 }
584 "#;
585 let prog = parse_source(host_src).unwrap();
586 let stages = lex_ast::canonicalize_program(&prog);
587 let bc = compile_program(&stages);
588
589 let spec = parse_spec(r#"
590 spec gated_budget {
591 forall active :: Int, delta :: Int, budget :: Int, headroom :: Int :
592 under_budget(active, delta, budget, headroom)
593 }
594 "#).unwrap();
595 let bindings = b([
596 ("active", Value::Int(40)),
597 ("delta", Value::Int(15)),
598 ("budget", Value::Int(60)),
599 ("headroom", Value::Int(0)),
600 ]);
601
602 let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
603 let captured_for_factory = std::sync::Arc::clone(&captured);
604 let v = evaluate_gate_compiled_traced(
605 std::slice::from_ref(&spec),
606 &bindings,
607 &bc,
608 move || Box::new(CallRecorder {
609 captured: std::sync::Arc::clone(&captured_for_factory),
610 }),
611 );
612 assert_eq!(v, GateVerdict::Allow);
613
614 let calls = captured.lock().unwrap();
615 for expected in ["projected_load", "budget_total"] {
622 assert!(calls.iter().any(|c| c == expected),
623 "expected `{expected}` in captured calls; got {:?}", *calls);
624 }
625 }
626
627 #[test]
628 fn untraced_gate_path_unchanged() {
629 let spec = parse_spec("spec ok { forall x :: Int : x + 1 > x }").unwrap();
634 let v = evaluate_gate_compiled(
635 std::slice::from_ref(&spec),
636 &b([("x", Value::Int(5))]),
637 &compile_program(&lex_ast::canonicalize_program(&parse_source("").unwrap())),
638 );
639 assert_eq!(v, GateVerdict::Allow);
640 }
641
642 fn rec(fields: &[(&str, Value)]) -> Value {
646 let mut m = indexmap::IndexMap::new();
647 for (k, v) in fields {
648 m.insert((*k).into(), v.clone());
649 }
650 Value::record_dynamic(m)
651 }
652
653 #[test]
654 fn record_quantifier_type_parses() {
655 let spec = parse_spec(r#"
660 spec session_ok {
661 forall s :: { used :: Int, ceiling :: Int } : true
662 }
663 "#).unwrap();
664 let v = evaluate_gate(&[spec], &b([
665 ("s", rec(&[("used", Value::Int(0)), ("ceiling", Value::Int(100))])),
666 ]), "");
667 assert_eq!(v, GateVerdict::Allow);
668 }
669
670 #[test]
671 fn field_access_drills_into_record_value() {
672 let spec = parse_spec(r#"
676 spec budget_ok {
677 forall s :: { used :: Int, ceiling :: Int } :
678 s.used <= s.ceiling
679 }
680 "#).unwrap();
681 let allow = evaluate_gate(std::slice::from_ref(&spec), &b([
682 ("s", rec(&[("used", Value::Int(40)), ("ceiling", Value::Int(100))])),
683 ]), "");
684 assert_eq!(allow, GateVerdict::Allow);
685 let deny = evaluate_gate(&[spec], &b([
686 ("s", rec(&[("used", Value::Int(120)), ("ceiling", Value::Int(100))])),
687 ]), "");
688 assert!(matches!(deny, GateVerdict::Deny { .. }));
689 }
690
691 #[test]
692 fn nested_record_field_access_works() {
693 let spec = parse_spec(r#"
698 spec station_ok {
699 forall s :: { charge :: { power_drawn :: Int, budget :: Int } } :
700 s.charge.power_drawn <= s.charge.budget
701 }
702 "#).unwrap();
703 let allow = evaluate_gate(std::slice::from_ref(&spec), &b([
704 ("s", rec(&[("charge", rec(&[
705 ("power_drawn", Value::Int(50)),
706 ("budget", Value::Int(80)),
707 ]))])),
708 ]), "");
709 assert_eq!(allow, GateVerdict::Allow);
710 }
711
712 #[test]
713 fn missing_field_is_inconclusive_not_panic() {
714 let spec = parse_spec(r#"
718 spec needs_budget {
719 forall s :: { used :: Int, budget :: Int } : s.used <= s.budget
720 }
721 "#).unwrap();
722 let v = evaluate_gate(&[spec], &b([
723 ("s", rec(&[("used", Value::Int(40))])),
724 ]), "");
725 match v {
726 GateVerdict::Inconclusive { reason, .. } => {
727 assert!(reason.contains("field `budget`"),
728 "reason should name the missing field; got: {reason}");
729 }
730 other => panic!("expected Inconclusive, got {other:?}"),
731 }
732 }
733
734 #[test]
735 fn field_access_on_non_record_is_inconclusive() {
736 let spec = parse_spec(r#"
738 spec wrong_shape {
739 forall x :: Int : x.used > 0
740 }
741 "#).unwrap();
742 let v = evaluate_gate(&[spec], &b([("x", Value::Int(40))]), "");
743 match v {
744 GateVerdict::Inconclusive { reason, .. } => {
745 assert!(reason.contains("non-record"),
746 "reason should call out non-record; got: {reason}");
747 }
748 other => panic!("expected Inconclusive, got {other:?}"),
749 }
750 }
751
752 fn lst(items: &[Value]) -> Value {
756 Value::List(items.iter().cloned().collect())
757 }
758
759 #[test]
760 fn list_quantifier_type_parses() {
761 let spec = parse_spec(r#"
762 spec ok {
763 forall xs :: List[Int] : true
764 }
765 "#).unwrap();
766 let v = evaluate_gate(&[spec], &b([
767 ("xs", lst(&[Value::Int(1), Value::Int(2)])),
768 ]), "");
769 assert_eq!(v, GateVerdict::Allow);
770 }
771
772 #[test]
773 fn length_builtin_returns_list_length() {
774 let spec = parse_spec(r#"
775 spec at_least_one {
776 forall xs :: List[Int] : length(xs) > 0
777 }
778 "#).unwrap();
779 let allow = evaluate_gate(std::slice::from_ref(&spec), &b([
780 ("xs", lst(&[Value::Int(7)])),
781 ]), "");
782 assert_eq!(allow, GateVerdict::Allow);
783 let deny = evaluate_gate(&[spec], &b([
784 ("xs", lst(&[])),
785 ]), "");
786 assert!(matches!(deny, GateVerdict::Deny { .. }));
787 }
788
789 #[test]
790 fn indexed_access_reads_list_element() {
791 let spec = parse_spec(r#"
792 spec head_positive {
793 forall xs :: List[Int] : xs[0] > 0
794 }
795 "#).unwrap();
796 let allow = evaluate_gate(std::slice::from_ref(&spec), &b([
797 ("xs", lst(&[Value::Int(5), Value::Int(10)])),
798 ]), "");
799 assert_eq!(allow, GateVerdict::Allow);
800 let deny = evaluate_gate(&[spec], &b([
801 ("xs", lst(&[Value::Int(0), Value::Int(10)])),
802 ]), "");
803 assert!(matches!(deny, GateVerdict::Deny { .. }));
804 }
805
806 #[test]
807 fn head_and_tail_builtins_work() {
808 let spec = parse_spec(r#"
811 spec shape {
812 forall xs :: List[Int] :
813 length(xs) > 0 and head(xs) >= length(tail(xs))
814 }
815 "#).unwrap();
816 let allow = evaluate_gate(std::slice::from_ref(&spec), &b([
818 ("xs", lst(&[Value::Int(3), Value::Int(1), Value::Int(2)])),
819 ]), "");
820 assert_eq!(allow, GateVerdict::Allow);
821 let deny = evaluate_gate(&[spec], &b([
823 ("xs", lst(&[Value::Int(1), Value::Int(1),
824 Value::Int(2), Value::Int(3)])),
825 ]), "");
826 assert!(matches!(deny, GateVerdict::Deny { .. }));
827 }
828
829 #[test]
830 fn list_of_records_lets_specs_quantify_structured_collections() {
831 let spec = parse_spec(r#"
838 spec first_session_within_budget {
839 forall sessions :: List[{ power :: Int, budget :: Int }] :
840 length(sessions) == 0 or sessions[0].power <= sessions[0].budget
841 }
842 "#).unwrap();
843 let mut session = indexmap::IndexMap::new();
844 session.insert("power".into(), Value::Int(50));
845 session.insert("budget".into(), Value::Int(80));
846 let allow = evaluate_gate(std::slice::from_ref(&spec), &b([
847 ("sessions", Value::List(vec![Value::record_dynamic(session.clone())].into())),
848 ]), "");
849 assert_eq!(allow, GateVerdict::Allow);
850
851 let mut over = indexmap::IndexMap::new();
852 over.insert("power".into(), Value::Int(120));
853 over.insert("budget".into(), Value::Int(80));
854 let deny = evaluate_gate(&[spec], &b([
855 ("sessions", Value::List(vec![Value::record_dynamic(over)].into())),
856 ]), "");
857 assert!(matches!(deny, GateVerdict::Deny { .. }));
858 }
859
860 #[test]
861 fn empty_list_passes_when_predicate_is_vacuous() {
862 let spec = parse_spec(r#"
865 spec ok_or_empty {
866 forall xs :: List[Int] : length(xs) == 0 or xs[0] > 0
867 }
868 "#).unwrap();
869 let v = evaluate_gate(&[spec], &b([("xs", lst(&[]))]), "");
870 assert_eq!(v, GateVerdict::Allow);
871 }
872
873 #[test]
874 fn out_of_bounds_index_is_inconclusive() {
875 let spec = parse_spec(r#"
876 spec needs_two {
877 forall xs :: List[Int] : xs[1] > 0
878 }
879 "#).unwrap();
880 let v = evaluate_gate(&[spec], &b([
881 ("xs", lst(&[Value::Int(5)])), ]), "");
883 match v {
884 GateVerdict::Inconclusive { reason, .. } => {
885 assert!(reason.contains("out of bounds"),
886 "expected OOB diagnostic; got: {reason}");
887 }
888 other => panic!("expected Inconclusive, got {other:?}"),
889 }
890 }
891
892 #[test]
893 fn head_of_empty_list_is_inconclusive() {
894 let spec = parse_spec(r#"
895 spec head_pos {
896 forall xs :: List[Int] : head(xs) > 0
897 }
898 "#).unwrap();
899 let v = evaluate_gate(&[spec], &b([("xs", lst(&[]))]), "");
900 match v {
901 GateVerdict::Inconclusive { reason, .. } => {
902 assert!(reason.contains("empty list"),
903 "expected empty-list diagnostic; got: {reason}");
904 }
905 other => panic!("expected Inconclusive, got {other:?}"),
906 }
907 }
908
909 fn variant(name: &str, args: Vec<Value>) -> Value {
913 Value::Variant { name: name.into(), args }
914 }
915
916 #[test]
917 fn named_type_in_quantifier_parses() {
918 let spec = parse_spec(r#"
919 spec ok {
920 forall msg :: Message : true
921 }
922 "#).unwrap();
923 let v = evaluate_gate(&[spec], &b([
924 ("msg", variant("Heartbeat", vec![])),
925 ]), "");
926 assert_eq!(v, GateVerdict::Allow);
927 }
928
929 #[test]
930 fn match_dispatches_on_variant_name() {
931 let spec = parse_spec(r#"
934 spec valid_msg {
935 forall msg :: Message :
936 match msg {
937 Charge(amount) => amount > 0,
938 Telemetry(payload) => true,
939 _ => false,
940 }
941 }
942 "#).unwrap();
943 let allow_charge = evaluate_gate(std::slice::from_ref(&spec), &b([
944 ("msg", variant("Charge", vec![Value::Int(50)])),
945 ]), "");
946 assert_eq!(allow_charge, GateVerdict::Allow);
947
948 let deny_negative = evaluate_gate(std::slice::from_ref(&spec), &b([
949 ("msg", variant("Charge", vec![Value::Int(-1)])),
950 ]), "");
951 assert!(matches!(deny_negative, GateVerdict::Deny { .. }));
952
953 let allow_telemetry = evaluate_gate(std::slice::from_ref(&spec), &b([
954 ("msg", variant("Telemetry", vec![Value::Str("ok".into())])),
955 ]), "");
956 assert_eq!(allow_telemetry, GateVerdict::Allow);
957
958 let deny_unknown = evaluate_gate(&[spec], &b([
959 ("msg", variant("UnknownTopic", vec![])),
960 ]), "");
961 assert!(matches!(deny_unknown, GateVerdict::Deny { .. }));
962 }
963
964 #[test]
965 fn variant_pattern_binds_positional_args() {
966 let spec = parse_spec(r#"
969 spec budget_match {
970 forall msg :: Message :
971 match msg {
972 Charge(amount, budget) => amount <= budget,
973 _ => true,
974 }
975 }
976 "#).unwrap();
977 let allow = evaluate_gate(std::slice::from_ref(&spec), &b([
978 ("msg", variant("Charge", vec![Value::Int(50), Value::Int(100)])),
979 ]), "");
980 assert_eq!(allow, GateVerdict::Allow);
981 let deny = evaluate_gate(&[spec], &b([
982 ("msg", variant("Charge", vec![Value::Int(150), Value::Int(100)])),
983 ]), "");
984 assert!(matches!(deny, GateVerdict::Deny { .. }));
985 }
986
987 #[test]
988 fn variant_arity_mismatch_falls_through_to_next_arm() {
989 let spec = parse_spec(r#"
992 spec arity_check {
993 forall msg :: Message :
994 match msg {
995 Charge(x) => x > 0,
996 _ => true,
997 }
998 }
999 "#).unwrap();
1000 let v = evaluate_gate(&[spec], &b([
1001 ("msg", variant("Charge", vec![Value::Int(1), Value::Int(2)])),
1002 ]), "");
1003 assert_eq!(v, GateVerdict::Allow);
1004 }
1005
1006 #[test]
1007 fn non_exhaustive_match_is_inconclusive() {
1008 let spec = parse_spec(r#"
1010 spec only_charge {
1011 forall msg :: Message :
1012 match msg {
1013 Charge(_) => true,
1014 }
1015 }
1016 "#).unwrap();
1017 let v = evaluate_gate(&[spec], &b([
1018 ("msg", variant("OtherTopic", vec![])),
1019 ]), "");
1020 match v {
1021 GateVerdict::Inconclusive { reason, .. } => {
1022 assert!(reason.contains("non-exhaustive"),
1023 "expected non-exhaustive diagnostic; got: {reason}");
1024 }
1025 other => panic!("expected Inconclusive, got {other:?}"),
1026 }
1027 }
1028
1029 #[test]
1030 fn nested_match_drills_into_variant_payload_record() {
1031 let spec = parse_spec(r#"
1036 spec station_match {
1037 forall msg :: Message :
1038 match msg {
1039 Charge(s) => s.power <= s.budget,
1040 _ => true,
1041 }
1042 }
1043 "#).unwrap();
1044 let mut session = indexmap::IndexMap::new();
1045 session.insert("power".into(), Value::Int(60));
1046 session.insert("budget".into(), Value::Int(100));
1047 let allow = evaluate_gate(std::slice::from_ref(&spec), &b([
1048 ("msg", variant("Charge", vec![Value::record_dynamic(session)])),
1049 ]), "");
1050 assert_eq!(allow, GateVerdict::Allow);
1051
1052 let mut over = indexmap::IndexMap::new();
1053 over.insert("power".into(), Value::Int(160));
1054 over.insert("budget".into(), Value::Int(100));
1055 let deny = evaluate_gate(&[spec], &b([
1056 ("msg", variant("Charge", vec![Value::record_dynamic(over)])),
1057 ]), "");
1058 assert!(matches!(deny, GateVerdict::Deny { .. }));
1059 }
1060}