use crate::ast::{Spec, SpecExpr, SpecOp};
use indexmap::IndexMap;
use lex_bytecode::{compile_program, vm::Vm, Value};
use lex_runtime::{DefaultHandler, Policy};
use lex_syntax::parse_source;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "verdict", rename_all = "snake_case")]
pub enum GateVerdict {
Allow,
Deny { spec_name: String, reason: String },
Inconclusive { spec_name: String, reason: String },
}
pub fn evaluate_gate(
specs: &[Spec],
bindings: &IndexMap<String, Value>,
lex_source: &str,
) -> GateVerdict {
let prog = match parse_source(lex_source) {
Ok(p) => p,
Err(e) => return GateVerdict::Inconclusive {
spec_name: "<parse>".into(),
reason: format!("parse: {e}"),
},
};
let stages = lex_ast::canonicalize_program(&prog);
if let Err(errs) = lex_types::check_program(&stages) {
return GateVerdict::Inconclusive {
spec_name: "<typecheck>".into(),
reason: format!("typecheck: {errs:?}"),
};
}
let bc = compile_program(&stages);
evaluate_gate_compiled(specs, bindings, &bc)
}
pub fn evaluate_gate_compiled(
specs: &[Spec],
bindings: &IndexMap<String, Value>,
bc: &lex_bytecode::Program,
) -> GateVerdict {
evaluate_gate_compiled_inner(specs, bindings, bc, None)
}
pub fn evaluate_gate_compiled_traced<F>(
specs: &[Spec],
bindings: &IndexMap<String, Value>,
bc: &lex_bytecode::Program,
new_tracer: F,
) -> GateVerdict
where
F: Fn() -> Box<dyn lex_bytecode::vm::Tracer>,
{
evaluate_gate_compiled_inner(specs, bindings, bc, Some(&new_tracer))
}
fn evaluate_gate_compiled_inner(
specs: &[Spec],
bindings: &IndexMap<String, Value>,
bc: &lex_bytecode::Program,
new_tracer: Option<&dyn Fn() -> Box<dyn lex_bytecode::vm::Tracer>>,
) -> GateVerdict {
let policy = Policy::permissive();
for spec in specs {
match eval_body(&spec.body, bindings, bc, &policy, new_tracer) {
Ok(Value::Bool(true)) => continue,
Ok(Value::Bool(false)) => {
return GateVerdict::Deny {
spec_name: spec.name.clone(),
reason: format!(
"spec `{}` returned false; bindings: {}",
spec.name,
format_bindings(bindings),
),
};
}
Ok(other) => return GateVerdict::Inconclusive {
spec_name: spec.name.clone(),
reason: format!("spec body returned non-bool: {other:?}"),
},
Err(e) => return GateVerdict::Inconclusive {
spec_name: spec.name.clone(),
reason: e,
},
}
}
GateVerdict::Allow
}
fn format_bindings(b: &IndexMap<String, Value>) -> String {
let mut parts: Vec<String> = Vec::with_capacity(b.len());
for (k, v) in b {
parts.push(format!("{k}={}", short_value(v)));
}
parts.join(", ")
}
fn short_value(v: &Value) -> String {
match v {
Value::Int(i) => format!("{i}"),
Value::Float(f) => format!("{f}"),
Value::Bool(b) => format!("{b}"),
Value::Str(s) => format!("\"{}\"", s.chars().take(40).collect::<String>()),
other => format!("{other:?}"),
}
}
fn eval_body(
e: &SpecExpr,
bindings: &IndexMap<String, Value>,
bc: &lex_bytecode::Program,
policy: &Policy,
new_tracer: Option<&dyn Fn() -> Box<dyn lex_bytecode::vm::Tracer>>,
) -> Result<Value, String> {
match e {
SpecExpr::IntLit { value } => Ok(Value::Int(*value)),
SpecExpr::FloatLit { value } => Ok(Value::Float(*value)),
SpecExpr::BoolLit { value } => Ok(Value::Bool(*value)),
SpecExpr::StrLit { value } => Ok(Value::Str(value.clone().into())),
SpecExpr::Var { name } => bindings.get(name).cloned()
.ok_or_else(|| format!("unbound spec var `{name}` (provide via gate bindings)")),
SpecExpr::Let { name, value, body } => {
let v = eval_body(value, bindings, bc, policy, new_tracer)?;
let mut next = bindings.clone();
next.insert(name.clone(), v);
eval_body(body, &next, bc, policy, new_tracer)
}
SpecExpr::Not { expr } => match eval_body(expr, bindings, bc, policy, new_tracer)? {
Value::Bool(b) => Ok(Value::Bool(!b)),
other => Err(format!("not on non-bool: {other:?}")),
},
SpecExpr::BinOp { op, lhs, rhs } => {
if matches!(op, SpecOp::And | SpecOp::Or) {
let a = eval_body(lhs, bindings, bc, policy, new_tracer)?;
let av = match a {
Value::Bool(b) => b,
other => return Err(format!(
"{} on non-bool lhs: {other:?}", op.as_str())),
};
if matches!(op, SpecOp::And) && !av { return Ok(Value::Bool(false)); }
if matches!(op, SpecOp::Or) && av { return Ok(Value::Bool(true)); }
let b = eval_body(rhs, bindings, bc, policy, new_tracer)?;
return match b {
Value::Bool(bb) => Ok(Value::Bool(bb)),
other => Err(format!("{} on non-bool rhs: {other:?}", op.as_str())),
};
}
let a = eval_body(lhs, bindings, bc, policy, new_tracer)?;
let b = eval_body(rhs, bindings, bc, policy, new_tracer)?;
apply_binop(*op, a, b)
}
SpecExpr::Call { func, args } => {
let mut argv = Vec::new();
for a in args { argv.push(eval_body(a, bindings, bc, policy, new_tracer)?); }
if let Some(v) = list_builtin(func, &argv) { return v; }
let handler = DefaultHandler::new(policy.clone());
let mut vm = Vm::with_handler(bc, Box::new(handler));
if let Some(make_tracer) = new_tracer {
vm.set_tracer(make_tracer());
}
vm.call(func, argv).map_err(|e| format!("call `{func}`: {e}"))
}
SpecExpr::Index { list, index } => {
let xs = eval_body(list, bindings, bc, policy, new_tracer)?;
let i = eval_body(index, bindings, bc, policy, new_tracer)?;
list_index(xs, i)
}
SpecExpr::Match { scrutinee, arms } => {
let v = eval_body(scrutinee, bindings, bc, policy, new_tracer)?;
for arm in arms {
if let Some(extra) = pattern_match(&arm.pattern, &v) {
let mut next = bindings.clone();
for (k, vv) in extra { next.insert(k, vv); }
return eval_body(&arm.body, &next, bc, policy, new_tracer);
}
}
Err(format!(
"non-exhaustive match: no arm matched value {}",
short_value(&v)))
}
SpecExpr::FieldAccess { value, field } => {
let v = eval_body(value, bindings, bc, policy, new_tracer)?;
match v {
Value::Record(fields) => fields.get(field).cloned().ok_or_else(|| {
let known: Vec<&str> = fields.keys().map(String::as_str).collect();
format!("field `{field}` missing on record (have: {})", known.join(", "))
}),
other => Err(format!(
"field access `.{field}` on non-record: {}",
short_value(&other))),
}
}
}
}
pub(crate) fn pattern_match(pat: &crate::ast::SpecPattern, v: &Value)
-> Option<Vec<(String, Value)>>
{
use crate::ast::SpecPattern;
match pat {
SpecPattern::Wildcard => Some(Vec::new()),
SpecPattern::Variant { name, bindings } => {
match v {
Value::Variant { name: vn, args } if vn == name && args.len() == bindings.len() => {
Some(bindings.iter().cloned()
.zip(args.iter().cloned())
.collect())
}
_ => None,
}
}
}
}
pub(crate) fn list_builtin(func: &str, args: &[Value]) -> Option<Result<Value, String>> {
match func {
"length" => {
if args.len() != 1 {
return Some(Err(format!("length: expected 1 arg, got {}", args.len())));
}
match &args[0] {
Value::List(xs) => Some(Ok(Value::Int(xs.len() as i64))),
_ => None,
}
}
"head" => {
if args.len() != 1 { return None; }
match &args[0] {
Value::List(xs) => Some(match xs.front() {
Some(v) => Ok(v.clone()),
None => Err("head: empty list".into()),
}),
_ => None,
}
}
"tail" => {
if args.len() != 1 { return None; }
match &args[0] {
Value::List(xs) => Some(if xs.is_empty() {
Err("tail: empty list".into())
} else {
Ok(Value::List(xs.iter().skip(1).cloned().collect::<std::collections::VecDeque<_>>()))
}),
_ => None,
}
}
_ => None,
}
}
fn list_index(list: Value, index: Value) -> Result<Value, String> {
let xs = match list {
Value::List(xs) => xs,
other => return Err(format!("index `[..]` on non-list: {}", short_value(&other))),
};
let i = match index {
Value::Int(n) => n,
other => return Err(format!("list index must be Int, got {}", short_value(&other))),
};
if i < 0 || (i as usize) >= xs.len() {
return Err(format!(
"list index {i} out of bounds (length {})", xs.len()));
}
Ok(xs[i as usize].clone())
}
fn apply_binop(op: SpecOp, a: Value, b: Value) -> Result<Value, String> {
use SpecOp::*;
match (op, &a, &b) {
(Add, Value::Int(x), Value::Int(y)) => Ok(Value::Int(x + y)),
(Sub, Value::Int(x), Value::Int(y)) => Ok(Value::Int(x - y)),
(Mul, Value::Int(x), Value::Int(y)) => Ok(Value::Int(x * y)),
(Div, Value::Int(x), Value::Int(y)) if *y != 0 => Ok(Value::Int(x / y)),
(Mod, Value::Int(x), Value::Int(y)) if *y != 0 => Ok(Value::Int(x % y)),
(Add, Value::Float(x), Value::Float(y)) => Ok(Value::Float(x + y)),
(Sub, Value::Float(x), Value::Float(y)) => Ok(Value::Float(x - y)),
(Mul, Value::Float(x), Value::Float(y)) => Ok(Value::Float(x * y)),
(Div, Value::Float(x), Value::Float(y)) => Ok(Value::Float(x / y)),
(Eq, x, y) => Ok(Value::Bool(x == y)),
(Neq, x, y) => Ok(Value::Bool(x != y)),
(Lt, Value::Int(x), Value::Int(y)) => Ok(Value::Bool(x < y)),
(Le, Value::Int(x), Value::Int(y)) => Ok(Value::Bool(x <= y)),
(Gt, Value::Int(x), Value::Int(y)) => Ok(Value::Bool(x > y)),
(Ge, Value::Int(x), Value::Int(y)) => Ok(Value::Bool(x >= y)),
(Lt, Value::Float(x), Value::Float(y)) => Ok(Value::Bool(x < y)),
(Le, Value::Float(x), Value::Float(y)) => Ok(Value::Bool(x <= y)),
(Gt, Value::Float(x), Value::Float(y)) => Ok(Value::Bool(x > y)),
(Ge, Value::Float(x), Value::Float(y)) => Ok(Value::Bool(x >= y)),
(And, Value::Bool(x), Value::Bool(y)) => Ok(Value::Bool(*x && *y)),
(Or, Value::Bool(x), Value::Bool(y)) => Ok(Value::Bool(*x || *y)),
_ => Err(format!("invalid binop {op:?} on {a:?}, {b:?}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse_spec;
fn b<I: IntoIterator<Item = (&'static str, Value)>>(items: I) -> IndexMap<String, Value> {
items.into_iter().map(|(k, v)| (k.to_string(), v)).collect()
}
#[test]
fn allow_when_spec_returns_true() {
let spec = parse_spec("spec ok { forall x :: Int : x + 1 > x }").unwrap();
let v = evaluate_gate(&[spec], &b([("x", Value::Int(5))]), "");
assert_eq!(v, GateVerdict::Allow);
}
#[test]
fn deny_when_spec_returns_false() {
let spec = parse_spec("spec budget { forall used :: Int, delta :: Int : (used + delta) <= 100 }").unwrap();
let v = evaluate_gate(
&[spec],
&b([("used", Value::Int(80)), ("delta", Value::Int(30))]),
"",
);
match v {
GateVerdict::Deny { spec_name, reason } => {
assert_eq!(spec_name, "budget");
assert!(reason.contains("used=80"), "reason should include bindings: {reason}");
}
other => panic!("expected Deny, got {other:?}"),
}
}
#[test]
fn first_failing_spec_is_reported() {
let s1 = parse_spec("spec always { forall x :: Int : x == x }").unwrap();
let s2 = parse_spec("spec never { forall x :: Int : x != x }").unwrap();
let v = evaluate_gate(&[s1, s2], &b([("x", Value::Int(0))]), "");
match v {
GateVerdict::Deny { spec_name, .. } => assert_eq!(spec_name, "never"),
other => panic!("expected Deny on `never`, got {other:?}"),
}
}
#[test]
fn missing_binding_is_inconclusive_not_panic() {
let spec = parse_spec("spec needs_x { forall x :: Int : x > 0 }").unwrap();
let v = evaluate_gate(&[spec], &b([]), "");
match v {
GateVerdict::Inconclusive { reason, .. } => {
assert!(reason.contains("unbound spec var"),
"expected unbound-var error, got: {reason}");
}
other => panic!("expected Inconclusive, got {other:?}"),
}
}
#[test]
fn grid_budget_phase1_spec() {
let spec = parse_spec(r#"
spec grid_budget {
forall active :: Int, scheduled :: Int, delta :: Int, budget :: Int :
(active + scheduled + delta) <= budget
}
"#).unwrap();
let allow = evaluate_gate(std::slice::from_ref(&spec), &b([
("active", Value::Int(40)),
("scheduled", Value::Int(20)),
("delta", Value::Int(15)),
("budget", Value::Int(100)),
]), "");
assert_eq!(allow, GateVerdict::Allow);
let deny = evaluate_gate(&[spec], &b([
("active", Value::Int(40)),
("scheduled", Value::Int(20)),
("delta", Value::Int(60)),
("budget", Value::Int(100)),
]), "");
assert!(matches!(deny, GateVerdict::Deny { .. }));
}
#[test]
fn soc_reserve_phase1_spec() {
let spec = parse_spec(r#"
spec soc_reserve {
forall soc :: Int, draw :: Int, reserve :: Int :
(soc - draw) >= reserve
}
"#).unwrap();
let allow = evaluate_gate(std::slice::from_ref(&spec), &b([
("soc", Value::Int(80)),
("draw", Value::Int(20)),
("reserve", Value::Int(40)),
]), "");
assert_eq!(allow, GateVerdict::Allow);
let deny = evaluate_gate(&[spec], &b([
("soc", Value::Int(50)),
("draw", Value::Int(20)),
("reserve", Value::Int(40)),
]), "");
assert!(matches!(deny, GateVerdict::Deny { .. }));
}
#[test]
fn gate_is_fast_enough_for_synchronous_use() {
let s1 = parse_spec(r#"
spec grid_budget {
forall active :: Int, scheduled :: Int, delta :: Int, budget :: Int :
(active + scheduled + delta) <= budget
}
"#).unwrap();
let s2 = parse_spec(r#"
spec soc_reserve {
forall soc :: Int, draw :: Int, reserve :: Int :
(soc - draw) >= reserve
}
"#).unwrap();
let bindings = b([
("active", Value::Int(40)),
("scheduled", Value::Int(20)),
("delta", Value::Int(15)),
("budget", Value::Int(100)),
("soc", Value::Int(80)),
("draw", Value::Int(20)),
("reserve", Value::Int(40)),
]);
let prog = parse_source("").unwrap();
let stages = lex_ast::canonicalize_program(&prog);
let bc = compile_program(&stages);
let n = 1000;
let start = std::time::Instant::now();
for _ in 0..n {
let v = evaluate_gate_compiled(&[s1.clone(), s2.clone()], &bindings, &bc);
assert_eq!(v, GateVerdict::Allow);
}
let elapsed = start.elapsed();
let per_call_us = elapsed.as_micros() / n as u128;
assert!(per_call_us < 5_000,
"per-gate verdict should be under 5ms; got {per_call_us}μs");
}
struct CallRecorder {
captured: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
}
impl lex_bytecode::vm::Tracer for CallRecorder {
fn enter_call(&mut self, _node_id: &str, name: &str, _args: &[Value]) {
self.captured.lock().unwrap().push(name.to_string());
}
fn enter_effect(&mut self, _: &str, _: &str, _: &str, _: &[Value]) {}
fn exit_ok(&mut self, _: &Value) {}
fn exit_err(&mut self, _: &str) {}
fn exit_call_tail(&mut self) {}
fn override_effect(&mut self, _: &str) -> Option<Value> { None }
}
#[test]
fn traced_gate_captures_nested_call_events() {
let host_src = r#"
fn projected_load(active :: Int, delta :: Int) -> Int {
active + delta
}
fn budget_total(budget :: Int, headroom :: Int) -> Int {
budget + headroom
}
fn under_budget(active :: Int, delta :: Int, budget :: Int, headroom :: Int) -> Bool {
projected_load(active, delta) <= budget_total(budget, headroom)
}
"#;
let prog = parse_source(host_src).unwrap();
let stages = lex_ast::canonicalize_program(&prog);
let bc = compile_program(&stages);
let spec = parse_spec(r#"
spec gated_budget {
forall active :: Int, delta :: Int, budget :: Int, headroom :: Int :
under_budget(active, delta, budget, headroom)
}
"#).unwrap();
let bindings = b([
("active", Value::Int(40)),
("delta", Value::Int(15)),
("budget", Value::Int(60)),
("headroom", Value::Int(0)),
]);
let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let captured_for_factory = std::sync::Arc::clone(&captured);
let v = evaluate_gate_compiled_traced(
std::slice::from_ref(&spec),
&bindings,
&bc,
move || Box::new(CallRecorder {
captured: std::sync::Arc::clone(&captured_for_factory),
}),
);
assert_eq!(v, GateVerdict::Allow);
let calls = captured.lock().unwrap();
for expected in ["projected_load", "budget_total"] {
assert!(calls.iter().any(|c| c == expected),
"expected `{expected}` in captured calls; got {:?}", *calls);
}
}
#[test]
fn untraced_gate_path_unchanged() {
let spec = parse_spec("spec ok { forall x :: Int : x + 1 > x }").unwrap();
let v = evaluate_gate_compiled(
std::slice::from_ref(&spec),
&b([("x", Value::Int(5))]),
&compile_program(&lex_ast::canonicalize_program(&parse_source("").unwrap())),
);
assert_eq!(v, GateVerdict::Allow);
}
fn rec(fields: &[(&str, Value)]) -> Value {
let mut m = indexmap::IndexMap::new();
for (k, v) in fields {
m.insert((*k).into(), v.clone());
}
Value::Record(m)
}
#[test]
fn record_quantifier_type_parses() {
let spec = parse_spec(r#"
spec session_ok {
forall s :: { used :: Int, ceiling :: Int } : true
}
"#).unwrap();
let v = evaluate_gate(&[spec], &b([
("s", rec(&[("used", Value::Int(0)), ("ceiling", Value::Int(100))])),
]), "");
assert_eq!(v, GateVerdict::Allow);
}
#[test]
fn field_access_drills_into_record_value() {
let spec = parse_spec(r#"
spec budget_ok {
forall s :: { used :: Int, ceiling :: Int } :
s.used <= s.ceiling
}
"#).unwrap();
let allow = evaluate_gate(std::slice::from_ref(&spec), &b([
("s", rec(&[("used", Value::Int(40)), ("ceiling", Value::Int(100))])),
]), "");
assert_eq!(allow, GateVerdict::Allow);
let deny = evaluate_gate(&[spec], &b([
("s", rec(&[("used", Value::Int(120)), ("ceiling", Value::Int(100))])),
]), "");
assert!(matches!(deny, GateVerdict::Deny { .. }));
}
#[test]
fn nested_record_field_access_works() {
let spec = parse_spec(r#"
spec station_ok {
forall s :: { charge :: { power_drawn :: Int, budget :: Int } } :
s.charge.power_drawn <= s.charge.budget
}
"#).unwrap();
let allow = evaluate_gate(std::slice::from_ref(&spec), &b([
("s", rec(&[("charge", rec(&[
("power_drawn", Value::Int(50)),
("budget", Value::Int(80)),
]))])),
]), "");
assert_eq!(allow, GateVerdict::Allow);
}
#[test]
fn missing_field_is_inconclusive_not_panic() {
let spec = parse_spec(r#"
spec needs_budget {
forall s :: { used :: Int, budget :: Int } : s.used <= s.budget
}
"#).unwrap();
let v = evaluate_gate(&[spec], &b([
("s", rec(&[("used", Value::Int(40))])),
]), "");
match v {
GateVerdict::Inconclusive { reason, .. } => {
assert!(reason.contains("field `budget`"),
"reason should name the missing field; got: {reason}");
}
other => panic!("expected Inconclusive, got {other:?}"),
}
}
#[test]
fn field_access_on_non_record_is_inconclusive() {
let spec = parse_spec(r#"
spec wrong_shape {
forall x :: Int : x.used > 0
}
"#).unwrap();
let v = evaluate_gate(&[spec], &b([("x", Value::Int(40))]), "");
match v {
GateVerdict::Inconclusive { reason, .. } => {
assert!(reason.contains("non-record"),
"reason should call out non-record; got: {reason}");
}
other => panic!("expected Inconclusive, got {other:?}"),
}
}
fn lst(items: &[Value]) -> Value {
Value::List(items.iter().cloned().collect::<std::collections::VecDeque<_>>())
}
#[test]
fn list_quantifier_type_parses() {
let spec = parse_spec(r#"
spec ok {
forall xs :: List[Int] : true
}
"#).unwrap();
let v = evaluate_gate(&[spec], &b([
("xs", lst(&[Value::Int(1), Value::Int(2)])),
]), "");
assert_eq!(v, GateVerdict::Allow);
}
#[test]
fn length_builtin_returns_list_length() {
let spec = parse_spec(r#"
spec at_least_one {
forall xs :: List[Int] : length(xs) > 0
}
"#).unwrap();
let allow = evaluate_gate(std::slice::from_ref(&spec), &b([
("xs", lst(&[Value::Int(7)])),
]), "");
assert_eq!(allow, GateVerdict::Allow);
let deny = evaluate_gate(&[spec], &b([
("xs", lst(&[])),
]), "");
assert!(matches!(deny, GateVerdict::Deny { .. }));
}
#[test]
fn indexed_access_reads_list_element() {
let spec = parse_spec(r#"
spec head_positive {
forall xs :: List[Int] : xs[0] > 0
}
"#).unwrap();
let allow = evaluate_gate(std::slice::from_ref(&spec), &b([
("xs", lst(&[Value::Int(5), Value::Int(10)])),
]), "");
assert_eq!(allow, GateVerdict::Allow);
let deny = evaluate_gate(&[spec], &b([
("xs", lst(&[Value::Int(0), Value::Int(10)])),
]), "");
assert!(matches!(deny, GateVerdict::Deny { .. }));
}
#[test]
fn head_and_tail_builtins_work() {
let spec = parse_spec(r#"
spec shape {
forall xs :: List[Int] :
length(xs) > 0 and head(xs) >= length(tail(xs))
}
"#).unwrap();
let allow = evaluate_gate(std::slice::from_ref(&spec), &b([
("xs", lst(&[Value::Int(3), Value::Int(1), Value::Int(2)])),
]), "");
assert_eq!(allow, GateVerdict::Allow);
let deny = evaluate_gate(&[spec], &b([
("xs", lst(&[Value::Int(1), Value::Int(1),
Value::Int(2), Value::Int(3)])),
]), "");
assert!(matches!(deny, GateVerdict::Deny { .. }));
}
#[test]
fn list_of_records_lets_specs_quantify_structured_collections() {
let spec = parse_spec(r#"
spec first_session_within_budget {
forall sessions :: List[{ power :: Int, budget :: Int }] :
length(sessions) == 0 or sessions[0].power <= sessions[0].budget
}
"#).unwrap();
let mut session = indexmap::IndexMap::new();
session.insert("power".into(), Value::Int(50));
session.insert("budget".into(), Value::Int(80));
let allow = evaluate_gate(std::slice::from_ref(&spec), &b([
("sessions", Value::List(vec![Value::Record(session.clone())].into())),
]), "");
assert_eq!(allow, GateVerdict::Allow);
let mut over = indexmap::IndexMap::new();
over.insert("power".into(), Value::Int(120));
over.insert("budget".into(), Value::Int(80));
let deny = evaluate_gate(&[spec], &b([
("sessions", Value::List(vec![Value::Record(over)].into())),
]), "");
assert!(matches!(deny, GateVerdict::Deny { .. }));
}
#[test]
fn empty_list_passes_when_predicate_is_vacuous() {
let spec = parse_spec(r#"
spec ok_or_empty {
forall xs :: List[Int] : length(xs) == 0 or xs[0] > 0
}
"#).unwrap();
let v = evaluate_gate(&[spec], &b([("xs", lst(&[]))]), "");
assert_eq!(v, GateVerdict::Allow);
}
#[test]
fn out_of_bounds_index_is_inconclusive() {
let spec = parse_spec(r#"
spec needs_two {
forall xs :: List[Int] : xs[1] > 0
}
"#).unwrap();
let v = evaluate_gate(&[spec], &b([
("xs", lst(&[Value::Int(5)])), ]), "");
match v {
GateVerdict::Inconclusive { reason, .. } => {
assert!(reason.contains("out of bounds"),
"expected OOB diagnostic; got: {reason}");
}
other => panic!("expected Inconclusive, got {other:?}"),
}
}
#[test]
fn head_of_empty_list_is_inconclusive() {
let spec = parse_spec(r#"
spec head_pos {
forall xs :: List[Int] : head(xs) > 0
}
"#).unwrap();
let v = evaluate_gate(&[spec], &b([("xs", lst(&[]))]), "");
match v {
GateVerdict::Inconclusive { reason, .. } => {
assert!(reason.contains("empty list"),
"expected empty-list diagnostic; got: {reason}");
}
other => panic!("expected Inconclusive, got {other:?}"),
}
}
fn variant(name: &str, args: Vec<Value>) -> Value {
Value::Variant { name: name.into(), args }
}
#[test]
fn named_type_in_quantifier_parses() {
let spec = parse_spec(r#"
spec ok {
forall msg :: Message : true
}
"#).unwrap();
let v = evaluate_gate(&[spec], &b([
("msg", variant("Heartbeat", vec![])),
]), "");
assert_eq!(v, GateVerdict::Allow);
}
#[test]
fn match_dispatches_on_variant_name() {
let spec = parse_spec(r#"
spec valid_msg {
forall msg :: Message :
match msg {
Charge(amount) => amount > 0,
Telemetry(payload) => true,
_ => false,
}
}
"#).unwrap();
let allow_charge = evaluate_gate(std::slice::from_ref(&spec), &b([
("msg", variant("Charge", vec![Value::Int(50)])),
]), "");
assert_eq!(allow_charge, GateVerdict::Allow);
let deny_negative = evaluate_gate(std::slice::from_ref(&spec), &b([
("msg", variant("Charge", vec![Value::Int(-1)])),
]), "");
assert!(matches!(deny_negative, GateVerdict::Deny { .. }));
let allow_telemetry = evaluate_gate(std::slice::from_ref(&spec), &b([
("msg", variant("Telemetry", vec![Value::Str("ok".into())])),
]), "");
assert_eq!(allow_telemetry, GateVerdict::Allow);
let deny_unknown = evaluate_gate(&[spec], &b([
("msg", variant("UnknownTopic", vec![])),
]), "");
assert!(matches!(deny_unknown, GateVerdict::Deny { .. }));
}
#[test]
fn variant_pattern_binds_positional_args() {
let spec = parse_spec(r#"
spec budget_match {
forall msg :: Message :
match msg {
Charge(amount, budget) => amount <= budget,
_ => true,
}
}
"#).unwrap();
let allow = evaluate_gate(std::slice::from_ref(&spec), &b([
("msg", variant("Charge", vec![Value::Int(50), Value::Int(100)])),
]), "");
assert_eq!(allow, GateVerdict::Allow);
let deny = evaluate_gate(&[spec], &b([
("msg", variant("Charge", vec![Value::Int(150), Value::Int(100)])),
]), "");
assert!(matches!(deny, GateVerdict::Deny { .. }));
}
#[test]
fn variant_arity_mismatch_falls_through_to_next_arm() {
let spec = parse_spec(r#"
spec arity_check {
forall msg :: Message :
match msg {
Charge(x) => x > 0,
_ => true,
}
}
"#).unwrap();
let v = evaluate_gate(&[spec], &b([
("msg", variant("Charge", vec![Value::Int(1), Value::Int(2)])),
]), "");
assert_eq!(v, GateVerdict::Allow);
}
#[test]
fn non_exhaustive_match_is_inconclusive() {
let spec = parse_spec(r#"
spec only_charge {
forall msg :: Message :
match msg {
Charge(_) => true,
}
}
"#).unwrap();
let v = evaluate_gate(&[spec], &b([
("msg", variant("OtherTopic", vec![])),
]), "");
match v {
GateVerdict::Inconclusive { reason, .. } => {
assert!(reason.contains("non-exhaustive"),
"expected non-exhaustive diagnostic; got: {reason}");
}
other => panic!("expected Inconclusive, got {other:?}"),
}
}
#[test]
fn nested_match_drills_into_variant_payload_record() {
let spec = parse_spec(r#"
spec station_match {
forall msg :: Message :
match msg {
Charge(s) => s.power <= s.budget,
_ => true,
}
}
"#).unwrap();
let mut session = indexmap::IndexMap::new();
session.insert("power".into(), Value::Int(60));
session.insert("budget".into(), Value::Int(100));
let allow = evaluate_gate(std::slice::from_ref(&spec), &b([
("msg", variant("Charge", vec![Value::Record(session)])),
]), "");
assert_eq!(allow, GateVerdict::Allow);
let mut over = indexmap::IndexMap::new();
over.insert("power".into(), Value::Int(160));
over.insert("budget".into(), Value::Int(100));
let deny = evaluate_gate(&[spec], &b([
("msg", variant("Charge", vec![Value::Record(over)])),
]), "");
assert!(matches!(deny, GateVerdict::Deny { .. }));
}
}