Skip to main content

spec_checker/
checker.rs

1//! Randomized checker that drives the Lex VM with generated inputs.
2
3use crate::ast::*;
4use indexmap::IndexMap;
5use lex_ast::canonicalize_program;
6use lex_bytecode::{compile_program, vm::Vm, Value};
7use lex_runtime::{DefaultHandler, Policy};
8use lex_syntax::parse_source;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12#[serde(rename_all = "snake_case")]
13pub enum ProofStatus { Proved, Counterexample, Inconclusive }
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Evidence {
17    pub method: String,
18    pub trials: u32,
19    /// On counterexample, the failing input.
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub counterexample: Option<IndexMap<String, serde_json::Value>>,
22    /// On inconclusive, why we couldn't decide.
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub note: Option<String>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct CheckResult {
29    pub spec_id: String,
30    pub status: ProofStatus,
31    pub evidence: Evidence,
32}
33
34/// Check a spec against a Lex program. The program must define the
35/// function the spec refers to (by name).
36///
37/// `trials`: number of random samples for the randomized strategy.
38/// Recommended ≥ 1000 for honest "proved" claims; small numbers (e.g. 10)
39/// are useful for fast smoke tests.
40pub fn check_spec(spec: &Spec, lex_source: &str, trials: u32) -> CheckResult {
41    let spec_id = spec_id(spec);
42    let prog = match parse_source(lex_source) {
43        Ok(p) => p,
44        Err(e) => return inconclusive(spec_id, format!("parse: {e}")),
45    };
46    let stages = canonicalize_program(&prog);
47    if let Err(errs) = lex_types::check_program(&stages) {
48        return inconclusive(spec_id, format!("typecheck: {errs:?}"));
49    }
50    let bc = compile_program(&stages);
51
52    // Skip Float-only specs in the randomized strategy: float spaces are
53    // huge and counterexamples don't generalize. Report inconclusive
54    // honestly per spec §14.5.
55    if spec.quantifiers.iter().any(|q| q.ty == SpecType::Float) {
56        return CheckResult {
57            spec_id,
58            status: ProofStatus::Inconclusive,
59            evidence: Evidence {
60                method: "randomized".into(),
61                trials: 0,
62                counterexample: None,
63                note: Some("randomized search inconclusive on Float quantifiers; use SMT (see to_smtlib)".into()),
64            },
65        };
66    }
67
68    let mut rng = DetRng::new(seed_from_spec(spec));
69    let policy = Policy::permissive();
70
71    for trial in 0..trials {
72        let mut bindings = IndexMap::new();
73        let mut skip = false;
74        for q in &spec.quantifiers {
75            let v = sample(&q.ty, &mut rng);
76            bindings.insert(q.name.clone(), v);
77            // Apply per-quantifier constraint.
78            if let Some(c) = &q.constraint {
79                match eval(c, &bindings, &bc, &policy) {
80                    Ok(Value::Bool(true)) => {}
81                    Ok(Value::Bool(false)) => { skip = true; break; }
82                    Ok(_) => return inconclusive(spec_id, format!("constraint did not return Bool (trial {trial})")),
83                    Err(e) => return inconclusive(spec_id, format!("constraint eval failed: {e}")),
84                }
85            }
86        }
87        if skip { continue; }
88
89        match eval(&spec.body, &bindings, &bc, &policy) {
90            Ok(Value::Bool(true)) => continue,
91            Ok(Value::Bool(false)) => {
92                return CheckResult {
93                    spec_id,
94                    status: ProofStatus::Counterexample,
95                    evidence: Evidence {
96                        method: "randomized".into(),
97                        trials: trial + 1,
98                        counterexample: Some(bindings_to_json(&bindings)),
99                        note: None,
100                    },
101                };
102            }
103            Ok(other) => return inconclusive(spec_id, format!("body returned non-bool: {other:?}")),
104            Err(e) => return inconclusive(spec_id, format!("body eval failed: {e}")),
105        }
106    }
107
108    CheckResult {
109        spec_id,
110        status: ProofStatus::Proved,
111        evidence: Evidence {
112            method: "randomized".into(),
113            trials,
114            counterexample: None,
115            note: Some(format!("survived {trials} random trials; not a deductive proof")),
116        },
117    }
118}
119
120fn inconclusive(spec_id: String, note: impl Into<String>) -> CheckResult {
121    CheckResult {
122        spec_id,
123        status: ProofStatus::Inconclusive,
124        evidence: Evidence {
125            method: "randomized".into(),
126            trials: 0,
127            counterexample: None,
128            note: Some(note.into()),
129        },
130    }
131}
132
133fn spec_id(spec: &Spec) -> String {
134    use sha2::{Digest, Sha256};
135    let v = serde_json::to_value(spec).unwrap();
136    let s = lex_ast::canon_json::to_canonical_string(&v);
137    let mut h = Sha256::new();
138    h.update(s.as_bytes());
139    let r = h.finalize();
140    let mut hex = String::with_capacity(64);
141    for b in r { hex.push_str(&format!("{:02x}", b)); }
142    hex
143}
144
145fn seed_from_spec(spec: &Spec) -> u64 {
146    use sha2::{Digest, Sha256};
147    let mut h = Sha256::new();
148    h.update(spec.name.as_bytes());
149    let r = h.finalize();
150    u64::from_le_bytes(r[..8].try_into().unwrap())
151}
152
153/// Tiny deterministic xorshift RNG, no external dep.
154struct DetRng { state: u64 }
155impl DetRng {
156    fn new(seed: u64) -> Self { Self { state: seed.max(1) } }
157    fn next_u64(&mut self) -> u64 {
158        let mut x = self.state;
159        x ^= x << 13;
160        x ^= x >> 7;
161        x ^= x << 17;
162        self.state = x;
163        x
164    }
165    fn next_i64_in(&mut self, lo: i64, hi: i64) -> i64 {
166        if hi <= lo { return lo; }
167        let span = (hi - lo) as u64;
168        lo + (self.next_u64() % span) as i64
169    }
170}
171
172fn sample(ty: &SpecType, rng: &mut DetRng) -> Value {
173    match ty {
174        SpecType::Int => Value::Int(rng.next_i64_in(-1000, 1001)),
175        SpecType::Float => {
176            let n = rng.next_u64() as f64;
177            Value::Float((n / u64::MAX as f64) * 2000.0 - 1000.0)
178        }
179        SpecType::Bool => Value::Bool(rng.next_u64() & 1 == 0),
180        SpecType::Str => {
181            let n = rng.next_u64() % 6;
182            Value::Str(("ab".repeat(n as usize)).chars().take(n as usize).collect())
183        }
184        // #208: random-input sampling on records recurses field by field.
185        // The gate-evaluation path (which is what soft-agent uses) bypasses
186        // this — agents pass concrete record values via bindings — but the
187        // offline `check_spec` random-input prover needs a sampler too.
188        SpecType::Record { fields } => {
189            let mut out = indexmap::IndexMap::new();
190            for (name, fty) in fields {
191                out.insert(name.clone(), sample(fty, rng));
192            }
193            Value::Record(out)
194        }
195        // List sampling generates a length in [0, 8] and recurses on
196        // each element. The cap matches the small-bounded discipline
197        // the rest of `sample` uses (Int is in [-1000, 1000], Str is
198        // ≤ 5 chars) — the prover's intent is finding counterexamples
199        // quickly, not stress-testing throughput.
200        SpecType::List { element } => {
201            let len = (rng.next_u64() % 9) as usize;
202            let mut out = Vec::with_capacity(len);
203            for _ in 0..len {
204                out.push(sample(element, rng));
205            }
206            Value::List(out)
207        }
208        // #208 slice 3: random sampling over user-defined ADTs needs
209        // a variant table that the spec language doesn't carry. The
210        // gate path doesn't need it (concrete bindings come from the
211        // caller) but the offline prover does. For v1 we surface a
212        // sentinel value; the random-input prover treats this as
213        // "type not sampleable" and skips the spec. Specs that
214        // quantify over `Named` types should be evaluated via the
215        // gate path (`evaluate_gate_compiled`) with concrete inputs.
216        SpecType::Named { name } => Value::Variant {
217            name: format!("__unsampled_{name}"),
218            args: Vec::new(),
219        },
220    }
221}
222
223fn eval(
224    e: &SpecExpr,
225    bindings: &IndexMap<String, Value>,
226    bc: &lex_bytecode::Program,
227    policy: &Policy,
228) -> Result<Value, String> {
229    match e {
230        SpecExpr::IntLit { value } => Ok(Value::Int(*value)),
231        SpecExpr::FloatLit { value } => Ok(Value::Float(*value)),
232        SpecExpr::BoolLit { value } => Ok(Value::Bool(*value)),
233        SpecExpr::StrLit { value } => Ok(Value::Str(value.clone())),
234        SpecExpr::Var { name } => bindings.get(name).cloned()
235            .ok_or_else(|| format!("unbound spec var `{name}`")),
236        SpecExpr::Let { name, value, body } => {
237            let v = eval(value, bindings, bc, policy)?;
238            let mut next = bindings.clone();
239            next.insert(name.clone(), v);
240            eval(body, &next, bc, policy)
241        }
242        SpecExpr::Not { expr } => match eval(expr, bindings, bc, policy)? {
243            Value::Bool(b) => Ok(Value::Bool(!b)),
244            other => Err(format!("not on non-bool: {other:?}")),
245        },
246        SpecExpr::BinOp { op, lhs, rhs } => {
247            let a = eval(lhs, bindings, bc, policy)?;
248            let b = eval(rhs, bindings, bc, policy)?;
249            apply_binop(*op, a, b)
250        }
251        SpecExpr::Call { func, args } => {
252            // Materialize args.
253            let mut argv = Vec::new();
254            for a in args { argv.push(eval(a, bindings, bc, policy)?); }
255            // Spec-builtin list ops (#208) — same intercept as the
256            // gate path so random-input proofs and per-action gates
257            // see consistent semantics.
258            if let Some(v) = crate::gate::list_builtin(func, &argv) { return v; }
259            // Run the Lex function with a fresh VM (cheap: program is shared).
260            let handler = DefaultHandler::new(policy.clone());
261            let mut vm = Vm::with_handler(bc, Box::new(handler));
262            vm.call(func, argv).map_err(|e| format!("call `{func}`: {e}"))
263        }
264        SpecExpr::FieldAccess { value, field } => {
265            // #208: random-input prover supports field access on records,
266            // mirroring the gate path. The two evaluators stay in sync —
267            // a spec that holds for a concrete record at runtime should
268            // also hold for randomly-sampled records of the same shape.
269            let v = eval(value, bindings, bc, policy)?;
270            match v {
271                Value::Record(fields) => fields.get(field).cloned().ok_or_else(|| {
272                    let known: Vec<&str> = fields.keys().map(String::as_str).collect();
273                    format!("field `{field}` missing on record (have: {})", known.join(", "))
274                }),
275                other => Err(format!("field access `.{field}` on non-record: {other:?}")),
276            }
277        }
278        SpecExpr::Index { list, index } => {
279            // Mirror of the gate path's list-indexing (#208 slice 2).
280            let xs = eval(list, bindings, bc, policy)?;
281            let i = eval(index, bindings, bc, policy)?;
282            let xs = match xs {
283                Value::List(xs) => xs,
284                other => return Err(format!("index on non-list: {other:?}")),
285            };
286            let i = match i {
287                Value::Int(n) => n,
288                other => return Err(format!("list index must be Int: {other:?}")),
289            };
290            if i < 0 || (i as usize) >= xs.len() {
291                return Err(format!("list index {i} out of bounds (length {})", xs.len()));
292            }
293            Ok(xs[i as usize].clone())
294        }
295        SpecExpr::Match { scrutinee, arms } => {
296            // Mirror of the gate path's match (#208 slice 3).
297            let v = eval(scrutinee, bindings, bc, policy)?;
298            for arm in arms {
299                if let Some(extra) = crate::gate::pattern_match(&arm.pattern, &v) {
300                    let mut next = bindings.clone();
301                    for (k, vv) in extra { next.insert(k, vv); }
302                    return eval(&arm.body, &next, bc, policy);
303                }
304            }
305            Err(format!("non-exhaustive match: no arm matched value {v:?}"))
306        }
307    }
308}
309
310fn apply_binop(op: SpecOp, a: Value, b: Value) -> Result<Value, String> {
311    use SpecOp::*;
312    match (op, &a, &b) {
313        (Add, Value::Int(x), Value::Int(y)) => Ok(Value::Int(x + y)),
314        (Sub, Value::Int(x), Value::Int(y)) => Ok(Value::Int(x - y)),
315        (Mul, Value::Int(x), Value::Int(y)) => Ok(Value::Int(x * y)),
316        (Div, Value::Int(x), Value::Int(y)) if *y != 0 => Ok(Value::Int(x / y)),
317        (Mod, Value::Int(x), Value::Int(y)) if *y != 0 => Ok(Value::Int(x % y)),
318        (Add, Value::Float(x), Value::Float(y)) => Ok(Value::Float(x + y)),
319        (Sub, Value::Float(x), Value::Float(y)) => Ok(Value::Float(x - y)),
320        (Mul, Value::Float(x), Value::Float(y)) => Ok(Value::Float(x * y)),
321        (Div, Value::Float(x), Value::Float(y)) => Ok(Value::Float(x / y)),
322        (Eq, x, y) => Ok(Value::Bool(x == y)),
323        (Neq, x, y) => Ok(Value::Bool(x != y)),
324        (Lt, Value::Int(x), Value::Int(y)) => Ok(Value::Bool(x < y)),
325        (Le, Value::Int(x), Value::Int(y)) => Ok(Value::Bool(x <= y)),
326        (Gt, Value::Int(x), Value::Int(y)) => Ok(Value::Bool(x > y)),
327        (Ge, Value::Int(x), Value::Int(y)) => Ok(Value::Bool(x >= y)),
328        (Lt, Value::Float(x), Value::Float(y)) => Ok(Value::Bool(x < y)),
329        (Le, Value::Float(x), Value::Float(y)) => Ok(Value::Bool(x <= y)),
330        (Gt, Value::Float(x), Value::Float(y)) => Ok(Value::Bool(x > y)),
331        (Ge, Value::Float(x), Value::Float(y)) => Ok(Value::Bool(x >= y)),
332        (And, Value::Bool(x), Value::Bool(y)) => Ok(Value::Bool(*x && *y)),
333        (Or, Value::Bool(x), Value::Bool(y)) => Ok(Value::Bool(*x || *y)),
334        _ => Err(format!("invalid binop {op:?} on {a:?}, {b:?}")),
335    }
336}
337
338fn bindings_to_json(b: &IndexMap<String, Value>) -> IndexMap<String, serde_json::Value> {
339    let mut out = IndexMap::new();
340    for (k, v) in b { out.insert(k.clone(), value_to_json(v)); }
341    out
342}
343
344fn value_to_json(v: &Value) -> serde_json::Value { v.to_json() }