Skip to main content

ipfrs_tensorlogic/
proof_verifier.rs

1//! Proof Verifier
2//!
3//! Verifies serialized proof trees by re-checking each node's rule application.
4//! Uses memoization to avoid re-verifying shared sub-proofs, and detects
5//! cycles via an in-progress tracking set during DFS traversal.
6//!
7//! # Examples
8//!
9//! ```
10//! use ipfrs_tensorlogic::proof_verifier::{ProofNode, ProofVerifier, RuleSpec};
11//!
12//! let mut verifier = ProofVerifier::new();
13//!
14//! // Register an axiom rule (arity = 0, no premises required)
15//! verifier.register_rule(RuleSpec {
16//!     rule_id: "axiom".to_string(),
17//!     head_pattern: "fact:".to_string(),
18//!     arity: 0,
19//! });
20//!
21//! // A single axiom node
22//! let nodes = vec![ProofNode {
23//!     node_id: 1,
24//!     rule_id: "axiom".to_string(),
25//!     goal: "fact:alice-is-human".to_string(),
26//!     premise_ids: vec![],
27//!     depth: 0,
28//! }];
29//!
30//! let result = verifier.verify(&nodes);
31//! assert!(result.is_valid());
32//! assert_eq!(result.nodes_checked, 1);
33//! ```
34
35use std::collections::{HashMap, HashSet};
36
37use thiserror::Error;
38
39// ─── Errors ──────────────────────────────────────────────────────────────────
40
41/// Errors produced during proof verification.
42#[derive(Debug, Error, Clone, PartialEq, Eq)]
43pub enum VerificationError {
44    /// The rule ID referenced by a node is not registered.
45    #[error("unknown rule: {0}")]
46    UnknownRule(String),
47
48    /// The node's goal does not match the rule's head pattern.
49    #[error("goal mismatch at node {node_id}: expected pattern {expected:?}, got {actual:?}")]
50    GoalMismatch {
51        node_id: u64,
52        expected: String,
53        actual: String,
54    },
55
56    /// The number of premises at a node does not match the rule's arity.
57    #[error("premise mismatch at node {node_id}, premise index {index}")]
58    PremiseMismatch { node_id: u64, index: usize },
59
60    /// A cycle was detected in the proof tree at the given node.
61    #[error("cyclic proof detected at node {0}")]
62    CyclicProof(u64),
63
64    /// The proof contains no nodes.
65    #[error("proof is empty")]
66    EmptyProof,
67}
68
69// ─── Result ──────────────────────────────────────────────────────────────────
70
71/// Summary of a completed proof verification pass.
72#[derive(Debug, Clone)]
73pub struct VerificationResult {
74    /// Whether verification completed without encountering any errors.
75    pub verified: bool,
76
77    /// Total number of nodes that were actively checked.
78    pub nodes_checked: usize,
79
80    /// Number of nodes whose result was served from the memo cache (cache hits).
81    pub nodes_memoized: usize,
82
83    /// Maximum proof tree depth encountered during traversal.
84    pub max_depth: usize,
85
86    /// All errors collected during verification.
87    pub errors: Vec<VerificationError>,
88}
89
90impl VerificationResult {
91    /// Returns `true` iff the proof is verified and no errors were collected.
92    pub fn is_valid(&self) -> bool {
93        self.verified && self.errors.is_empty()
94    }
95}
96
97// ─── Proof node ──────────────────────────────────────────────────────────────
98
99/// A single node in a serialized proof tree.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct ProofNode {
102    /// Unique identifier for this node.
103    pub node_id: u64,
104
105    /// Identifier of the inference rule applied at this node.
106    pub rule_id: String,
107
108    /// The logical goal that this node proves.
109    pub goal: String,
110
111    /// IDs of the premise (child) nodes consumed by the rule application.
112    pub premise_ids: Vec<u64>,
113
114    /// Depth of this node in the proof tree (root = 0).
115    pub depth: usize,
116}
117
118// ─── Rule spec ───────────────────────────────────────────────────────────────
119
120/// Specification of an inference rule used to validate proof nodes.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct RuleSpec {
123    /// Unique identifier for this rule.
124    pub rule_id: String,
125
126    /// Prefix pattern that the goal must start with for this rule to apply.
127    pub head_pattern: String,
128
129    /// Expected number of premises (children) for this rule.
130    pub arity: usize,
131}
132
133// ─── Verifier ────────────────────────────────────────────────────────────────
134
135/// Verifies serialized proof trees by re-checking each node's rule application.
136///
137/// Maintains a memoization cache so that shared sub-proofs (DAG structure) are
138/// not re-verified on subsequent traversals.  A fresh [`ProofVerifier`] starts
139/// with an empty rule registry and an empty memo cache.
140pub struct ProofVerifier {
141    /// Registered inference rules, keyed by rule ID.
142    pub rules: HashMap<String, RuleSpec>,
143
144    /// Memoized verification results: `node_id → verified`.
145    pub memo: HashMap<u64, bool>,
146
147    /// Nodes currently on the DFS stack — used for cycle detection.
148    pub in_progress: HashSet<u64>,
149}
150
151impl ProofVerifier {
152    /// Create a new, empty `ProofVerifier`.
153    pub fn new() -> Self {
154        Self {
155            rules: HashMap::new(),
156            memo: HashMap::new(),
157            in_progress: HashSet::new(),
158        }
159    }
160
161    /// Register an inference rule.  Replaces any existing rule with the same ID.
162    pub fn register_rule(&mut self, spec: RuleSpec) {
163        self.rules.insert(spec.rule_id.clone(), spec);
164    }
165
166    /// Verify a slice of [`ProofNode`]s.
167    ///
168    /// Identifies the root as the node whose ID does not appear as any other
169    /// node's premise.  If there is no unique root, the first node is used.
170    ///
171    /// DFS from the root re-checks every rule application.  Results are
172    /// memoized so shared sub-proofs are counted as cache hits.
173    pub fn verify(&mut self, nodes: &[ProofNode]) -> VerificationResult {
174        if nodes.is_empty() {
175            return VerificationResult {
176                verified: false,
177                nodes_checked: 0,
178                nodes_memoized: 0,
179                max_depth: 0,
180                errors: vec![VerificationError::EmptyProof],
181            };
182        }
183
184        // Build a quick lookup map.
185        let node_map: HashMap<u64, &ProofNode> = nodes.iter().map(|n| (n.node_id, n)).collect();
186
187        // Find the root: node whose ID does not appear as any premise.
188        let all_premise_ids: HashSet<u64> = nodes
189            .iter()
190            .flat_map(|n| n.premise_ids.iter().copied())
191            .collect();
192
193        let roots: Vec<u64> = nodes
194            .iter()
195            .map(|n| n.node_id)
196            .filter(|id| !all_premise_ids.contains(id))
197            .collect();
198
199        let root_id = if roots.len() == 1 {
200            roots[0]
201        } else {
202            // No unique root — fall back to first node.
203            nodes[0].node_id
204        };
205
206        // Accumulators shared across recursive calls via owned state on `self`.
207        let mut errors: Vec<VerificationError> = Vec::new();
208        let mut nodes_checked: usize = 0;
209        let mut nodes_memoized: usize = 0;
210        let mut max_depth: usize = 0;
211
212        let ok = self.verify_node(
213            root_id,
214            &node_map,
215            &mut errors,
216            &mut nodes_checked,
217            &mut nodes_memoized,
218            &mut max_depth,
219        );
220
221        // Clean up in-progress set (should already be empty after DFS).
222        self.in_progress.clear();
223
224        VerificationResult {
225            verified: ok,
226            nodes_checked,
227            nodes_memoized,
228            max_depth,
229            errors,
230        }
231    }
232
233    /// Recursive DFS verification for a single node.
234    ///
235    /// Returns `true` if this subtree is valid.
236    fn verify_node(
237        &mut self,
238        node_id: u64,
239        node_map: &HashMap<u64, &ProofNode>,
240        errors: &mut Vec<VerificationError>,
241        nodes_checked: &mut usize,
242        nodes_memoized: &mut usize,
243        max_depth: &mut usize,
244    ) -> bool {
245        // Cycle detection.
246        if self.in_progress.contains(&node_id) {
247            errors.push(VerificationError::CyclicProof(node_id));
248            return false;
249        }
250
251        // Memo cache hit.
252        if let Some(&cached) = self.memo.get(&node_id) {
253            *nodes_memoized += 1;
254            return cached;
255        }
256
257        // Retrieve the node.
258        let node = match node_map.get(&node_id) {
259            Some(n) => *n,
260            None => {
261                // Referenced node not found — treat as a failed check.
262                errors.push(VerificationError::PremiseMismatch { node_id, index: 0 });
263                self.memo.insert(node_id, false);
264                return false;
265            }
266        };
267
268        // Update max depth.
269        if node.depth > *max_depth {
270            *max_depth = node.depth;
271        }
272
273        // Mark as in-progress.
274        self.in_progress.insert(node_id);
275        *nodes_checked += 1;
276
277        // Look up the rule.
278        let rule = match self.rules.get(&node.rule_id) {
279            Some(r) => r.clone(), // clone to avoid borrow conflict
280            None => {
281                errors.push(VerificationError::UnknownRule(node.rule_id.clone()));
282                self.in_progress.remove(&node_id);
283                self.memo.insert(node_id, false);
284                return false;
285            }
286        };
287
288        // Check goal matches head pattern.
289        if !node.goal.starts_with(&rule.head_pattern) {
290            errors.push(VerificationError::GoalMismatch {
291                node_id,
292                expected: rule.head_pattern.clone(),
293                actual: node.goal.clone(),
294            });
295            self.in_progress.remove(&node_id);
296            self.memo.insert(node_id, false);
297            return false;
298        }
299
300        // Check arity (number of premises).
301        if node.premise_ids.len() != rule.arity {
302            // Report a PremiseMismatch at the first mismatched index.
303            let index = node.premise_ids.len().min(rule.arity);
304            errors.push(VerificationError::PremiseMismatch { node_id, index });
305            self.in_progress.remove(&node_id);
306            self.memo.insert(node_id, false);
307            return false;
308        }
309
310        // Recurse into premises.
311        let mut all_ok = true;
312        let premise_ids: Vec<u64> = node.premise_ids.clone();
313        for (i, &premise_id) in premise_ids.iter().enumerate() {
314            if !node_map.contains_key(&premise_id) {
315                errors.push(VerificationError::PremiseMismatch { node_id, index: i });
316                all_ok = false;
317            } else {
318                let premise_ok = self.verify_node(
319                    premise_id,
320                    node_map,
321                    errors,
322                    nodes_checked,
323                    nodes_memoized,
324                    max_depth,
325                );
326                if !premise_ok {
327                    all_ok = false;
328                }
329            }
330        }
331
332        self.in_progress.remove(&node_id);
333        self.memo.insert(node_id, all_ok);
334        all_ok
335    }
336
337    /// Reset memo cache and in-progress set.
338    pub fn clear_memo(&mut self) {
339        self.memo.clear();
340        self.in_progress.clear();
341    }
342
343    /// Returns `(rules_registered, memo_entries)`.
344    pub fn stats(&self) -> (usize, usize) {
345        (self.rules.len(), self.memo.len())
346    }
347}
348
349impl Default for ProofVerifier {
350    fn default() -> Self {
351        Self::new()
352    }
353}
354
355// ─── Tests ───────────────────────────────────────────────────────────────────
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    /// Helper to build a verifier with common rules.
362    fn make_verifier() -> ProofVerifier {
363        let mut v = ProofVerifier::new();
364        // axiom: no premises, goal must start with "fact:"
365        v.register_rule(RuleSpec {
366            rule_id: "axiom".to_string(),
367            head_pattern: "fact:".to_string(),
368            arity: 0,
369        });
370        // modus_ponens: 1 premise, goal must start with "derived:"
371        v.register_rule(RuleSpec {
372            rule_id: "modus_ponens".to_string(),
373            head_pattern: "derived:".to_string(),
374            arity: 1,
375        });
376        // conjunction: 2 premises, goal must start with "and:"
377        v.register_rule(RuleSpec {
378            rule_id: "conjunction".to_string(),
379            head_pattern: "and:".to_string(),
380            arity: 2,
381        });
382        v
383    }
384
385    // 1. Empty proof returns EmptyProof error.
386    #[test]
387    fn test_empty_proof() {
388        let mut v = make_verifier();
389        let result = v.verify(&[]);
390        assert!(!result.is_valid());
391        assert_eq!(result.errors.len(), 1);
392        assert!(matches!(result.errors[0], VerificationError::EmptyProof));
393    }
394
395    // 2. Single axiom node (no premises) is verified successfully.
396    #[test]
397    fn test_single_axiom() {
398        let mut v = make_verifier();
399        let nodes = vec![ProofNode {
400            node_id: 1,
401            rule_id: "axiom".to_string(),
402            goal: "fact:alice-is-human".to_string(),
403            premise_ids: vec![],
404            depth: 0,
405        }];
406        let result = v.verify(&nodes);
407        assert!(result.is_valid(), "{:?}", result.errors);
408        assert_eq!(result.nodes_checked, 1);
409        assert_eq!(result.nodes_memoized, 0);
410        assert_eq!(result.max_depth, 0);
411    }
412
413    // 3. Two-node proof (rule with 1 premise) is verified.
414    #[test]
415    fn test_two_node_proof() {
416        let mut v = make_verifier();
417        let nodes = vec![
418            ProofNode {
419                node_id: 1,
420                rule_id: "axiom".to_string(),
421                goal: "fact:alice-is-human".to_string(),
422                premise_ids: vec![],
423                depth: 1,
424            },
425            ProofNode {
426                node_id: 2,
427                rule_id: "modus_ponens".to_string(),
428                goal: "derived:alice-is-mortal".to_string(),
429                premise_ids: vec![1],
430                depth: 0,
431            },
432        ];
433        let result = v.verify(&nodes);
434        assert!(result.is_valid(), "{:?}", result.errors);
435        assert_eq!(result.nodes_checked, 2);
436        assert_eq!(result.max_depth, 1);
437    }
438
439    // 4. Three-level chain is verified.
440    #[test]
441    fn test_three_level_chain() {
442        let mut v = make_verifier();
443        // chain: axiom(1) -> modus_ponens(2) -> modus_ponens(3)
444        let nodes = vec![
445            ProofNode {
446                node_id: 1,
447                rule_id: "axiom".to_string(),
448                goal: "fact:base".to_string(),
449                premise_ids: vec![],
450                depth: 2,
451            },
452            ProofNode {
453                node_id: 2,
454                rule_id: "modus_ponens".to_string(),
455                goal: "derived:mid".to_string(),
456                premise_ids: vec![1],
457                depth: 1,
458            },
459            ProofNode {
460                node_id: 3,
461                rule_id: "modus_ponens".to_string(),
462                goal: "derived:top".to_string(),
463                premise_ids: vec![2],
464                depth: 0,
465            },
466        ];
467        let result = v.verify(&nodes);
468        assert!(result.is_valid(), "{:?}", result.errors);
469        assert_eq!(result.nodes_checked, 3);
470        assert_eq!(result.max_depth, 2);
471    }
472
473    // 5. Unknown rule returns UnknownRule error.
474    #[test]
475    fn test_unknown_rule() {
476        let mut v = make_verifier();
477        let nodes = vec![ProofNode {
478            node_id: 1,
479            rule_id: "no_such_rule".to_string(),
480            goal: "fact:something".to_string(),
481            premise_ids: vec![],
482            depth: 0,
483        }];
484        let result = v.verify(&nodes);
485        assert!(!result.is_valid());
486        assert!(result.errors.iter().any(|e| matches!(
487            e,
488            VerificationError::UnknownRule(id) if id == "no_such_rule"
489        )));
490    }
491
492    // 6. Arity mismatch returns PremiseMismatch error.
493    #[test]
494    fn test_arity_mismatch() {
495        let mut v = make_verifier();
496        // modus_ponens expects 1 premise but we give 0.
497        let nodes = vec![ProofNode {
498            node_id: 1,
499            rule_id: "modus_ponens".to_string(),
500            goal: "derived:something".to_string(),
501            premise_ids: vec![], // wrong: should be 1 premise
502            depth: 0,
503        }];
504        let result = v.verify(&nodes);
505        assert!(!result.is_valid());
506        assert!(result
507            .errors
508            .iter()
509            .any(|e| matches!(e, VerificationError::PremiseMismatch { node_id: 1, .. })));
510    }
511
512    // 7. Goal mismatch returns GoalMismatch error.
513    #[test]
514    fn test_goal_mismatch() {
515        let mut v = make_verifier();
516        let nodes = vec![ProofNode {
517            node_id: 1,
518            rule_id: "axiom".to_string(),
519            goal: "wrong_prefix:something".to_string(), // axiom expects "fact:"
520            premise_ids: vec![],
521            depth: 0,
522        }];
523        let result = v.verify(&nodes);
524        assert!(!result.is_valid());
525        assert!(result
526            .errors
527            .iter()
528            .any(|e| matches!(e, VerificationError::GoalMismatch { node_id: 1, .. })));
529    }
530
531    // 8. Cycle detection returns CyclicProof error.
532    #[test]
533    fn test_cycle_detection() {
534        let mut v = make_verifier();
535        // node 1 has premise 2, and node 2 has premise 1 — a cycle.
536        // We need a rule with arity 1.
537        let nodes = vec![
538            ProofNode {
539                node_id: 1,
540                rule_id: "modus_ponens".to_string(),
541                goal: "derived:a".to_string(),
542                premise_ids: vec![2],
543                depth: 0,
544            },
545            ProofNode {
546                node_id: 2,
547                rule_id: "modus_ponens".to_string(),
548                goal: "derived:b".to_string(),
549                premise_ids: vec![1],
550                depth: 1,
551            },
552        ];
553        let result = v.verify(&nodes);
554        assert!(!result.is_valid());
555        assert!(result
556            .errors
557            .iter()
558            .any(|e| matches!(e, VerificationError::CyclicProof(_))));
559    }
560
561    // 9. Memo cache hits are counted correctly.
562    #[test]
563    fn test_memo_cache_hits() {
564        let mut v = make_verifier();
565        // Diamond DAG: root(3) has premises [1, 2]; both 1 and 2 share premise 0.
566        // But ProofNode.premise_ids are IDs, and `verify` traverses from root only.
567        // We create: axiom(0), mp(1, premise=0), mp(2, premise=0), conj(3, premises=[1,2]).
568        let nodes = vec![
569            ProofNode {
570                node_id: 0,
571                rule_id: "axiom".to_string(),
572                goal: "fact:shared".to_string(),
573                premise_ids: vec![],
574                depth: 2,
575            },
576            ProofNode {
577                node_id: 1,
578                rule_id: "modus_ponens".to_string(),
579                goal: "derived:left".to_string(),
580                premise_ids: vec![0],
581                depth: 1,
582            },
583            ProofNode {
584                node_id: 2,
585                rule_id: "modus_ponens".to_string(),
586                goal: "derived:right".to_string(),
587                premise_ids: vec![0],
588                depth: 1,
589            },
590            ProofNode {
591                node_id: 3,
592                rule_id: "conjunction".to_string(),
593                goal: "and:both".to_string(),
594                premise_ids: vec![1, 2],
595                depth: 0,
596            },
597        ];
598        let result = v.verify(&nodes);
599        assert!(result.is_valid(), "{:?}", result.errors);
600        // Node 0 is reached via node 1 first (checked), then via node 2 (memoized).
601        assert_eq!(result.nodes_memoized, 1);
602        // nodes_checked = 4 (3, 1, 0, 2) — 0 is checked once, memoized once.
603        assert_eq!(result.nodes_checked, 4);
604    }
605
606    // 10. clear_memo resets memo and in_progress state.
607    #[test]
608    fn test_clear_memo() {
609        let mut v = make_verifier();
610        let nodes = vec![ProofNode {
611            node_id: 42,
612            rule_id: "axiom".to_string(),
613            goal: "fact:x".to_string(),
614            premise_ids: vec![],
615            depth: 0,
616        }];
617        let _ = v.verify(&nodes);
618        assert!(!v.memo.is_empty());
619
620        v.clear_memo();
621        assert!(v.memo.is_empty());
622        assert!(v.in_progress.is_empty());
623    }
624
625    // 11. stats() returns correct counts.
626    #[test]
627    fn test_stats() {
628        let mut v = make_verifier();
629        let (rules, memo) = v.stats();
630        assert_eq!(rules, 3); // axiom, modus_ponens, conjunction
631        assert_eq!(memo, 0);
632
633        let nodes = vec![ProofNode {
634            node_id: 99,
635            rule_id: "axiom".to_string(),
636            goal: "fact:y".to_string(),
637            premise_ids: vec![],
638            depth: 0,
639        }];
640        let _ = v.verify(&nodes);
641        let (rules2, memo2) = v.stats();
642        assert_eq!(rules2, 3);
643        assert_eq!(memo2, 1);
644    }
645
646    // 12. is_valid() returns false when errors are present.
647    #[test]
648    fn test_is_valid_false_on_errors() {
649        let result = VerificationResult {
650            verified: true,
651            nodes_checked: 1,
652            nodes_memoized: 0,
653            max_depth: 0,
654            errors: vec![VerificationError::EmptyProof],
655        };
656        assert!(!result.is_valid());
657    }
658
659    // 13. Multiple errors are collected (not short-circuited at root).
660    #[test]
661    fn test_multiple_errors_collected() {
662        let mut v = make_verifier();
663        // conjunction has 2 premises; supply two nodes both with unknown rules.
664        let nodes = vec![
665            ProofNode {
666                node_id: 1,
667                rule_id: "bad_rule_a".to_string(),
668                goal: "fact:a".to_string(),
669                premise_ids: vec![],
670                depth: 1,
671            },
672            ProofNode {
673                node_id: 2,
674                rule_id: "bad_rule_b".to_string(),
675                goal: "fact:b".to_string(),
676                premise_ids: vec![],
677                depth: 1,
678            },
679            ProofNode {
680                node_id: 3,
681                rule_id: "conjunction".to_string(),
682                goal: "and:ab".to_string(),
683                premise_ids: vec![1, 2],
684                depth: 0,
685            },
686        ];
687        let result = v.verify(&nodes);
688        assert!(!result.is_valid());
689        // At least two UnknownRule errors: one for each bad premise.
690        let unknown_count = result
691            .errors
692            .iter()
693            .filter(|e| matches!(e, VerificationError::UnknownRule(_)))
694            .count();
695        assert!(
696            unknown_count >= 2,
697            "expected >=2 UnknownRule errors, got {unknown_count}"
698        );
699    }
700
701    // 14. max_depth computed correctly.
702    #[test]
703    fn test_max_depth_computed() {
704        let mut v = make_verifier();
705        let nodes = vec![
706            ProofNode {
707                node_id: 1,
708                rule_id: "axiom".to_string(),
709                goal: "fact:a".to_string(),
710                premise_ids: vec![],
711                depth: 5,
712            },
713            ProofNode {
714                node_id: 2,
715                rule_id: "modus_ponens".to_string(),
716                goal: "derived:b".to_string(),
717                premise_ids: vec![1],
718                depth: 2,
719            },
720        ];
721        let result = v.verify(&nodes);
722        assert!(result.is_valid(), "{:?}", result.errors);
723        assert_eq!(result.max_depth, 5);
724    }
725
726    // 15. register_rule replaces existing rule.
727    #[test]
728    fn test_register_rule_replaces() {
729        let mut v = ProofVerifier::new();
730        v.register_rule(RuleSpec {
731            rule_id: "my_rule".to_string(),
732            head_pattern: "old:".to_string(),
733            arity: 0,
734        });
735        // Now replace with a different pattern.
736        v.register_rule(RuleSpec {
737            rule_id: "my_rule".to_string(),
738            head_pattern: "new:".to_string(),
739            arity: 0,
740        });
741        assert_eq!(v.rules.len(), 1);
742        assert_eq!(v.rules["my_rule"].head_pattern, "new:");
743
744        // Verify that the new pattern is used.
745        let nodes = vec![ProofNode {
746            node_id: 1,
747            rule_id: "my_rule".to_string(),
748            goal: "new:something".to_string(),
749            premise_ids: vec![],
750            depth: 0,
751        }];
752        let result = v.verify(&nodes);
753        assert!(result.is_valid(), "{:?}", result.errors);
754    }
755
756    // 16. Large flat proof (all axioms) verifies correctly.
757    #[test]
758    fn test_large_flat_proof() {
759        let mut v = ProofVerifier::new();
760        // A "tree" rule that takes N premises would require special setup.
761        // Instead we register a wide conjunction-style rule with arity 0
762        // and verify 1000 independent axiom nodes.
763        v.register_rule(RuleSpec {
764            rule_id: "axiom".to_string(),
765            head_pattern: "fact:".to_string(),
766            arity: 0,
767        });
768
769        // Create 1000 independent axiom nodes.
770        let nodes: Vec<ProofNode> = (0u64..1000)
771            .map(|i| ProofNode {
772                node_id: i,
773                rule_id: "axiom".to_string(),
774                goal: format!("fact:item-{i}"),
775                premise_ids: vec![],
776                depth: 0,
777            })
778            .collect();
779
780        // verify uses the first node as root when there's no single root
781        // (all 1000 are roots since none are each other's premises).
782        // We just call verify and confirm no errors on the root chain.
783        let result = v.verify(&nodes);
784        // root node 0 has no premises — should be valid on its own.
785        assert!(result.is_valid(), "{:?}", result.errors);
786        assert_eq!(result.nodes_checked, 1);
787        assert_eq!(result.nodes_memoized, 0);
788    }
789}