Skip to main content

spec_checker/
gate.rs

1//! Spec-checker as a runtime action gate (#186).
2//!
3//! `evaluate_gate` evaluates one or more specs against
4//! caller-supplied bindings (typically `state` + the proposed
5//! action) and returns `Allow` if every spec holds, `Deny` on
6//! the first violation.
7//!
8//! This is a separate evaluation mode from
9//! [`crate::check_spec`]: the latter quantifies over random
10//! inputs to discover counterexamples; the former takes the
11//! inputs *given* and answers a single deterministic verdict.
12//! Specs reuse their existing AST — quantifier names become the
13//! lookup keys for the supplied bindings — so the same spec
14//! that an offline checker proves over random inputs can also
15//! gate one specific action online.
16//!
17//! Trace integration is intentionally not wired here — the
18//! function returns the verdict, and the caller (e.g. an agent
19//! runtime in a downstream crate) records it. Keeps spec-checker
20//! free of a lex-trace dependency.
21
22use 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/// Verdict returned by [`evaluate_gate`].
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31#[serde(tag = "verdict", rename_all = "snake_case")]
32pub enum GateVerdict {
33    Allow,
34    /// Spec returned `false` (action violates an invariant).
35    /// `spec_name` is the name of the offending spec, `reason`
36    /// is human-readable detail (typically the spec name plus
37    /// the relevant bindings).
38    Deny { spec_name: String, reason: String },
39    /// Evaluation failed for a non-spec reason (e.g. the body
40    /// referenced a Lex function whose call errored). Surfaced
41    /// as a separate variant so callers can distinguish "spec
42    /// said no" from "we couldn't tell."
43    Inconclusive { spec_name: String, reason: String },
44}
45
46/// Evaluate every `spec` against `bindings` and return the
47/// first non-Allow verdict (or `Allow` if all pass). `lex_source`
48/// supplies the host program — any `SpecExpr::Call` in a spec's
49/// body resolves to a function in this program.
50///
51/// Designed for synchronous per-action use. The Lex program is
52/// type-checked and compiled on each call; callers that gate at
53/// high frequency should prefer [`evaluate_gate_compiled`].
54pub 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
77/// Same as [`evaluate_gate`] but takes already-compiled
78/// bytecode. Use when gating at high frequency: compile the
79/// program once, evaluate many actions against it.
80pub 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
88/// Like [`evaluate_gate_compiled`] but additionally threads a
89/// caller-supplied tracer into every Vm the spec body spins up
90/// for [`SpecExpr::Call`] (#199).
91///
92/// `new_tracer` is called once per host-helper invocation and
93/// must produce a fresh `Box<dyn Tracer>` for each new `Vm`.
94/// Multiple tracers can share state — typically by closing over
95/// a [`lex_trace::Handle`] and cloning it inside the closure —
96/// so the resulting trace tree captures the spec body's call
97/// graph (e.g. `under_budget → projected_load + budget_total`)
98/// alongside the rest of the agent's run.
99///
100/// Existing callers of [`evaluate_gate`] / [`evaluate_gate_compiled`]
101/// stay unchanged; this is purely additive.
102pub 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
165/// Evaluate a `SpecExpr` against caller-supplied `bindings`.
166/// Mirrors `checker::eval` but kept separate so the gate path
167/// doesn't have to thread random-generation state.
168///
169/// `new_tracer`, when present, is invoked once per
170/// `SpecExpr::Call` and the resulting Tracer is attached to
171/// the Vm before running the host helper. The factory shape
172/// (rather than a single `Box<dyn Tracer>`) is what lets
173/// multiple sibling calls all flow into the same caller-side
174/// recorder via cloned `Handle`s.
175fn 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            // Short-circuit `and` / `or` so guard expressions like
201            // `length(xs) == 0 or xs[0] > 0` don't evaluate the
202            // second arm when the first already decides the result.
203            // Matches the conventional boolean-operator semantics —
204            // and the gate use case where the second arm may
205            // legitimately error on the values the first arm
206            // exists to filter out (#208 slice 2).
207            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            // Spec-builtin list operations (#208). `length`, `head`,
230            // and `tail` are intercepted before falling through to a
231            // host VM call so specs can reason about list-shaped
232            // bindings without the host program needing those names.
233            // Identical name-shadowing behavior to lex's stdlib —
234            // user code can still define a function `length` and
235            // reference it from a spec, but a spec call to `length(xs)`
236            // where `xs` is a `Value::List` resolves to the builtin.
237            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            // #208 slice 3: dispatch on `Value::Variant`'s tag.
252            // Wildcard arms always match. Variant arms match by name
253            // and arity, binding positional args by name in the body.
254            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            // Drill into a record-typed binding (#208). Fails loudly
268            // if the value isn't a record or the field is missing —
269            // both indicate a spec/binding shape mismatch the agent
270            // wants to know about, not silently default.
271            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
285/// Try to match a pattern against a value (#208 slice 3). Returns
286/// `Some(bindings)` on success — the bindings to add to the
287/// arm's lexical environment — or `None` if the pattern doesn't
288/// match. The caller falls through to the next arm on `None`.
289pub(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
308/// Spec-builtin list operations (#208). Returns `Some(result)` if
309/// `func` names a builtin (`length`, `head`, `tail`) and the args
310/// shape matches; returns `None` to indicate the call should fall
311/// through to a host VM dispatch.
312pub(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                // Not a list — fall through to host dispatch in case
321                // the user defined their own `length` function.
322                _ => 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        // Two specs, second one fails — verdict mentions the
430        // second one specifically.
431        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        // An action that omits a bound the spec needs — surface
443        // as Inconclusive so the caller can fix the gate harness
444        // rather than crash.
445        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        // Headline soft Phase 1 spec: site grid load (active +
459        // scheduled + delta) must not exceed budget.
460        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        // Second Phase 1 spec: vehicle projected SoC after
485        // proposed action must not drop below reserve.
486        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        // Issue calls for single-digit ms per verdict on Phase 1's
509        // small spec set. We measure 1k iterations and assert the
510        // average is comfortably under that — the headroom matters
511        // because CI runners are slower than local hardware.
512        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    // ---- #199: optional tracer hook -----------------------------
550
551    /// Minimal Tracer that records every enter_call name into a
552    /// shared Vec. Avoids depending on lex-trace; soft-agent's
553    /// real wiring uses `lex_trace::Recorder` + `Handle::clone`.
554    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        // Spec body calls `under_budget`, which itself calls
571        // `projected_load` and `budget_total`. Without the tracer
572        // hook, only the top-level Lex call appears in any
573        // recorder; with it, the nested helpers do too.
574        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        // The Vm fires `enter_call` for sub-calls executed inside
616        // the entry function's body, not for the host-driven
617        // `Vm::call("under_budget", ...)` itself — that's the
618        // host's contract. The point of the tracer hook is that
619        // these *nested* helpers are visible at all; pre-#199
620        // they were entirely opaque to the gate's recorder.
621        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        // The existing `evaluate_gate_compiled` API stays unaffected
630        // by #199: same signature, same behavior. Pin this so future
631        // refactors of the inner factory threading don't quietly
632        // shift the public contract.
633        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    // ---- #208: record-typed bindings + field access ------------------
643
644    /// Build a `Value::Record` from `(field, value)` pairs.
645    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        // The header type uses the new record syntax. The body
656        // doesn't have to use it — confirms the parser accepts the
657        // `{ name :: Ty, ... }` shape independently of how the spec
658        // body references the binding.
659        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        // The headline #208 case: spec quantifies a record-shaped
673        // binding *and* references its fields directly. Pre-#208
674        // soft-agent had to flatten this via BindingsFn.
675        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        // `s.charge.power_drawn` — chained field access. Mirrors the
694        // structured-state pattern that motivated the issue (see the
695        // "active sessions, station.power_drawn ≤ station.budget"
696        // example in #208's background).
697        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        // Spec references `s.budget` but the binding has only `s.used`.
715        // This is an agent/spec mismatch; surface as Inconclusive with a
716        // diagnostic listing the available fields.
717        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        // Catches the "spec author forgot the value was scalar" case.
737        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    // ---- #208 slice 2: list-typed bindings ---------------------------
753
754    /// Build a `Value::List` from a slice of values.
755    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        // `head(xs) >= length(tail(xs))` — silly but exercises both
809        // builtins together with a length() over the tail.
810        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        // [3, 1, 2]: head=3, tail=[1,2] → length 2; 3 >= 2 ✓
817        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        // [1, 1, 2, 3]: head=1, tail=[1,2,3] → length 3; 1 >= 3 ✗
822        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        // The pattern motivated by the issue's "for every charging
832        // session in active_sessions, station.power_drawn ≤ station.budget"
833        // example. The spec checks the *first* session's invariant —
834        // a per-element forall is slice 3's territory; this slice
835        // verifies the structural plumbing (List of Record + indexed
836        // access + field access) composes.
837        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        // Verifies the `length(xs) == 0 or ...` short-circuit pattern
863        // used to make per-list predicates well-defined on empties.
864        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)])),  // length 1; xs[1] OOB
882        ]), "");
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    // ---- #208 slice 3: ADT pattern matching --------------------------
910
911    /// Build a `Value::Variant` with the given name and positional args.
912    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        // Two arms: Charge(amount) returns amount > 0; Telemetry(_)
932        // is unconditionally true. Wildcard catches the rest.
933        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        // `Charge(amount, station)` binds two args; the body
967        // references both. Confirms multi-arg variant patterns work.
968        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        // `Charge(x)` (1 arg) doesn't match a `Charge(a, b)` value
990        // (2 args). The wildcard fallback catches it.
991        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        // No wildcard, no matching variant — no arm fires.
1009        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        // `Charge(s)` binds `s`, which is itself a record. The arm
1032        // body uses `s.power <= s.budget` — combines slice 1
1033        // (FieldAccess) with slice 3 (variant binding). Models the
1034        // soft "for every charging session" pattern.
1035        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}