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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72#[non_exhaustive]
73pub enum KnowledgeLayer {
74 Evidence,
76 Fact,
78 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
150#[serde(transparent)]
151pub struct Expression(ExpressionNode);
152
153impl Expression {
154 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 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 pub fn negate(expression: Self) -> Self {
172 Self(ExpressionNode::Not {
173 expression: Box::new(expression),
174 })
175 }
176
177 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 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 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 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
330pub struct ExpressionEvaluation {
331 trace: ExpressionTrace,
332}
333
334impl ExpressionEvaluation {
335 pub fn matched(&self) -> bool {
337 self.trace.matched
338 }
339
340 pub fn evidence_ids(&self) -> &BTreeSet<EvidenceId> {
342 &self.trace.evidence_ids
343 }
344
345 pub fn trace(&self) -> &ExpressionTrace {
347 &self.trace
348 }
349}
350
351#[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 pub fn label(&self) -> &str {
363 &self.label
364 }
365
366 pub fn matched(&self) -> bool {
368 self.matched
369 }
370
371 pub fn evidence_ids(&self) -> &BTreeSet<EvidenceId> {
373 &self.evidence_ids
374 }
375
376 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
612pub(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}