Skip to main content

logicaffeine_proof/
rule_search.rs

1//! `aesop`-style rule-set search: turn `auto`'s fixed cascade into an
2//! extensible, best-first search over registered rules.
3//!
4//! Lean's `aesop` lets you register lemmas as intro/elim/forward/destruct rules
5//! with priorities and a safe/unsafe classification, then searches best-first.
6//! This is that shape, built on the existing tactic combinators and the cheap
7//! [`ProofState`] clone that already powers backtracking: SAFE rules are applied
8//! whenever they fire (no branching — they never lose information); UNSAFE rules
9//! fork the search, ordered by priority. A node budget bounds the search, and
10//! [`SearchStats`] exposes how much was explored — so a best-first strategy can
11//! be shown to expand fewer nodes than blind depth-first `first`/`repeat`.
12//!
13//! `auto` becomes one rule among many (an unsafe fallback), completing the
14//! inversion the tactic framework was built for.
15
16use crate::tactic::{ProofState, Tactic};
17
18/// How a rule participates in search.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum Safety {
21    /// Never loses provability — applied eagerly, no branch point.
22    Safe,
23    /// May not lead to a proof — forks the search; higher priority tried first.
24    Unsafe(u8),
25}
26
27/// The role a rule plays (currently advisory metadata for ordering/diagnostics;
28/// the tactic itself encodes the actual transformation).
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum RuleKind {
31    Intro,
32    Elim,
33    Forward,
34    Destruct,
35}
36
37/// A search rule: a named tactic with a role and a safety classification.
38pub struct Rule {
39    pub name: String,
40    pub kind: RuleKind,
41    pub safety: Safety,
42    pub tactic: Tactic,
43}
44
45impl Rule {
46    pub fn new(name: &str, kind: RuleKind, safety: Safety, tactic: Tactic) -> Self {
47        Rule { name: name.to_string(), kind, safety, tactic }
48    }
49}
50
51/// An extensible collection of search rules.
52#[derive(Default)]
53pub struct RuleSet {
54    rules: Vec<Rule>,
55}
56
57/// What the search explored.
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
59pub struct SearchStats {
60    pub nodes_expanded: usize,
61    pub succeeded: bool,
62}
63
64/// Default node budget for a best-first search.
65const DEFAULT_NODE_BUDGET: usize = 2000;
66
67impl RuleSet {
68    pub fn new() -> Self {
69        Self::default()
70    }
71
72    pub fn register(&mut self, rule: Rule) {
73        self.rules.push(rule);
74    }
75
76    pub fn len(&self) -> usize {
77        self.rules.len()
78    }
79
80    pub fn is_empty(&self) -> bool {
81        self.rules.is_empty()
82    }
83
84    /// Best-first search from `st`. Applies safe rules eagerly at each node,
85    /// then forks on unsafe rules in priority order. Succeeds (leaving `st` at
86    /// a closed proof state) when some branch drives the open goals to zero.
87    pub fn search(&self, st: &mut ProofState) -> SearchStats {
88        self.search_bounded(st, DEFAULT_NODE_BUDGET)
89    }
90
91    pub fn search_bounded(&self, st: &mut ProofState, budget: usize) -> SearchStats {
92        let mut stats = SearchStats::default();
93        // A best-first frontier of `(path-cost, state)`; the next node expanded
94        // is the one with the fewest open goals, then the cheapest path. The
95        // frontier stays small for the goal sizes this targets.
96        let mut frontier: Vec<(usize, ProofState)> = vec![(0, st.clone())];
97        let rules = self.rules_by_priority();
98
99        while let Some(best_idx) = pick_best(&frontier) {
100            if stats.nodes_expanded >= budget {
101                break;
102            }
103            let (cost, node) = frontier.swap_remove(best_idx);
104            stats.nodes_expanded += 1;
105
106            if node.open_goals() == 0 {
107                *st = node;
108                stats.succeeded = true;
109                return stats;
110            }
111
112            // Fork on each rule, highest priority (and safe) first. A safe rule
113            // adds no path-cost (it never loses provability), so its branch is
114            // explored ahead of any unsafe alternative.
115            for rule in &rules {
116                let mut child = node.clone();
117                if (rule.tactic)(&mut child).is_ok() && !state_eq(&child, &node) {
118                    frontier.push((cost + rule_cost(rule), child));
119                }
120            }
121        }
122        stats
123    }
124
125    /// Rules ordered for expansion: safe first, then unsafe by descending priority.
126    fn rules_by_priority(&self) -> Vec<&Rule> {
127        let mut r: Vec<&Rule> = self.rules.iter().collect();
128        r.sort_by_key(|rule| std::cmp::Reverse(priority_of(rule)));
129        r
130    }
131}
132
133/// The path-cost of taking a rule: zero for safe rules (free), and the inverse
134/// of priority for unsafe ones, so high-priority unsafe branches are cheaper.
135fn rule_cost(rule: &Rule) -> usize {
136    match rule.safety {
137        Safety::Safe => 0,
138        Safety::Unsafe(p) => (255 - p as usize) + 1,
139    }
140}
141
142fn priority_of(rule: &Rule) -> u8 {
143    match rule.safety {
144        Safety::Unsafe(p) => p,
145        Safety::Safe => 255,
146    }
147}
148
149/// Cheap progress check: two states differ if their open-goal count or focused
150/// target differ (enough to avoid enqueuing a no-op rule application).
151fn state_eq(a: &ProofState, b: &ProofState) -> bool {
152    a.open_goals() == b.open_goals() && a.focused_target() == b.focused_target()
153}
154
155/// The frontier node to expand next: fewest open goals, then cheapest path.
156fn pick_best(frontier: &[(usize, ProofState)]) -> Option<usize> {
157    frontier
158        .iter()
159        .enumerate()
160        .min_by_key(|(_, (cost, st))| (st.open_goals(), *cost))
161        .map(|(i, _)| i)
162}
163
164/// The default rule set: `auto` as the sole unsafe fallback — so a default
165/// `search` closes exactly what `auto` closes, the regression baseline.
166pub fn default_ruleset() -> RuleSet {
167    let mut rs = RuleSet::new();
168    rs.register(Rule::new(
169        "auto",
170        RuleKind::Elim,
171        Safety::Unsafe(10),
172        crate::tactic::combinators::auto(),
173    ));
174    rs
175}