Skip to main content

rml/
check.rs

1// rml-check — independent proof-replay checker (issue #36).
2//
3// Verifies that a derivation produced by the proof-producing evaluator
4// (issue #35) replays under the kernel alone — no evaluator. Each
5// `(by <rule> <sub>...)` node is matched against the kernel's structural
6// shape for its expression: rule name, arity, and that sub-derivations
7// recurse onto matching sub-expressions. Mutating any of those rejects.
8
9use crate::{is_num, is_structurally_same, key_of, parse_lino, parse_one, tokenize_one, Node};
10use std::collections::HashSet;
11
12#[derive(Debug, Clone, PartialEq)]
13pub struct CheckOk {
14    pub rule: String,
15    pub expr: String,
16}
17
18#[derive(Debug, Clone, PartialEq)]
19pub struct CheckError {
20    pub path: Vec<String>,
21    pub message: String,
22}
23
24#[derive(Debug, Clone, Default, PartialEq)]
25pub struct CheckResult {
26    pub ok: Vec<CheckOk>,
27    pub errors: Vec<CheckError>,
28}
29
30impl CheckResult {
31    pub fn is_ok(&self) -> bool {
32        self.errors.is_empty()
33    }
34}
35
36// Built-in operator names plus user-declared `(name: ...)` heads. Used to
37// validate the prefix-operator fallback rule. Mirrors the names recognised
38// by `Env::new()` so the checker can validate proofs without an env.
39fn collect_operators(forms: &[Node]) -> HashSet<String> {
40    let mut ops: HashSet<String> = ["not", "and", "or", "=", "!=", "+", "-", "*", "/"]
41        .iter()
42        .map(|s| s.to_string())
43        .collect();
44    for f in forms {
45        if let Node::List(c) = f {
46            if let Some(Node::Leaf(s)) = c.first() {
47                if let Some(name) = s.strip_suffix(':') {
48                    if !name.is_empty() {
49                        ops.insert(name.to_string());
50                    }
51                }
52            }
53        }
54    }
55    ops
56}
57
58// Equality keys (both prefix and infix shapes) that the program assigned a
59// probability to. Used by `expected_rule` to know whether `assigned-*`
60// rules are admissible at a given equality node.
61fn collect_assignments(forms: &[Node]) -> HashSet<String> {
62    let mut out = HashSet::new();
63    for f in forms {
64        if let Node::List(c) = f {
65            if c.len() == 4 {
66                if let (Node::Leaf(w1), Node::Leaf(w2), Node::Leaf(w3)) = (&c[1], &c[2], &c[3]) {
67                    if w1 == "has" && w2 == "probability" && is_num(w3) {
68                        if let Node::List(inner) = &c[0] {
69                            out.insert(key_of(&c[0]));
70                            if inner.len() == 3 {
71                                if let Node::Leaf(op) = &inner[1] {
72                                    if op == "=" {
73                                        out.insert(key_of(&Node::List(vec![
74                                            Node::Leaf("=".into()),
75                                            inner[0].clone(),
76                                            inner[2].clone(),
77                                        ])));
78                                    }
79                                }
80                            }
81                        }
82                    }
83                }
84            }
85        }
86    }
87    out
88}
89
90// Parse a `.lino` source into top-level forms via the kernel parser.
91fn parse_forms(src: &str) -> Vec<Node> {
92    parse_lino(src)
93        .into_iter()
94        .filter(|s| !s.trim_start().starts_with("(#"))
95        .filter_map(|s| parse_one(&tokenize_one(&s)).ok())
96        .collect()
97}
98
99// Strip `(? expr)` wrappers and the optional `with proof` keyword pair.
100fn query_target(n: &Node) -> Option<Node> {
101    if let Node::List(c) = n {
102        if let Some(Node::Leaf(h)) = c.first() {
103            if h == "?" {
104                let parts: Vec<Node> = c[1..].to_vec();
105                let parts = if parts.len() >= 2 {
106                    if let (Node::Leaf(a), Node::Leaf(b)) =
107                        (&parts[parts.len() - 2], &parts[parts.len() - 1])
108                    {
109                        if a == "with" && b == "proof" {
110                            parts[..parts.len() - 2].to_vec()
111                        } else {
112                            parts
113                        }
114                    } else {
115                        parts
116                    }
117                } else {
118                    parts
119                };
120                return Some(if parts.len() == 1 {
121                    parts[0].clone()
122                } else {
123                    Node::List(parts)
124                });
125            }
126        }
127    }
128    None
129}
130
131// Decompose `(by <rule> <sub>...)` or fail with a descriptive error.
132fn decode<'a>(p: &'a Node, path: &[String]) -> Result<(String, &'a [Node]), CheckError> {
133    if let Node::List(c) = p {
134        if c.len() >= 2 {
135            if let (Node::Leaf(by), Node::Leaf(rule)) = (&c[0], &c[1]) {
136                if by == "by" {
137                    return Ok((rule.clone(), &c[2..]));
138                }
139            }
140        }
141    }
142    Err(CheckError {
143        path: path.to_vec(),
144        message: format!("expected `(by <rule> ...)`, got `{}`", key_of(p)),
145    })
146}
147
148// The single rule the kernel would emit for `expr`. Equality is the only
149// shape with multiple admissible rules (assigned/structural/numeric); we
150// pick the unique one matching the program facts.
151fn expected_rule(expr: &Node, ops: &HashSet<String>, assigned: &HashSet<String>) -> &'static str {
152    match expr {
153        Node::Leaf(s) => {
154            if is_num(s) {
155                "literal"
156            } else {
157                "symbol"
158            }
159        }
160        Node::List(c) => {
161            if let Some(Node::Leaf(h)) = c.first() {
162                if h.ends_with(':') {
163                    return "definition";
164                }
165                match h.as_str() {
166                    "Type" if c.len() == 2 => return "type-universe",
167                    "Prop" if c.len() == 1 => return "prop",
168                    "Pi" if c.len() == 3 => return "pi-formation",
169                    "lambda" if c.len() == 3 => return "lambda-formation",
170                    "apply" if c.len() == 3 => return "beta-reduction",
171                    "subst" if c.len() == 4 => return "substitution",
172                    "fresh" if c.len() == 4 => {
173                        if let Node::Leaf(in_kw) = &c[2] {
174                            if in_kw == "in" {
175                                return "fresh";
176                            }
177                        }
178                    }
179                    "type" if c.len() == 3 => {
180                        if let Node::Leaf(of) = &c[1] {
181                            if of == "of" {
182                                return "type-query";
183                            }
184                        }
185                    }
186                    _ => {}
187                }
188            }
189            // ((expr) has probability p)
190            if c.len() == 4 {
191                if let (Node::Leaf(w1), Node::Leaf(w2), Node::Leaf(w3)) = (&c[1], &c[2], &c[3]) {
192                    if w1 == "has" && w2 == "probability" && is_num(w3) {
193                        return "assigned-probability";
194                    }
195                }
196            }
197            // (range lo hi) / (valence N)
198            if c.len() == 3 {
199                if let (Node::Leaf(h), Node::Leaf(a), Node::Leaf(b)) = (&c[0], &c[1], &c[2]) {
200                    if h == "range" && is_num(a) && is_num(b) {
201                        return "configuration";
202                    }
203                }
204            }
205            if c.len() == 2 {
206                if let (Node::Leaf(h), Node::Leaf(v)) = (&c[0], &c[1]) {
207                    if h == "valence" && is_num(v) {
208                        return "configuration";
209                    }
210                }
211            }
212            // (L op R) infix.
213            if c.len() == 3 {
214                if let Node::Leaf(op) = &c[1] {
215                    return match op.as_str() {
216                        "+" => "sum",
217                        "-" => "difference",
218                        "*" => "product",
219                        "/" => "quotient",
220                        "and" => "and",
221                        "or" => "or",
222                        "both" => "both",
223                        "neither" => "neither",
224                        "of" => "type-check",
225                        "=" | "!=" => {
226                            let l = &c[0];
227                            let r = &c[2];
228                            let kp = key_of(&Node::List(vec![
229                                Node::Leaf("=".into()),
230                                l.clone(),
231                                r.clone(),
232                            ]));
233                            let ki = key_of(&Node::List(vec![
234                                l.clone(),
235                                Node::Leaf("=".into()),
236                                r.clone(),
237                            ]));
238                            let assigned = assigned.contains(&kp) || assigned.contains(&ki);
239                            if op == "!=" {
240                                if assigned {
241                                    "assigned-inequality"
242                                } else if is_structurally_same(l, r) {
243                                    "structural-inequality"
244                                } else {
245                                    "numeric-inequality"
246                                }
247                            } else if assigned {
248                                "assigned-equality"
249                            } else if is_structurally_same(l, r) {
250                                "structural-equality"
251                            } else {
252                                "numeric-equality"
253                            }
254                        }
255                        _ => fallback_prefix(c, ops),
256                    };
257                }
258            }
259            // Composite (both A and B [...]) / (neither A nor B [...]).
260            if c.len() >= 4 && c.len() % 2 == 0 {
261                if let Node::Leaf(h) = &c[0] {
262                    if h == "both" {
263                        return "both";
264                    }
265                    if h == "neither" {
266                        return "neither";
267                    }
268                }
269            }
270            fallback_prefix(c, ops)
271        }
272    }
273}
274
275fn fallback_prefix(c: &[Node], ops: &HashSet<String>) -> &'static str {
276    if let Some(Node::Leaf(h)) = c.first() {
277        if ops.contains(h) {
278            return prefix_marker(h);
279        }
280    }
281    "reduce"
282}
283
284// Stable static slice for prefix-op rule names: the rule string is the op
285// name itself, so we look it up via `prefix_marker` to keep the return
286// type `&'static str`. Any unrecognised name falls back to `reduce`.
287fn prefix_marker(name: &str) -> &'static str {
288    match name {
289        "not" => "not",
290        "and" => "and",
291        "or" => "or",
292        "+" => "sum",
293        "-" => "difference",
294        "*" => "product",
295        "/" => "quotient",
296        // User-declared / less common prefix ops: report `reduce` so the
297        // checker's structural validator handles them via the prefix path.
298        _ => "reduce",
299    }
300}
301
302// Walk both trees in lockstep and verify each level matches.
303fn check_node(
304    expr: &Node,
305    proof: &Node,
306    ops: &HashSet<String>,
307    assigned: &HashSet<String>,
308    path: &[String],
309) -> Result<String, CheckError> {
310    let (rule, subs) = decode(proof, path)?;
311    if rule == "smt-trusted" {
312        return check_smt_trusted(&rule, subs, path);
313    }
314    let exp = expected_rule(expr, ops, assigned);
315    // For prefix-applied operators the kernel writes the op name itself
316    // (e.g. `not`, `and`, custom `myop`); accept either the marker we
317    // returned or the matching head leaf when expr is a prefix call.
318    let rule_ok = rule == exp || prefix_match(&rule, expr, ops);
319    if !rule_ok {
320        return Err(err(
321            path,
322            format!(
323                "rule `{}` does not justify `{}` (expected `{}`)",
324                rule,
325                key_of(expr),
326                exp
327            ),
328        ));
329    }
330    let mut next_path = path.to_vec();
331    next_path.push(format!("{}", rule));
332    match rule.as_str() {
333        // Leaves
334        "literal" => arity(&rule, subs, 1, path).and_then(|_| match (&subs[0], expr) {
335            (Node::Leaf(n), Node::Leaf(en)) if is_num(n) && n == en => Ok(rule),
336            _ => Err(err(
337                path,
338                format!("literal `{}` ≠ `{}`", key_of(&subs[0]), key_of(expr)),
339            )),
340        }),
341        "symbol" => arity(&rule, subs, 1, path).and_then(|_| match (&subs[0], expr) {
342            (Node::Leaf(s), Node::Leaf(es)) if s == es => Ok(rule),
343            _ => Err(err(
344                path,
345                format!("symbol `{}` ≠ `{}`", key_of(&subs[0]), key_of(expr)),
346            )),
347        }),
348        // Top-level leaf-like forms carry payloads that must match the
349        // expression exactly, even though there are no sub-derivations.
350        "definition" => check_payload(&rule, subs, &[expr.clone()], path),
351        "configuration" => check_configuration(expr, &rule, subs, path),
352        "assigned-probability" => check_assigned_probability(expr, &rule, subs, path),
353        "query" => Err(err(path, "stray `query` rule (stripped by checker)".into())),
354        "reduce" => check_payload(&rule, subs, &[expr.clone()], path),
355        // Infix arithmetic
356        "sum" | "difference" | "product" | "quotient" => {
357            let op = match rule.as_str() {
358                "sum" => "+",
359                "difference" => "-",
360                "product" => "*",
361                "quotient" => "/",
362                _ => unreachable!(),
363            };
364            check_infix(expr, &rule, subs, op, ops, assigned, &next_path, path)
365        }
366        // Logic — binary infix or composite chain
367        "and" | "or" | "both" | "neither" => {
368            check_logic(expr, &rule, subs, ops, assigned, &next_path, path)
369        }
370        // Unary not. Composite chains for `not` aren't a thing; only (not X).
371        "not" => arity(&rule, subs, 1, path).and_then(|_| match expr {
372            Node::List(c) if c.len() == 2 => match &c[0] {
373                Node::Leaf(h) if h == "not" => {
374                    check_node(&c[1], &subs[0], ops, assigned, &next_path).map(|_| rule.clone())
375                }
376                _ => Err(err(path, format!("`not` ≠ `{}`", key_of(expr)))),
377            },
378            _ => Err(err(path, format!("`not` ≠ `{}`", key_of(expr)))),
379        }),
380        // Equality / inequality
381        "structural-equality"
382        | "numeric-equality"
383        | "assigned-equality"
384        | "structural-inequality"
385        | "numeric-inequality"
386        | "assigned-inequality" => check_eq(expr, &rule, subs, ops, assigned, path),
387        // Type-system witnesses
388        "type-universe" | "prop" | "pi-formation" | "lambda-formation" | "type-query"
389        | "type-check" | "substitution" | "fresh" => check_typesys(expr, &rule, subs, path),
390        "beta-reduction" => arity(&rule, subs, 2, path).and_then(|_| match expr {
391            Node::List(c) if c.len() == 3 => match &c[0] {
392                Node::Leaf(h) if h == "apply" => {
393                    check_node(&c[1], &subs[0], ops, assigned, &next_path)?;
394                    check_node(&c[2], &subs[1], ops, assigned, &next_path)?;
395                    Ok(rule.clone())
396                }
397                _ => Err(err(path, format!("`beta-reduction` ≠ `{}`", key_of(expr)))),
398            },
399            _ => Err(err(path, format!("`beta-reduction` ≠ `{}`", key_of(expr)))),
400        }),
401        // Prefix operator named after the rule.
402        _ => check_prefix(expr, &rule, subs, ops, assigned, &next_path, path),
403    }
404}
405
406fn check_smt_trusted(
407    rule: &str,
408    subs: &[Node],
409    path: &[String],
410) -> Result<String, CheckError> {
411    arity(rule, subs, 1, path)?;
412    match &subs[0] {
413        Node::Leaf(solver) if !solver.is_empty() => Ok(rule.to_string()),
414        _ => Err(err(
415            path,
416            "`smt-trusted` expects a solver name payload".to_string(),
417        )),
418    }
419}
420
421fn err(path: &[String], message: String) -> CheckError {
422    CheckError {
423        path: path.to_vec(),
424        message,
425    }
426}
427
428fn arity(rule: &str, subs: &[Node], n: usize, path: &[String]) -> Result<(), CheckError> {
429    if subs.len() == n {
430        Ok(())
431    } else {
432        Err(err(
433            path,
434            format!("rule `{}` expects {} sub(s), got {}", rule, n, subs.len()),
435        ))
436    }
437}
438
439fn check_payload(
440    rule: &str,
441    subs: &[Node],
442    expected: &[Node],
443    path: &[String],
444) -> Result<String, CheckError> {
445    arity(rule, subs, expected.len(), path)?;
446    for (i, want) in expected.iter().enumerate() {
447        if !is_structurally_same(&subs[i], want) {
448            return Err(err(
449                path,
450                format!("payload {} `{}` ≠ `{}`", i, key_of(&subs[i]), key_of(want)),
451            ));
452        }
453    }
454    Ok(rule.into())
455}
456
457fn check_configuration(
458    expr: &Node,
459    rule: &str,
460    subs: &[Node],
461    path: &[String],
462) -> Result<String, CheckError> {
463    match expr {
464        Node::List(c) if c.len() == 3 => match (&c[0], &c[1], &c[2]) {
465            (Node::Leaf(h), lo, hi) if h == "range" => check_payload(
466                rule,
467                subs,
468                &[Node::Leaf("range".into()), lo.clone(), hi.clone()],
469                path,
470            ),
471            _ => Err(err(path, format!("`{}` ≠ `{}`", rule, key_of(expr)))),
472        },
473        Node::List(c) if c.len() == 2 => match (&c[0], &c[1]) {
474            (Node::Leaf(h), v) if h == "valence" => {
475                check_payload(rule, subs, &[Node::Leaf("valence".into()), v.clone()], path)
476            }
477            _ => Err(err(path, format!("`{}` ≠ `{}`", rule, key_of(expr)))),
478        },
479        _ => Err(err(path, format!("`{}` ≠ `{}`", rule, key_of(expr)))),
480    }
481}
482
483fn check_assigned_probability(
484    expr: &Node,
485    rule: &str,
486    subs: &[Node],
487    path: &[String],
488) -> Result<String, CheckError> {
489    if let Node::List(c) = expr {
490        if c.len() == 4 {
491            if let (Node::Leaf(w1), Node::Leaf(w2)) = (&c[1], &c[2]) {
492                if w1 == "has" && w2 == "probability" {
493                    return check_payload(rule, subs, &[c[0].clone(), c[3].clone()], path);
494                }
495            }
496        }
497    }
498    Err(err(path, format!("`{}` ≠ `{}`", rule, key_of(expr))))
499}
500
501// True when `rule` is the bare name of a prefix operator applied at expr.
502fn prefix_match(rule: &str, expr: &Node, ops: &HashSet<String>) -> bool {
503    if !ops.contains(rule) {
504        return false;
505    }
506    if let Node::List(c) = expr {
507        if let Some(Node::Leaf(h)) = c.first() {
508            return h == rule;
509        }
510    }
511    false
512}
513
514fn check_infix(
515    expr: &Node,
516    rule: &str,
517    subs: &[Node],
518    op: &str,
519    ops: &HashSet<String>,
520    assigned: &HashSet<String>,
521    next_path: &[String],
522    path: &[String],
523) -> Result<String, CheckError> {
524    arity(rule, subs, 2, path)?;
525    if let Node::List(c) = expr {
526        if c.len() == 3 {
527            if let Node::Leaf(o) = &c[1] {
528                if o == op {
529                    check_node(&c[0], &subs[0], ops, assigned, next_path)?;
530                    check_node(&c[2], &subs[1], ops, assigned, next_path)?;
531                    return Ok(rule.into());
532                }
533            }
534        }
535    }
536    Err(err(path, format!("rule `{}` ≠ `{}`", rule, key_of(expr))))
537}
538
539fn check_logic(
540    expr: &Node,
541    rule: &str,
542    subs: &[Node],
543    ops: &HashSet<String>,
544    assigned: &HashSet<String>,
545    next_path: &[String],
546    path: &[String],
547) -> Result<String, CheckError> {
548    if let Node::List(c) = expr {
549        // Binary infix.
550        if c.len() == 3 {
551            if let Node::Leaf(o) = &c[1] {
552                if o == rule {
553                    arity(rule, subs, 2, path)?;
554                    check_node(&c[0], &subs[0], ops, assigned, next_path)?;
555                    check_node(&c[2], &subs[1], ops, assigned, next_path)?;
556                    return Ok(rule.into());
557                }
558            }
559        }
560        // Composite chain.
561        if (rule == "both" || rule == "neither") && c.len() >= 4 && c.len() % 2 == 0 {
562            if let Node::Leaf(h) = &c[0] {
563                if h == rule {
564                    let sep = if rule == "both" { "and" } else { "nor" };
565                    for i in (2..c.len()).step_by(2) {
566                        if let Node::Leaf(s) = &c[i] {
567                            if s != sep {
568                                return Err(err(
569                                    path,
570                                    format!("composite `{}` separator must be `{}`", rule, sep),
571                                ));
572                            }
573                        } else {
574                            return Err(err(
575                                path,
576                                format!("composite `{}` separator must be `{}`", rule, sep),
577                            ));
578                        }
579                    }
580                    let n = c.len() / 2;
581                    arity(rule, subs, n, path)?;
582                    for (j, sub) in subs.iter().enumerate() {
583                        check_node(&c[1 + j * 2], sub, ops, assigned, next_path)?;
584                    }
585                    return Ok(rule.into());
586                }
587            }
588        }
589    }
590    Err(err(path, format!("rule `{}` ≠ `{}`", rule, key_of(expr))))
591}
592
593fn check_eq(
594    expr: &Node,
595    rule: &str,
596    subs: &[Node],
597    ops: &HashSet<String>,
598    assigned: &HashSet<String>,
599    path: &[String],
600) -> Result<String, CheckError> {
601    arity(rule, subs, 1, path)?;
602    let pair = match &subs[0] {
603        Node::List(p) if p.len() == 2 => p,
604        _ => return Err(err(path, format!("`{}` expects `(L R)` sub", rule))),
605    };
606    if let Node::List(c) = expr {
607        if c.len() == 3 {
608            if let Node::Leaf(op) = &c[1] {
609                if op == "=" || op == "!=" {
610                    if !is_structurally_same(&c[0], &pair[0])
611                        || !is_structurally_same(&c[2], &pair[1])
612                    {
613                        return Err(err(
614                            path,
615                            format!(
616                                "operands `{} {}` ≠ `{} {}`",
617                                key_of(&pair[0]),
618                                key_of(&pair[1]),
619                                key_of(&c[0]),
620                                key_of(&c[2])
621                            ),
622                        ));
623                    }
624                    let exp = expected_rule(expr, ops, assigned);
625                    if exp == rule {
626                        return Ok(rule.into());
627                    }
628                    return Err(err(path, format!("rule `{}` ≠ expected `{}`", rule, exp)));
629                }
630            }
631        }
632    }
633    Err(err(path, format!("rule `{}` ≠ `{}`", rule, key_of(expr))))
634}
635
636fn check_typesys(
637    expr: &Node,
638    rule: &str,
639    subs: &[Node],
640    path: &[String],
641) -> Result<String, CheckError> {
642    let bad = |reason: &str| {
643        Err(err(
644            path,
645            format!("`{}` ≠ `{}` ({})", rule, key_of(expr), reason),
646        ))
647    };
648    if let Node::List(c) = expr {
649        match (rule, c.len()) {
650            ("type-universe", 2) => {
651                arity(rule, subs, 1, path)?;
652                if let Node::Leaf(h) = &c[0] {
653                    if h == "Type" && is_structurally_same(&c[1], &subs[0]) {
654                        return Ok(rule.into());
655                    }
656                }
657                return bad("Type level mismatch");
658            }
659            ("prop", 1) => {
660                arity(rule, subs, 0, path)?;
661                if let Node::Leaf(h) = &c[0] {
662                    if h == "Prop" {
663                        return Ok(rule.into());
664                    }
665                }
666                return bad("Prop head mismatch");
667            }
668            ("pi-formation", 3) | ("lambda-formation", 3) => {
669                arity(rule, subs, 2, path)?;
670                let head = if rule == "pi-formation" {
671                    "Pi"
672                } else {
673                    "lambda"
674                };
675                if let Node::Leaf(h) = &c[0] {
676                    if h == head
677                        && is_structurally_same(&c[1], &subs[0])
678                        && is_structurally_same(&c[2], &subs[1])
679                    {
680                        return Ok(rule.into());
681                    }
682                }
683                return bad("binder mismatch");
684            }
685            ("type-query", 3) => {
686                arity(rule, subs, 1, path)?;
687                if let (Node::Leaf(h), Node::Leaf(of)) = (&c[0], &c[1]) {
688                    if h == "type" && of == "of" && is_structurally_same(&c[2], &subs[0]) {
689                        return Ok(rule.into());
690                    }
691                }
692                return bad("type-query mismatch");
693            }
694            ("type-check", 3) => {
695                arity(rule, subs, 2, path)?;
696                if let Node::Leaf(of) = &c[1] {
697                    if of == "of"
698                        && is_structurally_same(&c[0], &subs[0])
699                        && is_structurally_same(&c[2], &subs[1])
700                    {
701                        return Ok(rule.into());
702                    }
703                }
704                return bad("type-check mismatch");
705            }
706            ("substitution", 4) => {
707                arity(rule, subs, 3, path)?;
708                if let Node::Leaf(h) = &c[0] {
709                    if h == "subst"
710                        && is_structurally_same(&c[1], &subs[0])
711                        && is_structurally_same(&c[2], &subs[1])
712                        && is_structurally_same(&c[3], &subs[2])
713                    {
714                        return Ok(rule.into());
715                    }
716                }
717                return bad("substitution mismatch");
718            }
719            ("fresh", 4) => {
720                arity(rule, subs, 2, path)?;
721                if let (Node::Leaf(h), Node::Leaf(in_kw)) = (&c[0], &c[2]) {
722                    if h == "fresh"
723                        && in_kw == "in"
724                        && is_structurally_same(&c[1], &subs[0])
725                        && is_structurally_same(&c[3], &subs[1])
726                    {
727                        return Ok(rule.into());
728                    }
729                }
730                return bad("fresh mismatch");
731            }
732            _ => {}
733        }
734    }
735    bad("shape mismatch")
736}
737
738fn check_prefix(
739    expr: &Node,
740    rule: &str,
741    subs: &[Node],
742    ops: &HashSet<String>,
743    assigned: &HashSet<String>,
744    next_path: &[String],
745    path: &[String],
746) -> Result<String, CheckError> {
747    if let Node::List(c) = expr {
748        if let Some(Node::Leaf(h)) = c.first() {
749            if h == rule && ops.contains(rule) {
750                arity(rule, subs, c.len() - 1, path)?;
751                for (i, sub) in subs.iter().enumerate() {
752                    check_node(&c[1 + i], sub, ops, assigned, next_path)?;
753                }
754                return Ok(rule.into());
755            }
756        }
757    }
758    Err(err(
759        path,
760        format!("unknown rule `{}` for `{}`", rule, key_of(expr)),
761    ))
762}
763
764/// Public entry point: parse both `.lino` sources, pair queries with
765/// derivations 1:1, and verify each pair structurally. Returns a
766/// `CheckResult` with one `CheckOk` per replayed derivation or a list of
767/// `CheckError`s describing the first divergence per query.
768pub fn check_program(program_src: &str, proofs_src: &str) -> CheckResult {
769    let program_forms = parse_forms(program_src);
770    let proof_forms = parse_forms(proofs_src);
771    let queries: Vec<Node> = program_forms.iter().filter_map(query_target).collect();
772    let ops = collect_operators(&program_forms);
773    let assigned = collect_assignments(&program_forms);
774    let mut result = CheckResult::default();
775    if queries.len() != proof_forms.len() {
776        result.errors.push(CheckError {
777            path: vec![],
778            message: format!(
779                "expected {} derivation(s), got {}",
780                queries.len(),
781                proof_forms.len()
782            ),
783        });
784        return result;
785    }
786    for (i, (q, p)) in queries.iter().zip(proof_forms.iter()).enumerate() {
787        let path = vec![format!("query[{}]", i)];
788        match check_node(q, p, &ops, &assigned, &path) {
789            Ok(rule) => result.ok.push(CheckOk {
790                rule,
791                expr: key_of(q),
792            }),
793            Err(e) => result.errors.push(e),
794        }
795    }
796    result
797}
798
799#[cfg(test)]
800mod sanity {
801    // Real coverage in `rust/tests/check_tests.rs`. These confirm the
802    // checker compiles against the kernel without pulling the evaluator.
803    use super::*;
804
805    #[test]
806    fn smoke_structural_equality() {
807        assert!(
808            check_program("(a: a is a)\n(? (a = a))", "(by structural-equality (a a))").is_ok()
809        );
810    }
811
812    #[test]
813    fn smoke_mutated_rule_fails() {
814        assert!(!check_program("(a: a is a)\n(? (a = a))", "(by numeric-equality (a a))").is_ok());
815    }
816}