Skip to main content

termivar_scanner/rules/
registry.rs

1use serde::{Deserialize, Deserializer, Serialize};
2use std::num::NonZeroU32;
3use termivar_core::{
4    EvidenceValue, HypothesisState, HypothesisStrength, KnowledgePredicate, Probability,
5};
6
7use crate::rules::{
8    expression::{
9        evidence_value_texts, is_false, text_contains, text_list_contains_exact, Expression,
10        RequiredNullable,
11    },
12    non_empty, RuleEngineError,
13};
14
15/// Selects raw evidence for one Bayesian calibration.
16///
17/// The wire contract requires an explicit nullable `value` field. Canonical
18/// constrained selectors also carry a compatibility guard, so losing one text
19/// matcher cannot silently reconstruct the selector as predicate existence.
20/// Guardless constrained selectors emitted by earlier Venom releases remain
21/// readable and are canonicalized when serialized again.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
23pub struct EvidenceSelector {
24    predicate: KnowledgePredicate,
25    value: Option<EvidenceValue>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    text_contains_ascii_case_insensitive: Option<String>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    text_list_contains_exact: Option<String>,
30    #[serde(default, skip_serializing_if = "is_false")]
31    matcher_policy_guard: bool,
32}
33
34impl EvidenceSelector {
35    /// Selects evidence with an exact predicate and value.
36    pub fn equals(predicate: KnowledgePredicate, value: EvidenceValue) -> Self {
37        Self {
38            predicate,
39            value: Some(value),
40            text_contains_ascii_case_insensitive: None,
41            text_list_contains_exact: None,
42            matcher_policy_guard: true,
43        }
44    }
45
46    /// Selects any evidence with this predicate.
47    pub fn exists(predicate: KnowledgePredicate) -> Self {
48        Self {
49            predicate,
50            value: None,
51            text_contains_ascii_case_insensitive: None,
52            text_list_contains_exact: None,
53            matcher_policy_guard: false,
54        }
55    }
56
57    /// Selects text evidence containing a locale-independent protocol token.
58    pub fn text_contains_ascii_case_insensitive(
59        predicate: KnowledgePredicate,
60        needle: impl Into<String>,
61    ) -> Result<Self, RuleEngineError> {
62        Ok(Self {
63            predicate,
64            value: None,
65            text_contains_ascii_case_insensitive: Some(non_empty(
66                needle,
67                "evidence-selector text needle",
68            )?),
69            text_list_contains_exact: None,
70            matcher_policy_guard: true,
71        })
72    }
73
74    /// Selects evidence whose [`EvidenceValue::TextList`] contains an exact
75    /// element. This is the calibration companion to
76    /// [`Expression::text_list_contains_exact`]: it attributes the likelihood
77    /// only to a record carrying the exact element, never a substring match and
78    /// never a scalar text value — so convention provenance stays truthful.
79    pub fn text_list_contains_exact(
80        predicate: KnowledgePredicate,
81        value: impl Into<String>,
82    ) -> Result<Self, RuleEngineError> {
83        Ok(Self {
84            predicate,
85            value: None,
86            text_contains_ascii_case_insensitive: None,
87            text_list_contains_exact: Some(non_empty(
88                value,
89                "evidence-selector text-list exact value",
90            )?),
91            matcher_policy_guard: true,
92        })
93    }
94
95    /// Returns the selected predicate.
96    pub fn predicate(&self) -> &KnowledgePredicate {
97        &self.predicate
98    }
99
100    /// Returns an optional exact-value constraint.
101    pub fn value(&self) -> Option<&EvidenceValue> {
102        self.value.as_ref()
103    }
104
105    /// Returns the optional ASCII case-insensitive text constraint.
106    pub fn text_needle(&self) -> Option<&str> {
107        self.text_contains_ascii_case_insensitive.as_deref()
108    }
109
110    /// Returns the optional exact text-list membership constraint.
111    pub fn text_list_exact_value(&self) -> Option<&str> {
112        self.text_list_contains_exact.as_deref()
113    }
114
115    pub(super) fn matches(&self, evidence: &termivar_core::Evidence) -> bool {
116        evidence.predicate() == &self.predicate
117            && self
118                .value
119                .as_ref()
120                .is_none_or(|expected| evidence.value() == expected)
121            && self
122                .text_contains_ascii_case_insensitive
123                .as_ref()
124                .is_none_or(|needle| {
125                    evidence_value_texts(evidence.value())
126                        .any(|text| text_contains(text, needle, true))
127                })
128            && self
129                .text_list_contains_exact
130                .as_ref()
131                .is_none_or(|value| text_list_contains_exact(evidence.value(), value))
132    }
133}
134
135impl<'de> Deserialize<'de> for EvidenceSelector {
136    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
137    where
138        D: Deserializer<'de>,
139    {
140        #[derive(Deserialize)]
141        #[serde(deny_unknown_fields)]
142        struct WireSelector {
143            predicate: KnowledgePredicate,
144            #[serde(default)]
145            value: RequiredNullable<EvidenceValue>,
146            #[serde(default)]
147            text_contains_ascii_case_insensitive: Option<String>,
148            #[serde(default)]
149            text_list_contains_exact: Option<String>,
150            #[serde(default)]
151            matcher_policy_guard: Option<bool>,
152        }
153
154        let wire = WireSelector::deserialize(deserializer)?;
155        if !wire.value.is_present() {
156            return Err(serde::de::Error::custom(
157                "evidence selector value field must be present; use null for predicate existence",
158            ));
159        }
160        let value = wire.value.into_inner();
161        let matchers = usize::from(value.is_some())
162            + usize::from(wire.text_contains_ascii_case_insensitive.is_some())
163            + usize::from(wire.text_list_contains_exact.is_some());
164        if matchers > 1 {
165            return Err(serde::de::Error::custom(
166                "evidence selector cannot combine exact, text, and text-list matching",
167            ));
168        }
169        if wire
170            .matcher_policy_guard
171            .is_some_and(|guard| !guard || matchers != 1)
172        {
173            return Err(serde::de::Error::custom(
174                "evidence selector matcher compatibility guard is inconsistent",
175            ));
176        }
177        if wire
178            .text_contains_ascii_case_insensitive
179            .as_ref()
180            .is_some_and(|needle| needle.trim().is_empty())
181        {
182            return Err(serde::de::Error::custom(RuleEngineError::EmptyValue {
183                field: "evidence-selector text needle",
184            }));
185        }
186        if wire
187            .text_list_contains_exact
188            .as_ref()
189            .is_some_and(|value| value.trim().is_empty())
190        {
191            return Err(serde::de::Error::custom(RuleEngineError::EmptyValue {
192                field: "evidence-selector text-list exact value",
193            }));
194        }
195        Ok(Self {
196            predicate: wire.predicate,
197            value,
198            text_contains_ascii_case_insensitive: wire.text_contains_ascii_case_insensitive,
199            text_list_contains_exact: wire.text_list_contains_exact,
200            matcher_policy_guard: matchers == 1,
201        })
202    }
203}
204
205/// How matching observations contribute to one Bayesian calibration.
206///
207/// The default preserves independent contribution semantics. A bounded policy
208/// is explicit and local to one calibration; it never infers independence from
209/// producer names or other forgeable provenance strings.
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
211#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
212#[non_exhaustive]
213pub enum EvidenceAggregation {
214    /// Every distinct matching evidence ID contributes once.
215    #[default]
216    Independent,
217    /// Only the strongest `limit` matches contribute.
218    ///
219    /// Selection is deterministic: reliability, then observation time, then
220    /// evidence ID. The expression trace still retains every candidate match.
221    MaxContributions {
222        /// Non-zero maximum number of observations.
223        limit: NonZeroU32,
224    },
225}
226
227impl EvidenceAggregation {
228    /// Creates an explicit non-zero contribution cap.
229    pub fn max_contributions(limit: u32) -> Result<Self, RuleEngineError> {
230        Ok(Self::MaxContributions {
231            limit: NonZeroU32::new(limit).ok_or(RuleEngineError::InvalidAggregationLimit)?,
232        })
233    }
234
235    pub(super) fn limit(self) -> Option<usize> {
236        match self {
237            Self::Independent => None,
238            Self::MaxContributions { limit } => {
239                Some(usize::try_from(limit.get()).unwrap_or(usize::MAX))
240            },
241        }
242    }
243
244    fn is_independent(&self) -> bool {
245        matches!(self, Self::Independent)
246    }
247}
248
249/// Bayesian likelihoods assigned to evidence selected by a rule.
250///
251/// Missing aggregation remains the historical independent policy. Canonical
252/// bounded calibrations carry a compatibility guard; losing their aggregation
253/// field therefore fails closed instead of removing the contribution cap.
254/// Guardless bounded calibrations emitted by earlier releases remain readable
255/// and are canonicalized when serialized again.
256#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
257pub struct EvidenceCalibration {
258    pub(super) selector: EvidenceSelector,
259    pub(super) likelihood_if_true: Probability,
260    pub(super) likelihood_if_false: Probability,
261    pub(super) rationale: String,
262    #[serde(default, skip_serializing_if = "EvidenceAggregation::is_independent")]
263    pub(super) aggregation: EvidenceAggregation,
264    #[serde(default, skip_serializing_if = "is_false")]
265    aggregation_policy_guard: bool,
266}
267
268impl EvidenceCalibration {
269    /// Creates a calibrated evidence binding with an explanation.
270    pub fn new(
271        selector: EvidenceSelector,
272        likelihood_if_true: Probability,
273        likelihood_if_false: Probability,
274        rationale: impl Into<String>,
275    ) -> Result<Self, RuleEngineError> {
276        Ok(Self {
277            selector,
278            likelihood_if_true,
279            likelihood_if_false,
280            rationale: non_empty(rationale, "evidence calibration rationale")?,
281            aggregation: EvidenceAggregation::Independent,
282            aggregation_policy_guard: false,
283        })
284    }
285
286    /// Applies an explicit contribution policy to this calibration.
287    pub fn with_aggregation(mut self, aggregation: EvidenceAggregation) -> Self {
288        self.aggregation_policy_guard = !aggregation.is_independent();
289        self.aggregation = aggregation;
290        self
291    }
292
293    /// Returns the raw-evidence selector.
294    pub fn selector(&self) -> &EvidenceSelector {
295        &self.selector
296    }
297
298    /// Returns `P(E|H)`.
299    pub fn likelihood_if_true(&self) -> Probability {
300        self.likelihood_if_true
301    }
302
303    /// Returns `P(E|not H)`.
304    pub fn likelihood_if_false(&self) -> Probability {
305        self.likelihood_if_false
306    }
307
308    /// Returns the calibration explanation.
309    pub fn rationale(&self) -> &str {
310        &self.rationale
311    }
312
313    /// Returns how repeated selector matches contribute to the posterior.
314    pub const fn aggregation(&self) -> EvidenceAggregation {
315        self.aggregation
316    }
317}
318
319impl<'de> Deserialize<'de> for EvidenceCalibration {
320    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
321    where
322        D: Deserializer<'de>,
323    {
324        #[derive(Deserialize)]
325        #[serde(deny_unknown_fields)]
326        struct WireCalibration {
327            selector: EvidenceSelector,
328            likelihood_if_true: Probability,
329            likelihood_if_false: Probability,
330            rationale: String,
331            #[serde(default)]
332            aggregation: EvidenceAggregation,
333            #[serde(default)]
334            aggregation_policy_guard: Option<bool>,
335        }
336
337        let wire = WireCalibration::deserialize(deserializer)?;
338        let bounded = !wire.aggregation.is_independent();
339        if wire
340            .aggregation_policy_guard
341            .is_some_and(|guard| !guard || !bounded)
342        {
343            return Err(serde::de::Error::custom(
344                "evidence aggregation compatibility guard is inconsistent",
345            ));
346        }
347        Self::new(
348            wire.selector,
349            wire.likelihood_if_true,
350            wire.likelihood_if_false,
351            wire.rationale,
352        )
353        .map(|calibration| calibration.with_aggregation(wire.aggregation))
354        .map_err(serde::de::Error::custom)
355    }
356}
357
358/// Data needed to materialize one Bayesian hypothesis after a rule matches.
359#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
360pub struct HypothesisConclusion {
361    pub(super) predicate: KnowledgePredicate,
362    pub(super) value: EvidenceValue,
363    pub(super) prior: Probability,
364    pub(super) strength: HypothesisStrength,
365    pub(super) state: HypothesisState,
366    pub(super) calibrations: Vec<EvidenceCalibration>,
367}
368
369impl HypothesisConclusion {
370    /// Creates a conclusion backed by one or more calibrated observations.
371    pub fn new(
372        predicate: KnowledgePredicate,
373        value: EvidenceValue,
374        prior: Probability,
375        strength: HypothesisStrength,
376        state: HypothesisState,
377        calibrations: Vec<EvidenceCalibration>,
378    ) -> Result<Self, RuleEngineError> {
379        if calibrations.is_empty() {
380            return Err(RuleEngineError::EmptyCalibrations);
381        }
382        if matches!(
383            state,
384            HypothesisState::Confirmed | HypothesisState::Rejected
385        ) {
386            return Err(RuleEngineError::VerifierOnlyState { state });
387        }
388        Ok(Self {
389            predicate,
390            value,
391            prior,
392            strength,
393            state,
394            calibrations,
395        })
396    }
397
398    /// Returns the conclusion predicate.
399    pub fn predicate(&self) -> &KnowledgePredicate {
400        &self.predicate
401    }
402
403    /// Returns the conclusion value.
404    pub fn value(&self) -> &EvidenceValue {
405        &self.value
406    }
407
408    /// Returns the calibrated prior.
409    pub fn prior(&self) -> Probability {
410        self.prior
411    }
412
413    /// Returns the rule-assigned evidence strength.
414    pub fn strength(&self) -> HypothesisStrength {
415        self.strength
416    }
417
418    /// Returns the non-verifier lifecycle state.
419    pub fn state(&self) -> HypothesisState {
420        self.state
421    }
422
423    /// Returns evidence calibrations in declared rule order.
424    pub fn calibrations(&self) -> &[EvidenceCalibration] {
425        &self.calibrations
426    }
427}
428
429impl<'de> Deserialize<'de> for HypothesisConclusion {
430    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
431    where
432        D: Deserializer<'de>,
433    {
434        #[derive(Deserialize)]
435        #[serde(deny_unknown_fields)]
436        struct WireConclusion {
437            predicate: KnowledgePredicate,
438            value: EvidenceValue,
439            prior: Probability,
440            strength: HypothesisStrength,
441            state: HypothesisState,
442            calibrations: Vec<EvidenceCalibration>,
443        }
444
445        let wire = WireConclusion::deserialize(deserializer)?;
446        Self::new(
447            wire.predicate,
448            wire.value,
449            wire.prior,
450            wire.strength,
451            wire.state,
452            wire.calibrations,
453        )
454        .map_err(serde::de::Error::custom)
455    }
456}
457
458/// Stable declarative rule from an expression to a Bayesian conclusion.
459#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
460pub struct ReasoningRule {
461    pub(super) id: String,
462    pub(super) condition: Expression,
463    pub(super) conclusion: HypothesisConclusion,
464}
465
466impl ReasoningRule {
467    /// Creates a rule with a stable, non-empty identity.
468    pub fn new(
469        id: impl Into<String>,
470        condition: Expression,
471        conclusion: HypothesisConclusion,
472    ) -> Result<Self, RuleEngineError> {
473        Ok(Self {
474            id: non_empty(id, "rule id")?,
475            condition,
476            conclusion,
477        })
478    }
479
480    /// Returns the stable rule identity.
481    pub fn id(&self) -> &str {
482        &self.id
483    }
484
485    /// Returns the declarative condition.
486    pub fn condition(&self) -> &Expression {
487        &self.condition
488    }
489
490    /// Returns the Bayesian conclusion template.
491    pub fn conclusion(&self) -> &HypothesisConclusion {
492        &self.conclusion
493    }
494}
495
496impl<'de> Deserialize<'de> for ReasoningRule {
497    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
498    where
499        D: Deserializer<'de>,
500    {
501        #[derive(Deserialize)]
502        #[serde(deny_unknown_fields)]
503        struct WireRule {
504            id: String,
505            condition: Expression,
506            conclusion: HypothesisConclusion,
507        }
508
509        let wire = WireRule::deserialize(deserializer)?;
510        Self::new(wire.id, wire.condition, wire.conclusion).map_err(serde::de::Error::custom)
511    }
512}
513
514/// Result of registering a rule identity.
515#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
516#[serde(rename_all = "snake_case")]
517#[non_exhaustive]
518pub enum RuleWrite {
519    /// A new rule was registered.
520    Inserted,
521    /// The identical rule was already registered.
522    Unchanged,
523}