termivar_scanner/rules/
engine.rs1use 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#[derive(Debug, Clone, Default)]
20pub struct RuleEngine {
21 rules: BTreeMap<String, ReasoningRule>,
22}
23
24impl RuleEngine {
25 pub fn new() -> Self {
27 Self::default()
28 }
29
30 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 pub fn len(&self) -> usize {
47 self.rules.len()
48 }
49
50 pub fn is_empty(&self) -> bool {
52 self.rules.is_empty()
53 }
54
55 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 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 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}