Skip to main content

termivar_scanner/rules/
engine.rs

1use std::collections::BTreeMap;
2
3use crate::knowledge::{KnowledgeBase, KnowledgeBaseError, KnowledgeSnapshot};
4
5use crate::rules::{
6    evaluation::{evaluate_rule, RuleApplication, RuleEvaluation},
7    registry::{ReasoningRule, RuleWrite},
8    RuleEngineError,
9};
10
11const MAX_STALE_SNAPSHOT_RETRIES: u8 = 3;
12pub(super) const MAX_REASONING_APPLY_ATTEMPTS: u8 = MAX_STALE_SNAPSHOT_RETRIES + 1;
13
14/// Deterministic registry and evaluator for declarative reasoning rules.
15///
16/// Rules are always evaluated in stable rule-ID order against one shared
17/// snapshot. Conclusions are written only after every rule has been evaluated,
18/// preventing earlier rules from changing later conditions in the same cycle.
19#[derive(Debug, Clone, Default)]
20pub struct RuleEngine {
21    rules: BTreeMap<String, ReasoningRule>,
22}
23
24impl RuleEngine {
25    /// Creates an empty engine.
26    pub fn new() -> Self {
27        Self::default()
28    }
29
30    /// Registers an idempotent rule definition.
31    pub fn register(&mut self, rule: ReasoningRule) -> Result<RuleWrite, RuleEngineError> {
32        if let Some(existing) = self.rules.get(rule.id()) {
33            return if existing == &rule {
34                Ok(RuleWrite::Unchanged)
35            } else {
36                Err(RuleEngineError::RuleIdentityConflict {
37                    id: rule.id().to_owned(),
38                })
39            };
40        }
41        self.rules.insert(rule.id().to_owned(), rule);
42        Ok(RuleWrite::Inserted)
43    }
44
45    /// Returns the number of registered rule identities.
46    pub fn len(&self) -> usize {
47        self.rules.len()
48    }
49
50    /// Returns whether no rules are registered.
51    pub fn is_empty(&self) -> bool {
52        self.rules.is_empty()
53    }
54
55    /// Evaluates all rules without mutating the knowledge base.
56    pub fn evaluate(
57        &self,
58        knowledge: &KnowledgeBase,
59        subject: &termivar_core::EntityId,
60    ) -> Result<Vec<RuleEvaluation>, RuleEngineError> {
61        let snapshot = knowledge.snapshot_for_subject(subject);
62        self.evaluate_snapshot(&snapshot)
63    }
64
65    /// Evaluates all rules against one immutable snapshot.
66    pub fn evaluate_snapshot(
67        &self,
68        snapshot: &KnowledgeSnapshot,
69    ) -> Result<Vec<RuleEvaluation>, RuleEngineError> {
70        self.rules
71            .values()
72            .map(|rule| evaluate_rule(rule, snapshot))
73            .collect()
74    }
75
76    /// Evaluates one decision cycle and atomically writes matched hypotheses.
77    ///
78    /// All rules first evaluate in stable rule-ID order against one immutable
79    /// snapshot. Every matched hypothesis is then preflighted and committed in
80    /// one knowledge-base write transaction, so one late identity conflict
81    /// cannot leave earlier conclusions stored. Existing verifier-owned
82    /// `Confirmed` and `Rejected` states are preserved under that same write
83    /// lock, so a concurrent reasoning pass cannot reverse a verification result.
84    pub fn apply(
85        &self,
86        knowledge: &KnowledgeBase,
87        subject: &termivar_core::EntityId,
88    ) -> Result<Vec<RuleApplication>, RuleEngineError> {
89        self.apply_with_before_commit(knowledge, subject, |_, _| {})
90    }
91
92    pub(super) fn apply_with_before_commit<F>(
93        &self,
94        knowledge: &KnowledgeBase,
95        subject: &termivar_core::EntityId,
96        mut before_commit: F,
97    ) -> Result<Vec<RuleApplication>, RuleEngineError>
98    where
99        F: FnMut(u8, &KnowledgeSnapshot),
100    {
101        for attempt in 1..=MAX_REASONING_APPLY_ATTEMPTS {
102            let snapshot = knowledge.snapshot_for_subject(subject);
103            let evaluations = self.evaluate_snapshot(&snapshot)?;
104            let hypotheses = evaluations
105                .iter()
106                .filter_map(|evaluation| evaluation.hypothesis().cloned())
107                .collect();
108            before_commit(attempt, &snapshot);
109
110            let writes = match knowledge.upsert_reasoning_hypothesis_batch(&snapshot, hypotheses) {
111                Ok(writes) => writes,
112                Err(KnowledgeBaseError::StaleSnapshot { .. })
113                    if attempt < MAX_REASONING_APPLY_ATTEMPTS =>
114                {
115                    continue;
116                },
117                Err(KnowledgeBaseError::StaleSnapshot { .. }) => {
118                    return Err(RuleEngineError::StaleSnapshotRetriesExhausted {
119                        attempts: attempt,
120                    });
121                },
122                Err(error) => return Err(error.into()),
123            };
124
125            let mut writes = writes.into_iter().peekable();
126            let applications = evaluations
127                .into_iter()
128                .map(|evaluation| {
129                    let write = evaluation.hypothesis().map(|_| {
130                        writes
131                            .next()
132                            .expect("matched hypotheses and writes stay aligned")
133                    });
134                    RuleApplication { evaluation, write }
135                })
136                .collect();
137            debug_assert!(writes.peek().is_none());
138            return Ok(applications);
139        }
140
141        unreachable!("bounded reasoning attempts always return or retry")
142    }
143}