Skip to main content

termivar_scanner/rules/
evaluation.rs

1use serde::Serialize;
2use std::collections::BTreeMap;
3use termivar_core::{BayesianEvidence, EvidenceId, Hypothesis};
4
5use crate::knowledge::{KnowledgeSnapshot, KnowledgeWrite};
6
7use crate::rules::{
8    expression::ExpressionEvaluation, hypothesis_id_for_rule, registry::ReasoningRule,
9    RuleEngineError,
10};
11
12/// Pure result of evaluating one rule against one snapshot.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
14pub struct RuleEvaluation {
15    rule_id: String,
16    condition: ExpressionEvaluation,
17    hypothesis: Option<Hypothesis>,
18}
19
20impl RuleEvaluation {
21    /// Returns the evaluated rule identity.
22    pub fn rule_id(&self) -> &str {
23        &self.rule_id
24    }
25
26    /// Returns whether the condition matched.
27    pub fn matched(&self) -> bool {
28        self.condition.matched()
29    }
30
31    /// Returns the expression result and trace.
32    pub fn condition(&self) -> &ExpressionEvaluation {
33        &self.condition
34    }
35
36    /// Returns the materialized hypothesis when the condition matched.
37    pub fn hypothesis(&self) -> Option<&Hypothesis> {
38        self.hypothesis.as_ref()
39    }
40}
41
42/// Result of evaluating one rule and committing its conclusion in a reasoning batch.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
44pub struct RuleApplication {
45    pub(super) evaluation: RuleEvaluation,
46    pub(super) write: Option<KnowledgeWrite>,
47}
48
49impl RuleApplication {
50    /// Returns the pure evaluation that preceded the committed batch.
51    ///
52    /// This is the snapshot candidate, not a fresh read of committed state.
53    /// Terminal-state preservation can therefore make the stored lifecycle
54    /// state differ from this hypothesis; query the knowledge base when the
55    /// post-commit record is required.
56    pub fn evaluation(&self) -> &RuleEvaluation {
57        &self.evaluation
58    }
59
60    /// Returns the knowledge write, or `None` for an unmatched rule.
61    pub fn write(&self) -> Option<KnowledgeWrite> {
62        self.write
63    }
64}
65
66pub(super) fn evaluate_rule(
67    rule: &ReasoningRule,
68    snapshot: &KnowledgeSnapshot,
69) -> Result<RuleEvaluation, RuleEngineError> {
70    let condition = rule.condition.evaluate(snapshot)?;
71    let hypothesis = if condition.matched() {
72        Some(materialize_hypothesis(rule, snapshot, &condition)?)
73    } else {
74        None
75    };
76    Ok(RuleEvaluation {
77        rule_id: rule.id.clone(),
78        condition,
79        hypothesis,
80    })
81}
82
83fn materialize_hypothesis(
84    rule: &ReasoningRule,
85    snapshot: &KnowledgeSnapshot,
86    condition: &ExpressionEvaluation,
87) -> Result<Hypothesis, RuleEngineError> {
88    let mut observations = BTreeMap::<EvidenceId, BayesianEvidence>::new();
89    for calibration in &rule.conclusion.calibrations {
90        let mut matches = snapshot
91            .evidence()
92            .iter()
93            .filter(|evidence| {
94                condition.evidence_ids().contains(evidence.id())
95                    && calibration.selector.matches(evidence)
96            })
97            .collect::<Vec<_>>();
98        if let Some(limit) = calibration.aggregation.limit() {
99            matches.sort_by(|left, right| {
100                right
101                    .reliability()
102                    .cmp(&left.reliability())
103                    .then_with(|| right.observed_at_ms().cmp(&left.observed_at_ms()))
104                    .then_with(|| left.id().cmp(right.id()))
105            });
106            matches.truncate(limit);
107        }
108        for evidence in matches {
109            let observation = BayesianEvidence::new(
110                evidence.id().clone(),
111                calibration.likelihood_if_true,
112                calibration.likelihood_if_false,
113                calibration.rationale.clone(),
114            )?;
115            if let Some(existing) = observations.get(evidence.id()) {
116                if existing != &observation {
117                    return Err(RuleEngineError::AmbiguousEvidenceCalibration {
118                        rule_id: rule.id.clone(),
119                        evidence_id: evidence.id().clone(),
120                    });
121                }
122            } else {
123                observations.insert(evidence.id().clone(), observation);
124            }
125        }
126    }
127
128    if observations.is_empty() {
129        return Err(RuleEngineError::MissingCalibratedEvidence {
130            rule_id: rule.id.clone(),
131        });
132    }
133
134    let stable_id = hypothesis_id_for_rule(&rule.id, snapshot.subject());
135    let mut hypothesis = Hypothesis::with_id(
136        stable_id,
137        snapshot.subject().clone(),
138        rule.conclusion.predicate.clone(),
139        rule.conclusion.value.clone(),
140        rule.conclusion.prior,
141    )?;
142    for observation in observations.into_values() {
143        hypothesis.observe(observation)?;
144    }
145    hypothesis.set_strength(rule.conclusion.strength);
146    hypothesis.set_state(rule.conclusion.state);
147    Ok(hypothesis)
148}