Skip to main content

termivar_scanner/rules/
expression.rs

1use serde::{Deserialize, Deserializer, Serialize};
2use std::collections::BTreeSet;
3use termivar_core::{ConceptId, EvidenceId, EvidenceValue, KnowledgePredicate, RelationTypeId};
4
5use crate::knowledge::KnowledgeSnapshot;
6
7use crate::rules::{non_empty, RuleEngineError};
8
9pub(super) fn is_false(value: &bool) -> bool {
10    !*value
11}
12
13/// A nullable wire value whose field must nevertheless be present.
14///
15/// Serde treats a missing `Option<T>` field as `None`, which is unsafe where
16/// `null` has an explicit meaning distinct from an omitted semantic field. The
17/// transparent wrapper preserves the historical JSON shape while making field
18/// omission a deserialization error.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
20#[serde(transparent)]
21pub(super) struct RequiredNullable<T> {
22    value: Option<T>,
23    #[serde(skip)]
24    present: bool,
25}
26
27impl<T> RequiredNullable<T> {
28    fn present(value: Option<T>) -> Self {
29        Self {
30            value,
31            present: true,
32        }
33    }
34
35    fn as_ref(&self) -> Option<&T> {
36        self.value.as_ref()
37    }
38
39    pub(super) fn is_present(&self) -> bool {
40        self.present
41    }
42
43    pub(super) fn into_inner(self) -> Option<T> {
44        self.value
45    }
46}
47
48impl<T> Default for RequiredNullable<T> {
49    fn default() -> Self {
50        Self {
51            value: None,
52            present: false,
53        }
54    }
55}
56
57impl<'de, T> Deserialize<'de> for RequiredNullable<T>
58where
59    T: Deserialize<'de>,
60{
61    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
62    where
63        D: Deserializer<'de>,
64    {
65        Option::<T>::deserialize(deserializer).map(Self::present)
66    }
67}
68
69/// Knowledge record layer queried by a claim expression.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72#[non_exhaustive]
73pub enum KnowledgeLayer {
74    /// Immutable observations from the evidence engine.
75    Evidence,
76    /// Materialized facts.
77    Fact,
78    /// Bayesian hypotheses produced by earlier decision cycles.
79    Hypothesis,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
84enum ExpressionNode {
85    All {
86        expressions: Vec<Expression>,
87    },
88    Any {
89        expressions: Vec<Expression>,
90    },
91    Not {
92        expression: Box<Expression>,
93    },
94    Claim {
95        layer: KnowledgeLayer,
96        predicate: KnowledgePredicate,
97        #[serde(default)]
98        value: RequiredNullable<EvidenceValue>,
99    },
100    TextContains {
101        layer: KnowledgeLayer,
102        predicate: KnowledgePredicate,
103        needle: String,
104        ascii_case_insensitive: bool,
105    },
106    TextListContainsExact {
107        layer: KnowledgeLayer,
108        predicate: KnowledgePredicate,
109        value: String,
110    },
111    OntologyRelation {
112        subject: ConceptId,
113        relation: RelationTypeId,
114        object: ConceptId,
115    },
116}
117
118/// Typed, serializable condition evaluated against a knowledge snapshot.
119///
120/// Empty `all` and `any` groups are rejected, avoiding vacuous truth and
121/// configuration mistakes. Negated branches never contribute evidence to a
122/// Bayesian conclusion because absence is not an immutable observation.
123/// Claim wire objects require `value` to be present: exact claims carry a typed
124/// value and existence claims carry explicit `null`. Unknown fields reject, so
125/// a misspelled exact value cannot broaden into existence.
126///
127/// # Example
128///
129/// ```rust
130/// use termivar_core::{EvidenceValue, KnowledgePredicate};
131/// use termivar_scanner::{Expression, KnowledgeLayer};
132///
133/// let condition = Expression::all(vec![
134///     Expression::equals(
135///         KnowledgeLayer::Evidence,
136///         KnowledgePredicate::new("technology", "framework")?,
137///         EvidenceValue::Text("Laravel".into()),
138///     ),
139///     Expression::equals(
140///         KnowledgeLayer::Evidence,
141///         KnowledgePredicate::new("authentication", "mechanism")?,
142///         EvidenceValue::Text("Sanctum".into()),
143///     ),
144/// ])?;
145///
146/// assert!(serde_json::to_string(&condition)?.contains("all"));
147/// # Ok::<(), Box<dyn std::error::Error>>(())
148/// ```
149#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
150#[serde(transparent)]
151pub struct Expression(ExpressionNode);
152
153impl Expression {
154    /// Requires every child expression to match.
155    pub fn all(expressions: Vec<Self>) -> Result<Self, RuleEngineError> {
156        if expressions.is_empty() {
157            return Err(RuleEngineError::EmptyExpression { operator: "all" });
158        }
159        Ok(Self(ExpressionNode::All { expressions }))
160    }
161
162    /// Requires at least one child expression to match.
163    pub fn any(expressions: Vec<Self>) -> Result<Self, RuleEngineError> {
164        if expressions.is_empty() {
165            return Err(RuleEngineError::EmptyExpression { operator: "any" });
166        }
167        Ok(Self(ExpressionNode::Any { expressions }))
168    }
169
170    /// Inverts a child condition without treating absence as evidence.
171    pub fn negate(expression: Self) -> Self {
172        Self(ExpressionNode::Not {
173            expression: Box::new(expression),
174        })
175    }
176
177    /// Matches a predicate and exact typed value in one knowledge layer.
178    pub fn equals(
179        layer: KnowledgeLayer,
180        predicate: KnowledgePredicate,
181        value: EvidenceValue,
182    ) -> Self {
183        Self(ExpressionNode::Claim {
184            layer,
185            predicate,
186            value: RequiredNullable::present(Some(value)),
187        })
188    }
189
190    /// Matches the existence of a predicate in one knowledge layer.
191    pub fn exists(layer: KnowledgeLayer, predicate: KnowledgePredicate) -> Self {
192        Self(ExpressionNode::Claim {
193            layer,
194            predicate,
195            value: RequiredNullable::present(None),
196        })
197    }
198
199    /// Matches a substring in a text or text-list claim value.
200    pub fn text_contains(
201        layer: KnowledgeLayer,
202        predicate: KnowledgePredicate,
203        needle: impl Into<String>,
204    ) -> Result<Self, RuleEngineError> {
205        Ok(Self(ExpressionNode::TextContains {
206            layer,
207            predicate,
208            needle: non_empty(needle, "text-match needle")?,
209            ascii_case_insensitive: false,
210        }))
211    }
212
213    /// Matches an ASCII case-insensitive substring in a text claim value.
214    ///
215    /// This comparison is deterministic and locale-independent, making it
216    /// suitable for protocol tokens and product fingerprints.
217    pub fn text_contains_ascii_case_insensitive(
218        layer: KnowledgeLayer,
219        predicate: KnowledgePredicate,
220        needle: impl Into<String>,
221    ) -> Result<Self, RuleEngineError> {
222        Ok(Self(ExpressionNode::TextContains {
223            layer,
224            predicate,
225            needle: non_empty(needle, "text-match needle")?,
226            ascii_case_insensitive: true,
227        }))
228    }
229
230    /// Matches exact membership of a value in an [`EvidenceValue::TextList`].
231    ///
232    /// Unlike [`Self::text_contains`], this compares complete list elements with
233    /// exact, case-sensitive string equality. It never performs substring
234    /// matching and never falls back to a scalar [`EvidenceValue::Text`]: a
235    /// record whose value is `Text("_token")` does not match, and a list element
236    /// `"_token_old"` does not match the value `"_token"`. This keeps typed
237    /// inventory reasoning fail-closed — only a record carrying the exact element
238    /// contributes.
239    pub fn text_list_contains_exact(
240        layer: KnowledgeLayer,
241        predicate: KnowledgePredicate,
242        value: impl Into<String>,
243    ) -> Result<Self, RuleEngineError> {
244        Ok(Self(ExpressionNode::TextListContainsExact {
245            layer,
246            predicate,
247            value: non_empty(value, "text-list exact value")?,
248        }))
249    }
250
251    /// Matches one semantic relationship in the captured ontology.
252    pub fn ontology_relation(
253        subject: ConceptId,
254        relation: RelationTypeId,
255        object: ConceptId,
256    ) -> Self {
257        Self(ExpressionNode::OntologyRelation {
258            subject,
259            relation,
260            object,
261        })
262    }
263
264    /// Evaluates the expression and returns an explainable trace.
265    pub fn evaluate(
266        &self,
267        snapshot: &KnowledgeSnapshot,
268    ) -> Result<ExpressionEvaluation, RuleEngineError> {
269        Ok(ExpressionEvaluation {
270            trace: evaluate_node(&self.0, snapshot)?,
271        })
272    }
273
274    pub(crate) fn uses_only_evidence(&self) -> bool {
275        match &self.0 {
276            ExpressionNode::All { expressions } | ExpressionNode::Any { expressions } => {
277                expressions.iter().all(Self::uses_only_evidence)
278            },
279            ExpressionNode::Not { expression } => expression.uses_only_evidence(),
280            ExpressionNode::Claim { layer, .. }
281            | ExpressionNode::TextContains { layer, .. }
282            | ExpressionNode::TextListContainsExact { layer, .. } => {
283                matches!(layer, KnowledgeLayer::Evidence)
284            },
285            ExpressionNode::OntologyRelation { .. } => false,
286        }
287    }
288}
289
290impl<'de> Deserialize<'de> for Expression {
291    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
292    where
293        D: Deserializer<'de>,
294    {
295        let node = ExpressionNode::deserialize(deserializer)?;
296        match &node {
297            ExpressionNode::All { expressions } if expressions.is_empty() => {
298                return Err(serde::de::Error::custom(RuleEngineError::EmptyExpression {
299                    operator: "all",
300                }));
301            },
302            ExpressionNode::Any { expressions } if expressions.is_empty() => {
303                return Err(serde::de::Error::custom(RuleEngineError::EmptyExpression {
304                    operator: "any",
305                }));
306            },
307            ExpressionNode::Claim { value, .. } if !value.is_present() => {
308                return Err(serde::de::Error::custom(
309                    "claim expression value field must be present; use null for existence",
310                ));
311            },
312            ExpressionNode::TextContains { needle, .. } if needle.trim().is_empty() => {
313                return Err(serde::de::Error::custom(RuleEngineError::EmptyValue {
314                    field: "text-match needle",
315                }));
316            },
317            ExpressionNode::TextListContainsExact { value, .. } if value.trim().is_empty() => {
318                return Err(serde::de::Error::custom(RuleEngineError::EmptyValue {
319                    field: "text-list exact value",
320                }));
321            },
322            _ => {},
323        }
324        Ok(Self(node))
325    }
326}
327
328/// Explainable result of one expression tree.
329#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
330pub struct ExpressionEvaluation {
331    trace: ExpressionTrace,
332}
333
334impl ExpressionEvaluation {
335    /// Returns whether the root expression matched.
336    pub fn matched(&self) -> bool {
337        self.trace.matched
338    }
339
340    /// Returns evidence that positively contributed to the match.
341    pub fn evidence_ids(&self) -> &BTreeSet<EvidenceId> {
342        &self.trace.evidence_ids
343    }
344
345    /// Returns the complete expression tree trace.
346    pub fn trace(&self) -> &ExpressionTrace {
347        &self.trace
348    }
349}
350
351/// One node in an expression evaluation trace.
352#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
353pub struct ExpressionTrace {
354    label: String,
355    matched: bool,
356    evidence_ids: BTreeSet<EvidenceId>,
357    children: Vec<ExpressionTrace>,
358}
359
360impl ExpressionTrace {
361    /// Returns a stable human-readable description of this operation.
362    pub fn label(&self) -> &str {
363        &self.label
364    }
365
366    /// Returns whether this operation matched.
367    pub fn matched(&self) -> bool {
368        self.matched
369    }
370
371    /// Returns positively contributing evidence at this node.
372    pub fn evidence_ids(&self) -> &BTreeSet<EvidenceId> {
373        &self.evidence_ids
374    }
375
376    /// Returns child operations in declared expression order.
377    pub fn children(&self) -> &[ExpressionTrace] {
378        &self.children
379    }
380}
381
382fn evaluate_node(
383    node: &ExpressionNode,
384    snapshot: &KnowledgeSnapshot,
385) -> Result<ExpressionTrace, RuleEngineError> {
386    match node {
387        ExpressionNode::All { expressions } => {
388            let children = evaluate_children(expressions, snapshot)?;
389            let matched = children.iter().all(ExpressionTrace::matched);
390            let evidence_ids = if matched {
391                collect_trace_evidence(&children)
392            } else {
393                BTreeSet::new()
394            };
395            Ok(ExpressionTrace {
396                label: "all".into(),
397                matched,
398                evidence_ids,
399                children,
400            })
401        },
402        ExpressionNode::Any { expressions } => {
403            let children = evaluate_children(expressions, snapshot)?;
404            let matched = children.iter().any(ExpressionTrace::matched);
405            let evidence_ids = children
406                .iter()
407                .filter(|child| child.matched)
408                .flat_map(|child| child.evidence_ids.iter().cloned())
409                .collect();
410            Ok(ExpressionTrace {
411                label: "any".into(),
412                matched,
413                evidence_ids,
414                children,
415            })
416        },
417        ExpressionNode::Not { expression } => {
418            let child = evaluate_node(&expression.0, snapshot)?;
419            Ok(ExpressionTrace {
420                label: "not".into(),
421                matched: !child.matched,
422                evidence_ids: BTreeSet::new(),
423                children: vec![child],
424            })
425        },
426        ExpressionNode::Claim {
427            layer,
428            predicate,
429            value,
430        } => Ok(evaluate_claim(*layer, predicate, value.as_ref(), snapshot)),
431        ExpressionNode::TextContains {
432            layer,
433            predicate,
434            needle,
435            ascii_case_insensitive,
436        } => Ok(evaluate_text_contains(
437            *layer,
438            predicate,
439            needle,
440            *ascii_case_insensitive,
441            snapshot,
442        )),
443        ExpressionNode::TextListContainsExact {
444            layer,
445            predicate,
446            value,
447        } => Ok(evaluate_text_list_contains_exact(
448            *layer, predicate, value, snapshot,
449        )),
450        ExpressionNode::OntologyRelation {
451            subject,
452            relation,
453            object,
454        } => Ok(ExpressionTrace {
455            label: format!("ontology:{subject}:{relation}:{object}"),
456            matched: snapshot.ontology().is_related(subject, relation, object)?,
457            evidence_ids: BTreeSet::new(),
458            children: Vec::new(),
459        }),
460    }
461}
462
463fn evaluate_text_contains(
464    layer: KnowledgeLayer,
465    predicate: &KnowledgePredicate,
466    needle: &str,
467    ascii_case_insensitive: bool,
468    snapshot: &KnowledgeSnapshot,
469) -> ExpressionTrace {
470    let matches_text = |value: &EvidenceValue| {
471        evidence_value_texts(value).any(|text| text_contains(text, needle, ascii_case_insensitive))
472    };
473    let mut evidence_ids = BTreeSet::new();
474    let matched = match layer {
475        KnowledgeLayer::Evidence => {
476            let matches: Vec<_> = snapshot
477                .evidence()
478                .iter()
479                .filter(|evidence| {
480                    evidence.predicate() == predicate && matches_text(evidence.value())
481                })
482                .collect();
483            evidence_ids.extend(matches.iter().map(|evidence| evidence.id().clone()));
484            !matches.is_empty()
485        },
486        KnowledgeLayer::Fact => {
487            let matches: Vec<_> = snapshot
488                .facts()
489                .iter()
490                .filter(|fact| fact.predicate() == predicate && matches_text(fact.value()))
491                .collect();
492            evidence_ids.extend(
493                matches
494                    .iter()
495                    .flat_map(|fact| fact.evidence_ids().iter().cloned()),
496            );
497            !matches.is_empty()
498        },
499        KnowledgeLayer::Hypothesis => {
500            let matches: Vec<_> = snapshot
501                .hypotheses()
502                .iter()
503                .filter(|hypothesis| {
504                    hypothesis.predicate() == predicate && matches_text(hypothesis.value())
505                })
506                .collect();
507            evidence_ids.extend(matches.iter().flat_map(|hypothesis| {
508                hypothesis
509                    .belief()
510                    .evidence()
511                    .iter()
512                    .map(|observation| observation.evidence_id().clone())
513            }));
514            !matches.is_empty()
515        },
516    };
517
518    let comparison = if ascii_case_insensitive {
519        "contains-ascii-ci"
520    } else {
521        "contains"
522    };
523    ExpressionTrace {
524        label: format!("{layer:?}:{}:{comparison}:{needle}", predicate.dotted()),
525        matched,
526        evidence_ids,
527        children: Vec::new(),
528    }
529}
530
531pub(super) fn evidence_value_texts(value: &EvidenceValue) -> Box<dyn Iterator<Item = &str> + '_> {
532    match value {
533        EvidenceValue::Text(text) => Box::new(std::iter::once(text.as_str())),
534        EvidenceValue::TextList(values) => Box::new(values.iter().map(String::as_str)),
535        _ => Box::new(std::iter::empty()),
536    }
537}
538
539pub(super) fn text_contains(value: &str, needle: &str, ascii_case_insensitive: bool) -> bool {
540    if ascii_case_insensitive {
541        value
542            .to_ascii_lowercase()
543            .contains(&needle.to_ascii_lowercase())
544    } else {
545        value.contains(needle)
546    }
547}
548
549fn evaluate_text_list_contains_exact(
550    layer: KnowledgeLayer,
551    predicate: &KnowledgePredicate,
552    value: &str,
553    snapshot: &KnowledgeSnapshot,
554) -> ExpressionTrace {
555    let matches_list = |candidate: &EvidenceValue| text_list_contains_exact(candidate, value);
556    let mut evidence_ids = BTreeSet::new();
557    let matched = match layer {
558        KnowledgeLayer::Evidence => {
559            let matches: Vec<_> = snapshot
560                .evidence()
561                .iter()
562                .filter(|evidence| {
563                    evidence.predicate() == predicate && matches_list(evidence.value())
564                })
565                .collect();
566            evidence_ids.extend(matches.iter().map(|evidence| evidence.id().clone()));
567            !matches.is_empty()
568        },
569        KnowledgeLayer::Fact => {
570            let matches: Vec<_> = snapshot
571                .facts()
572                .iter()
573                .filter(|fact| fact.predicate() == predicate && matches_list(fact.value()))
574                .collect();
575            evidence_ids.extend(
576                matches
577                    .iter()
578                    .flat_map(|fact| fact.evidence_ids().iter().cloned()),
579            );
580            !matches.is_empty()
581        },
582        KnowledgeLayer::Hypothesis => {
583            let matches: Vec<_> = snapshot
584                .hypotheses()
585                .iter()
586                .filter(|hypothesis| {
587                    hypothesis.predicate() == predicate && matches_list(hypothesis.value())
588                })
589                .collect();
590            evidence_ids.extend(matches.iter().flat_map(|hypothesis| {
591                hypothesis
592                    .belief()
593                    .evidence()
594                    .iter()
595                    .map(|observation| observation.evidence_id().clone())
596            }));
597            !matches.is_empty()
598        },
599    };
600
601    ExpressionTrace {
602        label: format!(
603            "{layer:?}:{}:list-contains-exact:{value}",
604            predicate.dotted()
605        ),
606        matched,
607        evidence_ids,
608        children: Vec::new(),
609    }
610}
611
612/// Exact membership in a text list. Matches only [`EvidenceValue::TextList`]
613/// with element-wise, case-sensitive equality — never a scalar text value and
614/// never a substring.
615pub(super) fn text_list_contains_exact(value: &EvidenceValue, target: &str) -> bool {
616    matches!(
617        value,
618        EvidenceValue::TextList(values) if values.iter().any(|element| element == target)
619    )
620}
621
622fn evaluate_children(
623    expressions: &[Expression],
624    snapshot: &KnowledgeSnapshot,
625) -> Result<Vec<ExpressionTrace>, RuleEngineError> {
626    expressions
627        .iter()
628        .map(|expression| evaluate_node(&expression.0, snapshot))
629        .collect()
630}
631
632fn collect_trace_evidence(children: &[ExpressionTrace]) -> BTreeSet<EvidenceId> {
633    children
634        .iter()
635        .flat_map(|child| child.evidence_ids.iter().cloned())
636        .collect()
637}
638
639fn evaluate_claim(
640    layer: KnowledgeLayer,
641    predicate: &KnowledgePredicate,
642    value: Option<&EvidenceValue>,
643    snapshot: &KnowledgeSnapshot,
644) -> ExpressionTrace {
645    let mut evidence_ids = BTreeSet::new();
646    let matched = match layer {
647        KnowledgeLayer::Evidence => {
648            let matches: Vec<_> = snapshot
649                .evidence()
650                .iter()
651                .filter(|evidence| {
652                    evidence.predicate() == predicate
653                        && value.is_none_or(|expected| evidence.value() == expected)
654                })
655                .collect();
656            evidence_ids.extend(matches.iter().map(|evidence| evidence.id().clone()));
657            !matches.is_empty()
658        },
659        KnowledgeLayer::Fact => {
660            let matches: Vec<_> = snapshot
661                .facts()
662                .iter()
663                .filter(|fact| {
664                    fact.predicate() == predicate
665                        && value.is_none_or(|expected| fact.value() == expected)
666                })
667                .collect();
668            evidence_ids.extend(
669                matches
670                    .iter()
671                    .flat_map(|fact| fact.evidence_ids().iter().cloned()),
672            );
673            !matches.is_empty()
674        },
675        KnowledgeLayer::Hypothesis => {
676            let matches: Vec<_> = snapshot
677                .hypotheses()
678                .iter()
679                .filter(|hypothesis| {
680                    hypothesis.predicate() == predicate
681                        && value.is_none_or(|expected| hypothesis.value() == expected)
682                })
683                .collect();
684            evidence_ids.extend(matches.iter().flat_map(|hypothesis| {
685                hypothesis
686                    .belief()
687                    .evidence()
688                    .iter()
689                    .map(|observation| observation.evidence_id().clone())
690            }));
691            !matches.is_empty()
692        },
693    };
694
695    let comparison = value.map_or_else(|| "exists".into(), |value| format!("equals:{value:?}"));
696    ExpressionTrace {
697        label: format!("{layer:?}:{}:{comparison}", predicate.dotted()),
698        matched,
699        evidence_ids,
700        children: Vec::new(),
701    }
702}