rust_rule_engine/backward/
conclusion_index.rs

1//! Conclusion Index for efficient rule lookup in backward chaining
2//!
3//! This module provides O(1) lookup for finding rules that can prove a goal,
4//! replacing the naive O(n) linear scan through all rules.
5//!
6//! The index maps from conclusion patterns (facts that rules derive) to
7//! the set of rule names that can derive them.
8
9use crate::engine::rule::Rule;
10use crate::types::ActionType;
11use std::collections::{HashMap, HashSet};
12
13/// Index for fast lookup of rules by their conclusions
14///
15/// This is similar to RETE's beta memory but specialized for backward chaining.
16/// Instead of matching conditions, we match rule conclusions (actions) to goals.
17#[derive(Debug, Clone)]
18pub struct ConclusionIndex {
19    /// Maps field patterns to rules that can derive them
20    /// Example: "User.IsVIP" -> ["DetermineVIP", "PromoteToVIP"]
21    field_to_rules: HashMap<String, HashSet<String>>,
22
23    /// Maps rule names to their conclusions (for updates/removals)
24    rule_to_conclusions: HashMap<String, HashSet<String>>,
25
26    /// Total number of indexed rules
27    rule_count: usize,
28}
29
30impl ConclusionIndex {
31    /// Create a new empty conclusion index
32    pub fn new() -> Self {
33        Self {
34            field_to_rules: HashMap::new(),
35            rule_to_conclusions: HashMap::new(),
36            rule_count: 0,
37        }
38    }
39
40    /// Build index from a collection of rules
41    pub fn from_rules(rules: &[Rule]) -> Self {
42        let mut index = Self::new();
43        for rule in rules {
44            index.add_rule(rule);
45        }
46        index
47    }
48
49    /// Add a rule to the index
50    pub fn add_rule(&mut self, rule: &Rule) {
51        if !rule.enabled {
52            return; // Don't index disabled rules
53        }
54
55        let conclusions = self.extract_conclusions(rule);
56
57        if conclusions.is_empty() {
58            return; // No indexable conclusions
59        }
60
61        // Add bidirectional mappings
62        for conclusion in &conclusions {
63            self.field_to_rules
64                .entry(conclusion.clone())
65                .or_default()
66                .insert(rule.name.clone());
67        }
68
69        self.rule_to_conclusions
70            .insert(rule.name.clone(), conclusions);
71        self.rule_count += 1;
72    }
73
74    /// Remove a rule from the index
75    pub fn remove_rule(&mut self, rule_name: &str) {
76        if let Some(conclusions) = self.rule_to_conclusions.remove(rule_name) {
77            for conclusion in conclusions {
78                if let Some(rules) = self.field_to_rules.get_mut(&conclusion) {
79                    rules.remove(rule_name);
80                    if rules.is_empty() {
81                        self.field_to_rules.remove(&conclusion);
82                    }
83                }
84            }
85            self.rule_count -= 1;
86        }
87    }
88
89    /// Find candidate rules that could prove a goal
90    ///
91    /// This is the O(1) lookup that replaces O(n) iteration.
92    ///
93    /// # Arguments
94    /// * `goal_pattern` - The goal pattern to prove (e.g., "User.IsVIP == true")
95    ///
96    /// # Returns
97    /// Set of rule names that might be able to derive this goal
98    pub fn find_candidates(&self, goal_pattern: &str) -> HashSet<String> {
99        let mut candidates = HashSet::new();
100
101        // Extract field name from goal pattern
102        // Examples:
103        //   "User.IsVIP == true" -> "User.IsVIP"
104        //   "Order.AutoApproved" -> "Order.AutoApproved"
105        //   "Customer.Status == 'VIP'" -> "Customer.Status"
106        let field = self.extract_field_from_goal(goal_pattern);
107
108        // Direct field match
109        if let Some(rules) = self.field_to_rules.get(field) {
110            candidates.extend(rules.iter().cloned());
111        }
112
113        // Check parent objects for partial matches
114        // Example: "User.IsVIP" also matches rules that set "User.*"
115        if let Some(dot_pos) = field.rfind('.') {
116            let object = &field[..dot_pos];
117
118            // Find all rules that modify any field of this object
119            for (indexed_field, rules) in &self.field_to_rules {
120                if indexed_field.starts_with(object) {
121                    candidates.extend(rules.iter().cloned());
122                }
123            }
124        }
125
126        candidates
127    }
128
129    /// Extract field name from goal pattern
130    fn extract_field_from_goal<'a>(&self, goal_pattern: &'a str) -> &'a str {
131        // Handle comparison operators
132        for op in &["==", "!=", ">=", "<=", ">", "<", " contains ", " matches "] {
133            if let Some(pos) = goal_pattern.find(op) {
134                return goal_pattern[..pos].trim();
135            }
136        }
137
138        // No operator found, return whole pattern
139        goal_pattern.trim()
140    }
141
142    /// Extract all conclusions (facts derived) from a rule
143    fn extract_conclusions(&self, rule: &Rule) -> HashSet<String> {
144        let mut conclusions = HashSet::new();
145
146        for action in &rule.actions {
147            match action {
148                ActionType::Set { field, .. } => {
149                    conclusions.insert(field.clone());
150                }
151                ActionType::MethodCall { object, method, .. } => {
152                    // Method calls might modify object state
153                    conclusions.insert(format!("{}.{}", object, method));
154                    // Also index the object itself
155                    conclusions.insert(object.clone());
156                }
157                ActionType::Retract { object } => {
158                    conclusions.insert(object.clone());
159                }
160                ActionType::SetWorkflowData { key, .. } => {
161                    conclusions.insert(key.clone());
162                }
163                // Log, Custom, ScheduleRule don't directly derive facts
164                _ => {}
165            }
166        }
167
168        conclusions
169    }
170
171    /// Get statistics about the index
172    pub fn stats(&self) -> IndexStats {
173        IndexStats {
174            total_rules: self.rule_count,
175            indexed_fields: self.field_to_rules.len(),
176            avg_rules_per_field: if self.field_to_rules.is_empty() {
177                0.0
178            } else {
179                self.field_to_rules.values().map(|s| s.len()).sum::<usize>() as f64
180                    / self.field_to_rules.len() as f64
181            },
182        }
183    }
184
185    /// Clear the index
186    pub fn clear(&mut self) {
187        self.field_to_rules.clear();
188        self.rule_to_conclusions.clear();
189        self.rule_count = 0;
190    }
191
192    /// Check if index is empty
193    pub fn is_empty(&self) -> bool {
194        self.rule_count == 0
195    }
196}
197
198impl Default for ConclusionIndex {
199    fn default() -> Self {
200        Self::new()
201    }
202}
203
204/// Statistics about the conclusion index
205#[derive(Debug, Clone)]
206pub struct IndexStats {
207    /// Total number of indexed rules
208    pub total_rules: usize,
209    /// Number of unique fields indexed
210    pub indexed_fields: usize,
211    /// Average number of rules per field
212    pub avg_rules_per_field: f64,
213}
214
215impl std::fmt::Display for IndexStats {
216    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217        writeln!(f, "Conclusion Index Statistics:")?;
218        writeln!(f, "  Total Rules: {}", self.total_rules)?;
219        writeln!(f, "  Indexed Fields: {}", self.indexed_fields)?;
220        writeln!(f, "  Avg Rules/Field: {:.2}", self.avg_rules_per_field)?;
221        Ok(())
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::engine::rule::{Condition, ConditionGroup, Rule};
229    use crate::types::{Operator, Value};
230
231    fn create_test_rule(name: &str, set_field: &str) -> Rule {
232        let conditions = ConditionGroup::Single(Condition::new(
233            "dummy".to_string(),
234            Operator::Equal,
235            Value::Boolean(true),
236        ));
237        let actions = vec![ActionType::Set {
238            field: set_field.to_string(),
239            value: Value::Boolean(true),
240        }];
241        Rule::new(name.to_string(), conditions, actions)
242    }
243
244    #[test]
245    fn test_index_creation() {
246        let index = ConclusionIndex::new();
247        assert!(index.is_empty());
248        assert_eq!(index.rule_count, 0);
249    }
250
251    #[test]
252    fn test_add_single_rule() {
253        let mut index = ConclusionIndex::new();
254        let rule = create_test_rule("TestRule", "User.IsVIP");
255
256        index.add_rule(&rule);
257
258        assert_eq!(index.rule_count, 1);
259        assert_eq!(index.field_to_rules.len(), 1);
260        assert!(index.field_to_rules.contains_key("User.IsVIP"));
261    }
262
263    #[test]
264    fn test_find_candidates_exact_match() {
265        let mut index = ConclusionIndex::new();
266        let rule = create_test_rule("DetermineVIP", "User.IsVIP");
267        index.add_rule(&rule);
268
269        let candidates = index.find_candidates("User.IsVIP == true");
270
271        assert_eq!(candidates.len(), 1);
272        assert!(candidates.contains("DetermineVIP"));
273    }
274
275    #[test]
276    fn test_find_candidates_multiple_rules() {
277        let mut index = ConclusionIndex::new();
278        index.add_rule(&create_test_rule("Rule1", "User.IsVIP"));
279        index.add_rule(&create_test_rule("Rule2", "User.IsVIP"));
280        index.add_rule(&create_test_rule("Rule3", "Order.Status"));
281
282        let candidates = index.find_candidates("User.IsVIP == true");
283
284        assert_eq!(candidates.len(), 2);
285        assert!(candidates.contains("Rule1"));
286        assert!(candidates.contains("Rule2"));
287        assert!(!candidates.contains("Rule3"));
288    }
289
290    #[test]
291    fn test_remove_rule() {
292        let mut index = ConclusionIndex::new();
293        let rule = create_test_rule("TestRule", "User.IsVIP");
294        index.add_rule(&rule);
295
296        assert_eq!(index.rule_count, 1);
297
298        index.remove_rule("TestRule");
299
300        assert_eq!(index.rule_count, 0);
301        assert!(index.is_empty());
302        assert!(index.field_to_rules.is_empty());
303    }
304
305    #[test]
306    fn test_extract_field_from_goal() {
307        let index = ConclusionIndex::new();
308
309        assert_eq!(
310            index.extract_field_from_goal("User.IsVIP == true"),
311            "User.IsVIP"
312        );
313        assert_eq!(
314            index.extract_field_from_goal("Order.Amount > 100"),
315            "Order.Amount"
316        );
317        assert_eq!(index.extract_field_from_goal("User.Name"), "User.Name");
318        assert_eq!(
319            index.extract_field_from_goal("Customer.Email contains '@'"),
320            "Customer.Email"
321        );
322    }
323
324    #[test]
325    fn test_disabled_rules_not_indexed() {
326        let mut index = ConclusionIndex::new();
327        let mut rule = create_test_rule("DisabledRule", "User.IsVIP");
328        rule.enabled = false;
329
330        index.add_rule(&rule);
331
332        assert_eq!(index.rule_count, 0);
333        assert!(index.is_empty());
334    }
335
336    #[test]
337    fn test_from_rules_bulk_creation() {
338        let rules = vec![
339            create_test_rule("Rule1", "User.IsVIP"),
340            create_test_rule("Rule2", "Order.Status"),
341            create_test_rule("Rule3", "Customer.Rating"),
342        ];
343
344        let index = ConclusionIndex::from_rules(&rules);
345
346        assert_eq!(index.rule_count, 3);
347        assert_eq!(index.field_to_rules.len(), 3);
348    }
349
350    #[test]
351    fn test_stats() {
352        let mut index = ConclusionIndex::new();
353        index.add_rule(&create_test_rule("Rule1", "User.IsVIP"));
354        index.add_rule(&create_test_rule("Rule2", "User.IsVIP"));
355        index.add_rule(&create_test_rule("Rule3", "Order.Status"));
356
357        let stats = index.stats();
358
359        assert_eq!(stats.total_rules, 3);
360        assert_eq!(stats.indexed_fields, 2);
361        assert!(stats.avg_rules_per_field > 0.0);
362    }
363}