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}