rust_rule_engine/rete/
agenda.rs

1//! Advanced Agenda System (Drools-style)
2//!
3//! This module implements advanced agenda features similar to Drools:
4//! - Activation Groups: Only one rule in a group can fire
5//! - Agenda Groups: Sequential execution of rule groups
6//! - Ruleflow Groups: Workflow-based execution
7//! - Auto-focus: Automatic agenda group switching
8//! - Lock-on-active: Prevent re-activation during rule firing
9//! - Conflict Resolution Strategies: Multiple ordering strategies
10
11use std::collections::{HashMap, HashSet, BinaryHeap};
12use std::cmp::Ordering;
13
14/// Conflict Resolution Strategy
15///
16/// Determines how conflicting activations are ordered in the agenda.
17/// Similar to CLIPS and Drools conflict resolution strategies.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ConflictResolutionStrategy {
20    /// Salience-based ordering (default) - Higher salience fires first
21    Salience,
22
23    /// LEX (Recency) - Most recently inserted facts fire first
24    /// Sorts by the timestamp of the most recent fact used in the activation
25    LEX,
26
27    /// MEA (Recency + Specificity) - LEX + more specific rules first
28    /// Combines recency with condition count (more conditions = more specific)
29    MEA,
30
31    /// Depth-first - Fire rule immediately after insertion
32    /// Re-evaluates agenda after each rule fires
33    Depth,
34
35    /// Breadth-first - Collect all activations before firing (default)
36    /// Fires all activations in current cycle before re-evaluating
37    Breadth,
38
39    /// Simplicity - Rules with fewer conditions fire first
40    /// Simpler rules are prioritized
41    Simplicity,
42
43    /// Complexity - Rules with more conditions fire first
44    /// More complex/specific rules are prioritized
45    Complexity,
46
47    /// Random - Random ordering
48    /// Useful for testing non-deterministic behavior
49    Random,
50}
51
52/// Activation represents a rule that is ready to fire
53#[derive(Debug, Clone)]
54pub struct Activation {
55    /// Rule name
56    pub rule_name: String,
57    /// Priority/salience (higher fires first)
58    pub salience: i32,
59    /// Activation group (only one rule in group can fire)
60    pub activation_group: Option<String>,
61    /// Agenda group (for sequential execution)
62    pub agenda_group: String,
63    /// Ruleflow group (for workflow execution)
64    pub ruleflow_group: Option<String>,
65    /// No-loop flag
66    pub no_loop: bool,
67    /// Lock-on-active flag
68    pub lock_on_active: bool,
69    /// Auto-focus flag
70    pub auto_focus: bool,
71    /// Creation timestamp (for conflict resolution)
72    pub created_at: std::time::Instant,
73    /// Number of conditions in the rule (for complexity/simplicity strategies)
74    pub condition_count: usize,
75    /// Internal ID
76    id: usize,
77}
78
79impl Activation {
80    /// Create a new activation
81    pub fn new(rule_name: String, salience: i32) -> Self {
82        Self {
83            rule_name,
84            salience,
85            activation_group: None,
86            agenda_group: "MAIN".to_string(),
87            ruleflow_group: None,
88            no_loop: true,
89            lock_on_active: false,
90            auto_focus: false,
91            created_at: std::time::Instant::now(),
92            condition_count: 1, // Default to 1
93            id: 0,
94        }
95    }
96
97    /// Builder: Set condition count
98    pub fn with_condition_count(mut self, count: usize) -> Self {
99        self.condition_count = count;
100        self
101    }
102
103    /// Builder: Set activation group
104    pub fn with_activation_group(mut self, group: String) -> Self {
105        self.activation_group = Some(group);
106        self
107    }
108
109    /// Builder: Set agenda group
110    pub fn with_agenda_group(mut self, group: String) -> Self {
111        self.agenda_group = group;
112        self
113    }
114
115    /// Builder: Set ruleflow group
116    pub fn with_ruleflow_group(mut self, group: String) -> Self {
117        self.ruleflow_group = Some(group);
118        self
119    }
120
121    /// Builder: Set no-loop
122    pub fn with_no_loop(mut self, no_loop: bool) -> Self {
123        self.no_loop = no_loop;
124        self
125    }
126
127    /// Builder: Set lock-on-active
128    pub fn with_lock_on_active(mut self, lock: bool) -> Self {
129        self.lock_on_active = lock;
130        self
131    }
132
133    /// Builder: Set auto-focus
134    pub fn with_auto_focus(mut self, auto_focus: bool) -> Self {
135        self.auto_focus = auto_focus;
136        self
137    }
138}
139
140// Implement ordering for priority queue (higher salience first)
141impl PartialEq for Activation {
142    fn eq(&self, other: &Self) -> bool {
143        self.id == other.id
144    }
145}
146
147impl Eq for Activation {}
148
149impl PartialOrd for Activation {
150    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
151        Some(self.cmp(other))
152    }
153}
154
155impl Ord for Activation {
156    fn cmp(&self, other: &Self) -> Ordering {
157        // First compare by salience (higher is better)
158        match self.salience.cmp(&other.salience) {
159            Ordering::Equal => {
160                // Then by creation time (earlier is better = reverse)
161                other.created_at.cmp(&self.created_at)
162            }
163            other_order => other_order,
164        }
165    }
166}
167
168/// Advanced Agenda (Drools-style)
169pub struct AdvancedAgenda {
170    /// All activations by agenda group
171    activations: HashMap<String, BinaryHeap<Activation>>,
172    /// Current focus (agenda group)
173    focus: String,
174    /// Focus stack
175    focus_stack: Vec<String>,
176    /// Fired rules (for no-loop)
177    fired_rules: HashSet<String>,
178    /// Fired activation groups
179    fired_activation_groups: HashSet<String>,
180    /// Locked groups (lock-on-active)
181    locked_groups: HashSet<String>,
182    /// Active ruleflow groups
183    active_ruleflow_groups: HashSet<String>,
184    /// Next activation ID
185    next_id: usize,
186    /// Conflict resolution strategy
187    strategy: ConflictResolutionStrategy,
188}
189
190impl AdvancedAgenda {
191    /// Create a new agenda with "MAIN" as default focus
192    pub fn new() -> Self {
193        let mut agenda = Self {
194            activations: HashMap::new(),
195            focus: "MAIN".to_string(),
196            focus_stack: Vec::new(),
197            fired_rules: HashSet::new(),
198            fired_activation_groups: HashSet::new(),
199            locked_groups: HashSet::new(),
200            active_ruleflow_groups: HashSet::new(),
201            next_id: 0,
202            strategy: ConflictResolutionStrategy::Salience, // Default strategy
203        };
204        agenda.activations.insert("MAIN".to_string(), BinaryHeap::new());
205        agenda
206    }
207
208    /// Set the conflict resolution strategy
209    pub fn set_strategy(&mut self, strategy: ConflictResolutionStrategy) {
210        self.strategy = strategy;
211        // Re-sort all existing activations with new strategy
212        let current_strategy = self.strategy; // Copy strategy to avoid borrow issues
213        for heap in self.activations.values_mut() {
214            let mut activations: Vec<_> = heap.drain().collect();
215            Self::sort_with_strategy(current_strategy, &mut activations);
216            *heap = activations.into_iter().collect();
217        }
218    }
219
220    /// Get current strategy
221    pub fn strategy(&self) -> ConflictResolutionStrategy {
222        self.strategy
223    }
224
225    /// Sort activations according to given strategy (static method)
226    fn sort_with_strategy(strategy: ConflictResolutionStrategy, activations: &mut [Activation]) {
227        match strategy {
228            ConflictResolutionStrategy::Salience => {
229                // Default: sort by salience (higher first), then by recency
230                activations.sort_by(|a, b| {
231                    match b.salience.cmp(&a.salience) {
232                        Ordering::Equal => b.created_at.cmp(&a.created_at),
233                        other => other,
234                    }
235                });
236            }
237            ConflictResolutionStrategy::LEX => {
238                // Recency: most recent first
239                activations.sort_by(|a, b| b.created_at.cmp(&a.created_at));
240            }
241            ConflictResolutionStrategy::MEA => {
242                // Recency + Specificity: recent first, then more conditions
243                activations.sort_by(|a, b| {
244                    match b.created_at.cmp(&a.created_at) {
245                        Ordering::Equal => b.condition_count.cmp(&a.condition_count),
246                        other => other,
247                    }
248                });
249            }
250            ConflictResolutionStrategy::Depth => {
251                // Depth-first: same as salience (handled in fire loop)
252                activations.sort_by(|a, b| {
253                    match b.salience.cmp(&a.salience) {
254                        Ordering::Equal => b.created_at.cmp(&a.created_at),
255                        other => other,
256                    }
257                });
258            }
259            ConflictResolutionStrategy::Breadth => {
260                // Breadth-first: same as salience (default behavior)
261                activations.sort_by(|a, b| {
262                    match b.salience.cmp(&a.salience) {
263                        Ordering::Equal => b.created_at.cmp(&a.created_at),
264                        other => other,
265                    }
266                });
267            }
268            ConflictResolutionStrategy::Simplicity => {
269                // Simpler rules (fewer conditions) first
270                activations.sort_by(|a, b| {
271                    match a.condition_count.cmp(&b.condition_count) {
272                        Ordering::Equal => b.created_at.cmp(&a.created_at),
273                        other => other,
274                    }
275                });
276            }
277            ConflictResolutionStrategy::Complexity => {
278                // More complex rules (more conditions) first
279                activations.sort_by(|a, b| {
280                    match b.condition_count.cmp(&a.condition_count) {
281                        Ordering::Equal => b.created_at.cmp(&a.created_at),
282                        other => other,
283                    }
284                });
285            }
286            ConflictResolutionStrategy::Random => {
287                // Random ordering using fastrand
288                fastrand::shuffle(activations);
289            }
290        }
291    }
292
293    /// Add an activation to the agenda
294    pub fn add_activation(&mut self, mut activation: Activation) {
295        // Auto-focus: switch to this agenda group if requested
296        if activation.auto_focus && activation.agenda_group != self.focus {
297            self.set_focus(activation.agenda_group.clone());
298        }
299
300        // Check activation group: if group already fired, skip
301        if let Some(ref group) = activation.activation_group {
302            if self.fired_activation_groups.contains(group) {
303                return; // Skip this activation
304            }
305        }
306
307        // Check ruleflow group: if not active, skip
308        if let Some(ref group) = activation.ruleflow_group {
309            if !self.active_ruleflow_groups.contains(group) {
310                return; // Skip this activation
311            }
312        }
313
314        // Assign ID
315        activation.id = self.next_id;
316        self.next_id += 1;
317
318        // Add to appropriate agenda group
319        self.activations
320            .entry(activation.agenda_group.clone())
321            .or_insert_with(BinaryHeap::new)
322            .push(activation);
323    }
324
325    /// Get the next activation to fire (from current focus)
326    pub fn get_next_activation(&mut self) -> Option<Activation> {
327        loop {
328            // Try to get from current focus
329            if let Some(heap) = self.activations.get_mut(&self.focus) {
330                while let Some(activation) = heap.pop() {
331                    // Check no-loop
332                    if activation.no_loop && self.fired_rules.contains(&activation.rule_name) {
333                        continue;
334                    }
335
336                    // Check lock-on-active
337                    if activation.lock_on_active && self.locked_groups.contains(&activation.agenda_group) {
338                        continue;
339                    }
340
341                    // Check activation group
342                    if let Some(ref group) = activation.activation_group {
343                        if self.fired_activation_groups.contains(group) {
344                            continue;
345                        }
346                    }
347
348                    return Some(activation);
349                }
350            }
351
352            // No more activations in current focus, try to pop focus stack
353            if let Some(prev_focus) = self.focus_stack.pop() {
354                self.focus = prev_focus;
355            } else {
356                return None; // Agenda is empty
357            }
358        }
359    }
360
361    /// Mark a rule as fired
362    pub fn mark_rule_fired(&mut self, activation: &Activation) {
363        self.fired_rules.insert(activation.rule_name.clone());
364
365        // If has activation group, mark group as fired (no other rules in group can fire)
366        if let Some(ref group) = activation.activation_group {
367            self.fired_activation_groups.insert(group.clone());
368        }
369
370        // Lock the agenda group if lock-on-active
371        if activation.lock_on_active {
372            self.locked_groups.insert(activation.agenda_group.clone());
373        }
374    }
375
376    /// Set focus to a specific agenda group
377    pub fn set_focus(&mut self, group: String) {
378        if group != self.focus {
379            self.focus_stack.push(self.focus.clone());
380            self.focus = group;
381        }
382    }
383
384    /// Get current focus
385    pub fn get_focus(&self) -> &str {
386        &self.focus
387    }
388
389    /// Clear all agenda groups
390    pub fn clear(&mut self) {
391        self.activations.clear();
392        self.activations.insert("MAIN".to_string(), BinaryHeap::new());
393        self.focus = "MAIN".to_string();
394        self.focus_stack.clear();
395        self.fired_rules.clear();
396        self.fired_activation_groups.clear();
397        self.locked_groups.clear();
398    }
399
400    /// Reset fired flags (for re-evaluation)
401    pub fn reset_fired_flags(&mut self) {
402        self.fired_rules.clear();
403        self.fired_activation_groups.clear();
404        self.locked_groups.clear();
405    }
406
407    /// Activate a ruleflow group (make rules in this group eligible to fire)
408    pub fn activate_ruleflow_group(&mut self, group: String) {
409        self.active_ruleflow_groups.insert(group);
410    }
411
412    /// Deactivate a ruleflow group
413    pub fn deactivate_ruleflow_group(&mut self, group: &str) {
414        self.active_ruleflow_groups.remove(group);
415    }
416
417    /// Check if ruleflow group is active
418    pub fn is_ruleflow_group_active(&self, group: &str) -> bool {
419        self.active_ruleflow_groups.contains(group)
420    }
421
422    /// Get agenda statistics
423    pub fn stats(&self) -> AgendaStats {
424        let total_activations: usize = self.activations.values().map(|heap| heap.len()).sum();
425        let groups = self.activations.len();
426
427        AgendaStats {
428            total_activations,
429            groups,
430            focus: self.focus.clone(),
431            fired_rules: self.fired_rules.len(),
432            fired_activation_groups: self.fired_activation_groups.len(),
433            active_ruleflow_groups: self.active_ruleflow_groups.len(),
434        }
435    }
436}
437
438impl Default for AdvancedAgenda {
439    fn default() -> Self {
440        Self::new()
441    }
442}
443
444/// Agenda statistics
445#[derive(Debug, Clone)]
446pub struct AgendaStats {
447    pub total_activations: usize,
448    pub groups: usize,
449    pub focus: String,
450    pub fired_rules: usize,
451    pub fired_activation_groups: usize,
452    pub active_ruleflow_groups: usize,
453}
454
455impl std::fmt::Display for AgendaStats {
456    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457        write!(
458            f,
459            "Agenda Stats: {} activations, {} groups, focus='{}', {} fired rules",
460            self.total_activations, self.groups, self.focus, self.fired_rules
461        )
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    #[test]
470    fn test_basic_activation() {
471        let mut agenda = AdvancedAgenda::new();
472
473        let act1 = Activation::new("Rule1".to_string(), 10);
474        let act2 = Activation::new("Rule2".to_string(), 20);
475
476        agenda.add_activation(act1);
477        agenda.add_activation(act2);
478
479        // Higher salience fires first
480        let next = agenda.get_next_activation().unwrap();
481        assert_eq!(next.rule_name, "Rule2");
482    }
483
484    #[test]
485    fn test_activation_groups() {
486        let mut agenda = AdvancedAgenda::new();
487
488        let act1 = Activation::new("Rule1".to_string(), 10)
489            .with_activation_group("group1".to_string());
490        let act2 = Activation::new("Rule2".to_string(), 20)
491            .with_activation_group("group1".to_string());
492
493        agenda.add_activation(act1);
494        agenda.add_activation(act2);
495
496        // First activation fires
497        let first = agenda.get_next_activation().unwrap();
498        agenda.mark_rule_fired(&first);
499
500        // Second activation should be skipped (same group)
501        let second = agenda.get_next_activation();
502        assert!(second.is_none());
503    }
504
505    #[test]
506    fn test_agenda_groups() {
507        let mut agenda = AdvancedAgenda::new();
508
509        let act1 = Activation::new("Rule1".to_string(), 10)
510            .with_agenda_group("group_a".to_string());
511        let act2 = Activation::new("Rule2".to_string(), 20)
512            .with_agenda_group("group_b".to_string());
513
514        agenda.add_activation(act1);
515        agenda.add_activation(act2);
516
517        // MAIN is empty, nothing fires
518        assert!(agenda.get_next_activation().is_none());
519
520        // Set focus to group_a
521        agenda.set_focus("group_a".to_string());
522        let next = agenda.get_next_activation().unwrap();
523        assert_eq!(next.rule_name, "Rule1");
524    }
525
526    #[test]
527    fn test_auto_focus() {
528        let mut agenda = AdvancedAgenda::new();
529
530        let act = Activation::new("Rule1".to_string(), 10)
531            .with_agenda_group("special".to_string())
532            .with_auto_focus(true);
533
534        agenda.add_activation(act);
535
536        // Auto-focus should switch to "special"
537        assert_eq!(agenda.get_focus(), "special");
538    }
539
540    #[test]
541    fn test_ruleflow_groups() {
542        let mut agenda = AdvancedAgenda::new();
543
544        let act = Activation::new("Rule1".to_string(), 10)
545            .with_ruleflow_group("flow1".to_string());
546
547        // Without activating ruleflow group, activation is not added
548        agenda.add_activation(act.clone());
549        assert_eq!(agenda.stats().total_activations, 0);
550
551        // Activate ruleflow group
552        agenda.activate_ruleflow_group("flow1".to_string());
553        agenda.add_activation(act);
554        assert_eq!(agenda.stats().total_activations, 1);
555    }
556}