1use termivar_core::{EntityId, EvidenceId, HypothesisState, OntologyError, ReasoningModelError};
17use thiserror::Error;
18
19use crate::knowledge::KnowledgeBaseError;
20
21mod engine;
22mod evaluation;
23mod expression;
24mod registry;
25
26pub use engine::RuleEngine;
27pub use evaluation::{RuleApplication, RuleEvaluation};
28pub use expression::{Expression, ExpressionEvaluation, ExpressionTrace, KnowledgeLayer};
29pub use registry::{
30 EvidenceAggregation, EvidenceCalibration, EvidenceSelector, HypothesisConclusion,
31 ReasoningRule, RuleWrite,
32};
33
34#[cfg(test)]
35use crate::knowledge::{KnowledgeBase, KnowledgeWrite};
36#[cfg(test)]
37use engine::MAX_REASONING_APPLY_ATTEMPTS;
38#[cfg(test)]
39use std::collections::BTreeSet;
40#[cfg(test)]
41use termivar_core::{
42 BayesianEvidence, ConceptId, EvidenceValue, Hypothesis, HypothesisStrength, KnowledgePredicate,
43 Probability, RelationTypeId,
44};
45
46pub(crate) fn hypothesis_id_for_rule(rule_id: &str, subject: &EntityId) -> String {
51 format!("rule:{}:{rule_id}:{subject}", rule_id.len())
52}
53
54#[derive(Debug, Error)]
56#[non_exhaustive]
57pub enum RuleEngineError {
58 #[error("{field} must not be empty")]
60 EmptyValue { field: &'static str },
61
62 #[error("{operator} expression must contain at least one child")]
64 EmptyExpression { operator: &'static str },
65
66 #[error("hypothesis conclusion must contain at least one evidence calibration")]
68 EmptyCalibrations,
69
70 #[error("evidence aggregation limit must be greater than zero")]
72 InvalidAggregationLimit,
73
74 #[error("hypothesis state {state:?} can only be assigned by a verifier")]
76 VerifierOnlyState { state: HypothesisState },
77
78 #[error("rule identity {id} already has a different definition")]
80 RuleIdentityConflict { id: String },
81
82 #[error("matched rule {rule_id} has no calibrated contributing evidence")]
84 MissingCalibratedEvidence { rule_id: String },
85
86 #[error("rule {rule_id} assigns ambiguous calibration to evidence {evidence_id}")]
88 AmbiguousEvidenceCalibration {
89 rule_id: String,
91 evidence_id: EvidenceId,
93 },
94
95 #[error(transparent)]
97 Ontology(#[from] OntologyError),
98
99 #[error(transparent)]
101 Reasoning(#[from] ReasoningModelError),
102
103 #[error(transparent)]
105 Knowledge(#[from] KnowledgeBaseError),
106
107 #[error("reasoning snapshot stayed stale after {attempts} commit attempts")]
109 StaleSnapshotRetriesExhausted { attempts: u8 },
110}
111
112pub(super) fn non_empty(
113 value: impl Into<String>,
114 field: &'static str,
115) -> Result<String, RuleEngineError> {
116 let value = value.into();
117 if value.trim().is_empty() {
118 return Err(RuleEngineError::EmptyValue { field });
119 }
120 Ok(value)
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use termivar_core::{
127 ConfidenceScore, EntityId, Evidence, EvidenceId, EvidenceKind, EvidenceSource, Fact,
128 Ontology, OntologyAxiom, OntologyConcept,
129 };
130
131 fn subject() -> EntityId {
132 EntityId::new("endpoint:https://example.test").unwrap()
133 }
134
135 fn framework_predicate() -> KnowledgePredicate {
136 KnowledgePredicate::new("technology", "framework").unwrap()
137 }
138
139 fn auth_predicate() -> KnowledgePredicate {
140 KnowledgePredicate::new("authentication", "mechanism").unwrap()
141 }
142
143 fn evidence(predicate: KnowledgePredicate, value: EvidenceValue) -> Evidence {
144 Evidence::new(
145 subject(),
146 EvidenceKind::Technology,
147 predicate,
148 value,
149 EvidenceSource::new("discovery", "test").unwrap(),
150 ConfidenceScore::from_percent(90).unwrap(),
151 )
152 }
153
154 fn calibration(
155 predicate: KnowledgePredicate,
156 value: EvidenceValue,
157 likelihood_if_true: u8,
158 likelihood_if_false: u8,
159 ) -> EvidenceCalibration {
160 EvidenceCalibration::new(
161 EvidenceSelector::equals(predicate, value),
162 Probability::from_percent(likelihood_if_true).unwrap(),
163 Probability::from_percent(likelihood_if_false).unwrap(),
164 "test calibration",
165 )
166 .unwrap()
167 }
168
169 fn laravel_rule(id: &str) -> ReasoningRule {
170 let framework = framework_predicate();
171 let auth = auth_predicate();
172 let laravel = EvidenceValue::Text("Laravel".into());
173 let sanctum = EvidenceValue::Text("Sanctum".into());
174 ReasoningRule::new(
175 id,
176 Expression::all(vec![
177 Expression::equals(KnowledgeLayer::Evidence, framework.clone(), laravel.clone()),
178 Expression::equals(KnowledgeLayer::Evidence, auth.clone(), sanctum.clone()),
179 ])
180 .unwrap(),
181 HypothesisConclusion::new(
182 KnowledgePredicate::new("stack", "framework").unwrap(),
183 laravel.clone(),
184 Probability::from_percent(10).unwrap(),
185 HypothesisStrength::Strong,
186 HypothesisState::Supported,
187 vec![
188 calibration(framework, laravel, 80, 20),
189 calibration(auth, sanctum, 90, 10),
190 ],
191 )
192 .unwrap(),
193 )
194 .unwrap()
195 }
196
197 #[test]
198 fn expression_composition_tracks_only_positive_matching_evidence() {
199 let knowledge = KnowledgeBase::new();
200 let framework_evidence =
201 evidence(framework_predicate(), EvidenceValue::Text("Laravel".into()));
202 let framework_id = framework_evidence.id().clone();
203 knowledge.insert_evidence(framework_evidence).unwrap();
204 let expression = Expression::all(vec![
205 Expression::equals(
206 KnowledgeLayer::Evidence,
207 framework_predicate(),
208 EvidenceValue::Text("Laravel".into()),
209 ),
210 Expression::negate(Expression::exists(
211 KnowledgeLayer::Evidence,
212 KnowledgePredicate::new("security", "waf").unwrap(),
213 )),
214 ])
215 .unwrap();
216
217 let evaluation = expression
218 .evaluate(&knowledge.snapshot_for_subject(&subject()))
219 .unwrap();
220
221 assert!(evaluation.matched());
222 assert_eq!(evaluation.evidence_ids(), &BTreeSet::from([framework_id]));
223 assert_eq!(evaluation.trace().children().len(), 2);
224 assert!(evaluation.trace().children()[1].evidence_ids().is_empty());
225 }
226
227 #[test]
228 fn all_and_any_preserve_truth_and_root_provenance() {
229 let knowledge = KnowledgeBase::new();
230 let matching = evidence(framework_predicate(), EvidenceValue::Text("Laravel".into()));
231 let matching_id = matching.id().clone();
232 knowledge.insert_evidence(matching).unwrap();
233 let missing = KnowledgePredicate::new("security", "waf").unwrap();
234 let snapshot = knowledge.snapshot_for_subject(&subject());
235
236 let any = Expression::any(vec![
237 Expression::equals(
238 KnowledgeLayer::Evidence,
239 framework_predicate(),
240 EvidenceValue::Text("Laravel".into()),
241 ),
242 Expression::exists(KnowledgeLayer::Evidence, missing.clone()),
243 ])
244 .unwrap()
245 .evaluate(&snapshot)
246 .unwrap();
247 assert!(any.matched());
248 assert_eq!(any.evidence_ids(), &BTreeSet::from([matching_id.clone()]));
249
250 let all = Expression::all(vec![
251 Expression::equals(
252 KnowledgeLayer::Evidence,
253 framework_predicate(),
254 EvidenceValue::Text("Laravel".into()),
255 ),
256 Expression::exists(KnowledgeLayer::Evidence, missing),
257 ])
258 .unwrap()
259 .evaluate(&snapshot)
260 .unwrap();
261 assert!(!all.matched());
262 assert!(all.evidence_ids().is_empty());
263 assert_eq!(
264 all.trace().children()[0].evidence_ids(),
265 &BTreeSet::from([matching_id])
266 );
267 }
268
269 #[test]
270 fn expression_wire_format_rejects_empty_groups() {
271 assert!(Expression::all(Vec::new()).is_err());
272 for operator in ["all", "any"] {
273 assert!(serde_json::from_value::<Expression>(serde_json::json!({
274 "op": operator,
275 "expressions": []
276 }))
277 .is_err());
278 }
279
280 let leaf = Expression::exists(KnowledgeLayer::Evidence, framework_predicate());
281 for expression in [
282 Expression::all(vec![leaf.clone()]).unwrap(),
283 Expression::any(vec![leaf]).unwrap(),
284 ] {
285 let wire = serde_json::to_value(&expression).unwrap();
286 assert_eq!(
287 serde_json::from_value::<Expression>(wire).unwrap(),
288 expression
289 );
290 }
291 }
292
293 #[test]
294 fn malformed_expression_wire_cannot_broaden_equals_to_exists() {
295 let expression = Expression::equals(
296 KnowledgeLayer::Evidence,
297 framework_predicate(),
298 EvidenceValue::Text("Laravel".into()),
299 );
300 let knowledge = KnowledgeBase::new();
301 knowledge
302 .insert_evidence(evidence(
303 framework_predicate(),
304 EvidenceValue::Text("Apache".into()),
305 ))
306 .unwrap();
307 assert!(!expression
308 .evaluate(&knowledge.snapshot_for_subject(&subject()))
309 .unwrap()
310 .matched());
311 let mut encoded = serde_json::to_value(&expression).unwrap();
312 let value = encoded.as_object_mut().unwrap().remove("value").unwrap();
313 let missing = encoded.clone();
314 encoded["vlaue"] = value;
315
316 assert!(serde_json::from_value::<Expression>(missing).is_err());
317 assert!(serde_json::from_value::<Expression>(encoded).is_err());
318 }
319
320 #[test]
321 fn expression_wire_requires_explicit_null_for_historical_exists() {
322 let expression = Expression::exists(KnowledgeLayer::Evidence, framework_predicate());
323 let encoded = serde_json::to_value(&expression).unwrap();
324 assert!(encoded.get("value").is_some_and(serde_json::Value::is_null));
325 assert_eq!(
326 serde_json::from_value::<Expression>(encoded.clone()).unwrap(),
327 expression
328 );
329
330 let mut missing = encoded.clone();
331 missing.as_object_mut().unwrap().remove("value");
332 assert!(serde_json::from_value::<Expression>(missing).is_err());
333
334 let mut extended = encoded;
335 extended["matcher_future"] = serde_json::json!(true);
336 assert!(serde_json::from_value::<Expression>(extended).is_err());
337 }
338
339 #[test]
340 fn malformed_nested_expressions_cannot_broaden_a_reasoning_rule() {
341 let mut encoded = serde_json::to_value(laravel_rule("wire.expression.strict")).unwrap();
342 let first_claim = &mut encoded["condition"]["expressions"][0];
343 let value = first_claim
344 .as_object_mut()
345 .unwrap()
346 .remove("value")
347 .unwrap();
348 first_claim["vlaue"] = value;
349
350 assert!(serde_json::from_value::<ReasoningRule>(encoded).is_err());
351
352 let mut empty_all =
353 serde_json::to_value(laravel_rule("wire.expression.empty-all")).unwrap();
354 empty_all["condition"] = serde_json::json!({
355 "op": "all",
356 "expressions": []
357 });
358 assert!(serde_json::from_value::<ReasoningRule>(empty_all).is_err());
359
360 let mut empty_contains =
361 serde_json::to_value(laravel_rule("wire.expression.empty-contains")).unwrap();
362 empty_contains["condition"] = serde_json::json!({
363 "op": "text_contains",
364 "layer": "evidence",
365 "predicate": framework_predicate(),
366 "needle": " ",
367 "ascii_case_insensitive": false
368 });
369 assert!(serde_json::from_value::<ReasoningRule>(empty_contains).is_err());
370 }
371
372 #[test]
373 fn text_expression_matches_ascii_case_insensitively_with_provenance() {
374 let knowledge = KnowledgeBase::new();
375 let server = KnowledgePredicate::new("http.header", "server").unwrap();
376 let observation = evidence(server.clone(), EvidenceValue::Text("NGINX/1.26".into()));
377 let evidence_id = observation.id().clone();
378 knowledge.insert_evidence(observation).unwrap();
379 let expression = Expression::text_contains_ascii_case_insensitive(
380 KnowledgeLayer::Evidence,
381 server,
382 "nginx",
383 )
384 .unwrap();
385
386 let evaluation = expression
387 .evaluate(&knowledge.snapshot_for_subject(&subject()))
388 .unwrap();
389
390 assert!(evaluation.matched());
391 assert_eq!(evaluation.evidence_ids(), &BTreeSet::from([evidence_id]));
392 assert!(evaluation.trace().label().contains("contains-ascii-ci"));
393 let encoded = serde_json::to_value(&expression).unwrap();
394 assert_eq!(
395 serde_json::from_value::<Expression>(encoded).unwrap(),
396 expression
397 );
398 assert!(
399 Expression::text_contains(KnowledgeLayer::Evidence, framework_predicate(), " ")
400 .is_err()
401 );
402
403 let mut empty_wire = serde_json::to_value(&expression).unwrap();
404 empty_wire["needle"] = serde_json::json!(" ");
405 assert!(serde_json::from_value::<Expression>(empty_wire).is_err());
406 }
407
408 fn form_controls() -> KnowledgePredicate {
409 KnowledgePredicate::new("http.response", "form-control-names").unwrap()
410 }
411
412 fn evaluate_exact(value: EvidenceValue, target: &str) -> ExpressionEvaluation {
413 let knowledge = KnowledgeBase::new();
414 knowledge
415 .insert_evidence(evidence(form_controls(), value))
416 .unwrap();
417 Expression::text_list_contains_exact(KnowledgeLayer::Evidence, form_controls(), target)
418 .unwrap()
419 .evaluate(&knowledge.snapshot_for_subject(&subject()))
420 .unwrap()
421 }
422
423 fn list(values: &[&str]) -> EvidenceValue {
424 EvidenceValue::TextList(values.iter().map(|value| (*value).to_owned()).collect())
425 }
426
427 #[test]
428 fn text_list_contains_exact_matches_only_whole_elements() {
429 assert!(evaluate_exact(list(&["_token", "email"]), "_token").matched());
432 assert!(evaluate_exact(list(&["_method"]), "_method").matched());
433
434 for (value, target) in [
435 (list(&["_token_backup"]), "_token"),
436 (list(&["_token_old"]), "_token"),
437 (list(&["my_token"]), "_token"),
438 (list(&[" _token "]), "_token"),
439 (list(&["_METHOD"]), "_method"),
440 (list(&[]), "_token"),
441 (EvidenceValue::Text("_token".to_owned()), "_token"),
443 ] {
444 assert!(
445 !evaluate_exact(value.clone(), target).matched(),
446 "`{value:?}` must not contain-exact `{target}`"
447 );
448 }
449 }
450
451 #[test]
452 fn text_list_contains_exact_attributes_only_the_contributing_evidence() {
453 let knowledge = KnowledgeBase::new();
454 let matching = evidence(form_controls(), list(&["email", "_token"]));
455 let matching_id = matching.id().clone();
456 let other = evidence(form_controls(), list(&["username", "_token_old"]));
457 let other_id = other.id().clone();
458 knowledge
459 .insert_evidence_batch(vec![matching, other])
460 .unwrap();
461
462 let evaluation = Expression::text_list_contains_exact(
463 KnowledgeLayer::Evidence,
464 form_controls(),
465 "_token",
466 )
467 .unwrap()
468 .evaluate(&knowledge.snapshot_for_subject(&subject()))
469 .unwrap();
470
471 assert!(evaluation.matched());
472 assert_eq!(evaluation.evidence_ids(), &BTreeSet::from([matching_id]));
473 assert!(!evaluation.evidence_ids().contains(&other_id));
474 assert!(evaluation.trace().label().contains("list-contains-exact"));
475 }
476
477 #[test]
478 fn text_list_contains_exact_validates_and_round_trips() {
479 let expression = Expression::text_list_contains_exact(
480 KnowledgeLayer::Evidence,
481 form_controls(),
482 "_token",
483 )
484 .unwrap();
485 let encoded = serde_json::to_value(&expression).unwrap();
486 assert_eq!(encoded["op"], "text_list_contains_exact");
487 assert_eq!(
488 serde_json::from_value::<Expression>(encoded).unwrap(),
489 expression
490 );
491
492 assert!(Expression::text_list_contains_exact(
495 KnowledgeLayer::Evidence,
496 form_controls(),
497 " "
498 )
499 .is_err());
500 assert!(serde_json::from_value::<Expression>(serde_json::json!({
501 "op": "text_list_contains_exact",
502 "layer": "evidence",
503 "predicate": form_controls(),
504 "value": " "
505 }))
506 .is_err());
507 }
508
509 #[test]
510 fn text_list_evidence_selector_matches_validates_and_round_trips() {
511 let selector =
512 EvidenceSelector::text_list_contains_exact(form_controls(), "_token").unwrap();
513 assert_eq!(selector.text_list_exact_value(), Some("_token"));
514
515 assert!(selector.matches(&evidence(form_controls(), list(&["_token", "email"]))));
518 assert!(!selector.matches(&evidence(form_controls(), list(&["_token_old"]))));
519 assert!(!selector.matches(&evidence(
520 form_controls(),
521 EvidenceValue::Text("_token".to_owned())
522 )));
523
524 let encoded = serde_json::to_value(&selector).unwrap();
525 assert_eq!(encoded["text_list_contains_exact"], "_token");
526 assert_eq!(
527 serde_json::from_value::<EvidenceSelector>(encoded).unwrap(),
528 selector
529 );
530
531 assert!(EvidenceSelector::text_list_contains_exact(form_controls(), " ").is_err());
533 assert!(
534 serde_json::from_value::<EvidenceSelector>(serde_json::json!({
535 "predicate": form_controls(),
536 "value": { "type": "text", "value": "_token" },
537 "text_list_contains_exact": "_token"
538 }))
539 .is_err()
540 );
541 }
542
543 #[test]
544 fn malformed_evidence_selector_cannot_broaden_exact_matching_to_exists() {
545 let selector =
546 EvidenceSelector::text_list_contains_exact(form_controls(), "_token").unwrap();
547 let mut encoded = serde_json::to_value(selector).unwrap();
548 let matcher = encoded
549 .as_object_mut()
550 .unwrap()
551 .remove("text_list_contains_exact")
552 .unwrap();
553 encoded["text_list_contians_exact"] = matcher;
554
555 assert!(serde_json::from_value::<EvidenceSelector>(encoded).is_err());
556 }
557
558 #[test]
559 fn selector_guard_preserves_history_and_rejects_tampering() {
560 let selector =
561 EvidenceSelector::text_list_contains_exact(form_controls(), "_token").unwrap();
562 let encoded = serde_json::to_value(&selector).unwrap();
563 assert_eq!(encoded["matcher_policy_guard"], true);
564
565 let mut current_history = encoded.clone();
566 current_history
567 .as_object_mut()
568 .unwrap()
569 .remove("matcher_policy_guard");
570 let restored: EvidenceSelector = serde_json::from_value(current_history).unwrap();
571 assert_eq!(restored, selector);
572 assert_eq!(
573 serde_json::to_value(&restored).unwrap()["matcher_policy_guard"],
574 true
575 );
576
577 let mut false_guard = encoded.clone();
578 false_guard["matcher_policy_guard"] = serde_json::json!(false);
579 assert!(serde_json::from_value::<EvidenceSelector>(false_guard).is_err());
580
581 let mut missing_matcher = encoded.clone();
582 missing_matcher
583 .as_object_mut()
584 .unwrap()
585 .remove("text_list_contains_exact");
586 assert!(serde_json::from_value::<EvidenceSelector>(missing_matcher).is_err());
587
588 let exists = EvidenceSelector::exists(form_controls());
589 let exists_wire = serde_json::to_value(&exists).unwrap();
590 assert!(exists_wire.get("matcher_policy_guard").is_none());
591 assert!(exists_wire
592 .get("value")
593 .is_some_and(serde_json::Value::is_null));
594 assert_eq!(
595 serde_json::from_value::<EvidenceSelector>(exists_wire.clone()).unwrap(),
596 exists
597 );
598
599 let mut guarded_exists = exists_wire.clone();
600 guarded_exists["matcher_policy_guard"] = serde_json::json!(true);
601 assert!(serde_json::from_value::<EvidenceSelector>(guarded_exists).is_err());
602
603 let mut missing_nullable = exists_wire.clone();
604 missing_nullable.as_object_mut().unwrap().remove("value");
605 assert!(serde_json::from_value::<EvidenceSelector>(missing_nullable).is_err());
606
607 let mut unknown_matcher = exists_wire;
608 unknown_matcher["matcher_future"] = serde_json::json!("_token");
609 assert!(serde_json::from_value::<EvidenceSelector>(unknown_matcher).is_err());
610 }
611
612 #[test]
613 fn malformed_calibration_selector_cannot_gain_unrelated_provenance() {
614 let mut encoded = serde_json::to_value(laravel_rule("wire.selector.strict")).unwrap();
615 let selector = &mut encoded["conclusion"]["calibrations"][0]["selector"];
616 let value = selector.as_object_mut().unwrap().remove("value").unwrap();
617 selector["vlaue"] = value;
618
619 assert!(serde_json::from_value::<ReasoningRule>(encoded).is_err());
620 }
621
622 #[test]
623 fn exact_calibration_attributes_only_matching_condition_evidence() {
624 let knowledge = KnowledgeBase::new();
625 let predicate = framework_predicate();
626 let laravel = evidence(predicate.clone(), EvidenceValue::Text("Laravel".into()));
627 let laravel_id = laravel.id().clone();
628 let apache = evidence(predicate.clone(), EvidenceValue::Text("Apache".into()));
629 let apache_id = apache.id().clone();
630 knowledge
631 .insert_evidence_batch(vec![laravel, apache])
632 .unwrap();
633
634 let rule = ReasoningRule::new(
635 "wire.selector.provenance",
636 Expression::exists(KnowledgeLayer::Evidence, predicate.clone()),
637 HypothesisConclusion::new(
638 KnowledgePredicate::new("audit", "exact-selector").unwrap(),
639 EvidenceValue::Boolean(true),
640 Probability::from_percent(50).unwrap(),
641 HypothesisStrength::Weak,
642 HypothesisState::Supported,
643 vec![EvidenceCalibration::new(
644 EvidenceSelector::equals(predicate, EvidenceValue::Text("Laravel".into())),
645 Probability::from_percent(90).unwrap(),
646 Probability::from_percent(10).unwrap(),
647 "exact framework",
648 )
649 .unwrap()],
650 )
651 .unwrap(),
652 )
653 .unwrap();
654 let mut engine = RuleEngine::new();
655 engine.register(rule).unwrap();
656
657 let evaluations = engine.evaluate(&knowledge, &subject()).unwrap();
658 let observations = evaluations[0].hypothesis().unwrap().belief().evidence();
659 assert_eq!(observations.len(), 1);
660 assert_eq!(observations[0].evidence_id(), &laravel_id);
661 assert_ne!(observations[0].evidence_id(), &apache_id);
662 }
663
664 #[test]
665 fn bounded_aggregation_wire_detects_single_field_policy_loss() {
666 let bounded = calibration(
667 framework_predicate(),
668 EvidenceValue::Text("Laravel".into()),
669 80,
670 20,
671 )
672 .with_aggregation(EvidenceAggregation::max_contributions(1).unwrap());
673 let encoded = serde_json::to_value(&bounded).unwrap();
674 assert_eq!(encoded["aggregation_policy_guard"], true);
675
676 let mut current_history = encoded.clone();
677 current_history
678 .as_object_mut()
679 .unwrap()
680 .remove("aggregation_policy_guard");
681 let restored = serde_json::from_value::<EvidenceCalibration>(current_history).unwrap();
682 assert_eq!(
683 restored.aggregation(),
684 EvidenceAggregation::max_contributions(1).unwrap()
685 );
686 assert_eq!(
687 serde_json::to_value(restored).unwrap()["aggregation_policy_guard"],
688 true
689 );
690
691 let mut false_guard = encoded.clone();
692 false_guard["aggregation_policy_guard"] = serde_json::json!(false);
693 assert!(serde_json::from_value::<EvidenceCalibration>(false_guard).is_err());
694
695 let mut corrupted = encoded;
696 corrupted.as_object_mut().unwrap().remove("aggregation");
697 assert!(serde_json::from_value::<EvidenceCalibration>(corrupted).is_err());
698
699 let independent = calibration(
700 framework_predicate(),
701 EvidenceValue::Text("Laravel".into()),
702 80,
703 20,
704 );
705 let mut guarded_independent = serde_json::to_value(independent).unwrap();
706 assert!(guarded_independent
707 .get("aggregation_policy_guard")
708 .is_none());
709 guarded_independent["aggregation_policy_guard"] = serde_json::json!(true);
710 assert!(serde_json::from_value::<EvidenceCalibration>(guarded_independent).is_err());
711 }
712
713 #[test]
714 fn text_evidence_selector_validates_and_round_trips() {
715 let selector = EvidenceSelector::text_contains_ascii_case_insensitive(
716 KnowledgePredicate::new("http.header", "x-powered-by").unwrap(),
717 "php",
718 )
719 .unwrap();
720 let encoded = serde_json::to_value(&selector).unwrap();
721
722 assert_eq!(
723 serde_json::from_value::<EvidenceSelector>(encoded).unwrap(),
724 selector
725 );
726 assert_eq!(selector.text_needle(), Some("php"));
727 assert!(
728 EvidenceSelector::text_contains_ascii_case_insensitive(framework_predicate(), " ")
729 .is_err()
730 );
731 assert!(
732 serde_json::from_value::<EvidenceSelector>(serde_json::json!({
733 "predicate": framework_predicate(),
734 "value": { "type": "text", "value": "Laravel" },
735 "text_contains_ascii_case_insensitive": "laravel"
736 }))
737 .is_err()
738 );
739 }
740
741 #[test]
742 fn fact_and_hypothesis_expressions_preserve_evidence_provenance() {
743 let knowledge = KnowledgeBase::new();
744 let observation = evidence(framework_predicate(), EvidenceValue::Text("Laravel".into()));
745 let evidence_id = observation.id().clone();
746 knowledge.insert_evidence(observation).unwrap();
747 knowledge
748 .upsert_fact(Fact::new(
749 subject(),
750 framework_predicate(),
751 EvidenceValue::Text("Laravel".into()),
752 ConfidenceScore::from_percent(90).unwrap(),
753 evidence_id.clone(),
754 ))
755 .unwrap();
756 let mut hypothesis = Hypothesis::new(
757 subject(),
758 KnowledgePredicate::new("stack", "framework").unwrap(),
759 EvidenceValue::Text("Laravel".into()),
760 Probability::from_percent(10).unwrap(),
761 );
762 hypothesis
763 .observe(
764 BayesianEvidence::new(
765 evidence_id.clone(),
766 Probability::from_percent(80).unwrap(),
767 Probability::from_percent(20).unwrap(),
768 "fact provenance",
769 )
770 .unwrap(),
771 )
772 .unwrap();
773 knowledge.upsert_hypothesis(hypothesis).unwrap();
774 let snapshot = knowledge.snapshot_for_subject(&subject());
775
776 let fact_match = Expression::equals(
777 KnowledgeLayer::Fact,
778 framework_predicate(),
779 EvidenceValue::Text("Laravel".into()),
780 )
781 .evaluate(&snapshot)
782 .unwrap();
783 let hypothesis_match = Expression::equals(
784 KnowledgeLayer::Hypothesis,
785 KnowledgePredicate::new("stack", "framework").unwrap(),
786 EvidenceValue::Text("Laravel".into()),
787 )
788 .evaluate(&snapshot)
789 .unwrap();
790
791 assert_eq!(
792 fact_match.evidence_ids(),
793 &BTreeSet::from([evidence_id.clone()])
794 );
795 assert_eq!(
796 hypothesis_match.evidence_ids(),
797 &BTreeSet::from([evidence_id])
798 );
799 }
800
801 #[test]
802 fn ontology_expression_uses_snapshot_semantics() {
803 let knowledge = KnowledgeBase::new();
804 let framework = ConceptId::new("framework").unwrap();
805 let laravel = ConceptId::new("laravel").unwrap();
806 knowledge
807 .register_concept(OntologyConcept::new(framework.clone(), "Framework").unwrap())
808 .unwrap();
809 knowledge
810 .register_concept(OntologyConcept::new(laravel.clone(), "Laravel").unwrap())
811 .unwrap();
812 knowledge
813 .register_axiom(OntologyAxiom::new(
814 laravel.clone(),
815 RelationTypeId::new(Ontology::IS_A).unwrap(),
816 framework.clone(),
817 ))
818 .unwrap();
819 let expression = Expression::ontology_relation(
820 laravel,
821 RelationTypeId::new(Ontology::IS_A).unwrap(),
822 framework,
823 );
824
825 assert!(expression
826 .evaluate(&knowledge.snapshot_for_subject(&subject()))
827 .unwrap()
828 .matched());
829 }
830
831 #[test]
832 fn hypothesis_id_helper_preserves_legacy_format() {
833 let subject = subject();
834
835 assert_eq!(
836 hypothesis_id_for_rule("framework.laravel", &subject),
837 "rule:17:framework.laravel:endpoint:https://example.test"
838 );
839 assert_eq!(
840 hypothesis_id_for_rule("rüle", &subject),
841 "rule:5:rüle:endpoint:https://example.test"
842 );
843 }
844
845 #[test]
846 fn rule_engine_materializes_stable_bayesian_hypothesis() {
847 let knowledge = KnowledgeBase::new();
848 knowledge
849 .insert_evidence(evidence(
850 framework_predicate(),
851 EvidenceValue::Text("Laravel".into()),
852 ))
853 .unwrap();
854 knowledge
855 .insert_evidence(evidence(
856 auth_predicate(),
857 EvidenceValue::Text("Sanctum".into()),
858 ))
859 .unwrap();
860 let mut engine = RuleEngine::new();
861 assert_eq!(
862 engine.register(laravel_rule("framework.laravel")).unwrap(),
863 RuleWrite::Inserted
864 );
865
866 let first = engine.apply(&knowledge, &subject()).unwrap();
867 let hypothesis = first[0].evaluation().hypothesis().unwrap();
868 assert_eq!(first[0].write(), Some(KnowledgeWrite::Inserted));
869 assert_eq!(hypothesis.belief().evidence().len(), 2);
870 assert_eq!(hypothesis.strength(), HypothesisStrength::Strong);
871 assert_eq!(hypothesis.state(), HypothesisState::Supported);
872 assert!(hypothesis.posterior() > Probability::from_percent(50).unwrap());
873 assert!(serde_json::to_value(&first[0]).is_ok());
874 let stable_id = hypothesis.id().to_owned();
875 assert_eq!(
876 stable_id,
877 hypothesis_id_for_rule("framework.laravel", &subject())
878 );
879
880 let second = engine.apply(&knowledge, &subject()).unwrap();
881 assert_eq!(second[0].write(), Some(KnowledgeWrite::Unchanged));
882 assert_eq!(second[0].evaluation().hypothesis().unwrap().id(), stable_id);
883 assert_eq!(knowledge.stats().hypotheses, 1);
884 }
885
886 #[test]
887 fn rule_engine_retries_a_controllably_stale_snapshot() {
888 let knowledge = KnowledgeBase::new();
889 knowledge
890 .insert_evidence(evidence(
891 framework_predicate(),
892 EvidenceValue::Text("Laravel".into()),
893 ))
894 .unwrap();
895 knowledge
896 .insert_evidence(evidence(
897 auth_predicate(),
898 EvidenceValue::Text("Sanctum".into()),
899 ))
900 .unwrap();
901 let mut engine = RuleEngine::new();
902 engine.register(laravel_rule("framework.laravel")).unwrap();
903
904 let applications = engine
905 .apply_with_before_commit(&knowledge, &subject(), |attempt, _| {
906 if attempt == 1 {
907 knowledge
908 .insert_evidence(evidence(
909 framework_predicate(),
910 EvidenceValue::Text("Laravel".into()),
911 ))
912 .unwrap();
913 }
914 })
915 .unwrap();
916
917 let committed_id = applications[0].evaluation().hypothesis().unwrap().id();
918 let stored = knowledge.hypothesis(committed_id).unwrap();
919 assert_eq!(stored.belief().evidence().len(), 3);
920 assert_eq!(applications[0].write(), Some(KnowledgeWrite::Inserted));
921 }
922
923 #[test]
924 fn empty_apply_validates_revisions_and_reports_retry_exhaustion() {
925 let knowledge = KnowledgeBase::new();
926 let engine = RuleEngine::new();
927
928 let error = engine
929 .apply_with_before_commit(&knowledge, &subject(), |attempt, _| {
930 knowledge
931 .insert_evidence(evidence(
932 framework_predicate(),
933 EvidenceValue::Text(format!("stale-attempt-{attempt}")),
934 ))
935 .unwrap();
936 })
937 .unwrap_err();
938
939 assert!(matches!(
940 error,
941 RuleEngineError::StaleSnapshotRetriesExhausted {
942 attempts: MAX_REASONING_APPLY_ATTEMPTS
943 }
944 ));
945 assert_eq!(knowledge.stats().hypotheses, 0);
946 }
947
948 #[test]
949 fn delayed_reasoning_batch_cannot_overwrite_a_newer_belief() {
950 let knowledge = KnowledgeBase::new();
951 knowledge
952 .insert_evidence(evidence(
953 framework_predicate(),
954 EvidenceValue::Text("Laravel".into()),
955 ))
956 .unwrap();
957 knowledge
958 .insert_evidence(evidence(
959 auth_predicate(),
960 EvidenceValue::Text("Sanctum".into()),
961 ))
962 .unwrap();
963 let mut engine = RuleEngine::new();
964 engine.register(laravel_rule("framework.laravel")).unwrap();
965 let stale_snapshot = knowledge.snapshot_for_subject(&subject());
966 let stale_hypotheses = engine
967 .evaluate_snapshot(&stale_snapshot)
968 .unwrap()
969 .into_iter()
970 .filter_map(|evaluation| evaluation.hypothesis().cloned())
971 .collect();
972
973 knowledge
974 .insert_evidence(evidence(
975 framework_predicate(),
976 EvidenceValue::Text("Laravel".into()),
977 ))
978 .unwrap();
979 let current = engine.apply(&knowledge, &subject()).unwrap();
980 let hypothesis_id = current[0].evaluation().hypothesis().unwrap().id();
981 assert_eq!(
982 knowledge
983 .hypothesis(hypothesis_id)
984 .unwrap()
985 .belief()
986 .evidence()
987 .len(),
988 3
989 );
990
991 assert!(matches!(
992 knowledge.upsert_reasoning_hypothesis_batch(&stale_snapshot, stale_hypotheses),
993 Err(KnowledgeBaseError::StaleSnapshot { .. })
994 ));
995 assert_eq!(
996 knowledge
997 .hypothesis(hypothesis_id)
998 .unwrap()
999 .belief()
1000 .evidence()
1001 .len(),
1002 3
1003 );
1004 }
1005
1006 #[test]
1007 fn rule_engine_rolls_back_every_hypothesis_on_a_late_identity_conflict() {
1008 let knowledge = KnowledgeBase::new();
1009 knowledge
1010 .insert_evidence(evidence(
1011 framework_predicate(),
1012 EvidenceValue::Text("Laravel".into()),
1013 ))
1014 .unwrap();
1015 knowledge
1016 .insert_evidence(evidence(
1017 auth_predicate(),
1018 EvidenceValue::Text("Sanctum".into()),
1019 ))
1020 .unwrap();
1021 let mut engine = RuleEngine::new();
1022 engine.register(laravel_rule("rule.a")).unwrap();
1023 engine.register(laravel_rule("rule.b")).unwrap();
1024
1025 let evaluations = engine.evaluate(&knowledge, &subject()).unwrap();
1026 let first_id = evaluations[0].hypothesis().unwrap().id().to_owned();
1027 let second_id = evaluations[1].hypothesis().unwrap().id().to_owned();
1028 let conflicting = Hypothesis::with_id(
1029 second_id.clone(),
1030 subject(),
1031 auth_predicate(),
1032 EvidenceValue::Text("conflicting-claim".into()),
1033 Probability::from_percent(50).unwrap(),
1034 )
1035 .unwrap();
1036 knowledge.upsert_hypothesis(conflicting).unwrap();
1037
1038 assert!(matches!(
1039 engine.apply(&knowledge, &subject()),
1040 Err(RuleEngineError::Knowledge(
1041 KnowledgeBaseError::IdentityConflict {
1042 kind: crate::KnowledgeRecordKind::Hypothesis,
1043 id,
1044 }
1045 )) if id == second_id
1046 ));
1047 assert!(knowledge.hypothesis(&first_id).is_none());
1048 assert_eq!(knowledge.stats().hypotheses, 1);
1049 }
1050
1051 #[test]
1052 fn rule_engine_recalibration_preserves_verifier_terminal_states() {
1053 for terminal_state in [HypothesisState::Confirmed, HypothesisState::Rejected] {
1054 let knowledge = KnowledgeBase::new();
1055 knowledge
1056 .insert_evidence(evidence(
1057 framework_predicate(),
1058 EvidenceValue::Text("Laravel".into()),
1059 ))
1060 .unwrap();
1061 knowledge
1062 .insert_evidence(evidence(
1063 auth_predicate(),
1064 EvidenceValue::Text("Sanctum".into()),
1065 ))
1066 .unwrap();
1067 let mut engine = RuleEngine::new();
1068 engine.register(laravel_rule("framework.laravel")).unwrap();
1069 let initial = engine.apply(&knowledge, &subject()).unwrap();
1070 let hypothesis_id = initial[0].evaluation().hypothesis().unwrap().id();
1071 let mut verified = knowledge.hypothesis(hypothesis_id).unwrap();
1072 verified.set_state(terminal_state);
1073 knowledge.upsert_hypothesis(verified).unwrap();
1074
1075 knowledge
1076 .insert_evidence(evidence(
1077 framework_predicate(),
1078 EvidenceValue::Text("Laravel".into()),
1079 ))
1080 .unwrap();
1081 let recalibrated = engine.apply(&knowledge, &subject()).unwrap();
1082
1083 assert_eq!(recalibrated[0].write(), Some(KnowledgeWrite::Updated));
1084 let stored = knowledge.hypothesis(hypothesis_id).unwrap();
1085 assert_eq!(stored.state(), terminal_state);
1086 assert_eq!(stored.belief().evidence().len(), 3);
1087 }
1088 }
1089
1090 #[test]
1091 fn rules_evaluate_in_id_order_not_registration_order() {
1092 let knowledge = KnowledgeBase::new();
1093 knowledge
1094 .insert_evidence(evidence(
1095 framework_predicate(),
1096 EvidenceValue::Text("Laravel".into()),
1097 ))
1098 .unwrap();
1099 knowledge
1100 .insert_evidence(evidence(
1101 auth_predicate(),
1102 EvidenceValue::Text("Sanctum".into()),
1103 ))
1104 .unwrap();
1105 let mut engine = RuleEngine::new();
1106 engine.register(laravel_rule("rule.z")).unwrap();
1107 engine.register(laravel_rule("rule.a")).unwrap();
1108
1109 let evaluations = engine.evaluate(&knowledge, &subject()).unwrap();
1110 assert_eq!(evaluations[0].rule_id(), "rule.a");
1111 assert_eq!(evaluations[1].rule_id(), "rule.z");
1112 }
1113
1114 #[test]
1115 fn rule_applications_keep_evaluation_order_and_unmatched_write_slots() {
1116 let knowledge = KnowledgeBase::new();
1117 knowledge
1118 .insert_evidence(evidence(
1119 framework_predicate(),
1120 EvidenceValue::Text("Laravel".into()),
1121 ))
1122 .unwrap();
1123 knowledge
1124 .insert_evidence(evidence(
1125 auth_predicate(),
1126 EvidenceValue::Text("Sanctum".into()),
1127 ))
1128 .unwrap();
1129 let template = laravel_rule("template");
1130 let unmatched = ReasoningRule::new(
1131 "rule.b",
1132 Expression::exists(
1133 KnowledgeLayer::Evidence,
1134 KnowledgePredicate::new("security", "waf").unwrap(),
1135 ),
1136 template.conclusion.clone(),
1137 )
1138 .unwrap();
1139 let mut engine = RuleEngine::new();
1140 engine.register(laravel_rule("rule.c")).unwrap();
1141 engine.register(unmatched).unwrap();
1142 engine.register(laravel_rule("rule.a")).unwrap();
1143
1144 let applications = engine.apply(&knowledge, &subject()).unwrap();
1145
1146 assert_eq!(
1147 applications
1148 .iter()
1149 .map(|application| application.evaluation().rule_id())
1150 .collect::<Vec<_>>(),
1151 vec!["rule.a", "rule.b", "rule.c"]
1152 );
1153 assert_eq!(
1154 applications
1155 .iter()
1156 .map(RuleApplication::write)
1157 .collect::<Vec<_>>(),
1158 vec![
1159 Some(KnowledgeWrite::Inserted),
1160 None,
1161 Some(KnowledgeWrite::Inserted),
1162 ]
1163 );
1164 assert_eq!(knowledge.stats().hypotheses, 2);
1165 }
1166
1167 #[test]
1168 fn calibration_contribution_caps_are_explicit_and_round_trip() {
1169 let knowledge = KnowledgeBase::new();
1170 let predicate = framework_predicate();
1171 let value = EvidenceValue::Text("Laravel".into());
1172 let weak_id = EvidenceId::parse("signal:weak").unwrap();
1173 let strong_id = EvidenceId::parse("signal:strong").unwrap();
1174 knowledge
1175 .insert_evidence_batch(vec![
1176 Evidence::with_id_at(
1177 weak_id.clone(),
1178 subject(),
1179 EvidenceKind::Technology,
1180 predicate.clone(),
1181 value.clone(),
1182 EvidenceSource::new("discovery", "test").unwrap(),
1183 ConfidenceScore::from_percent(50).unwrap(),
1184 2_000,
1185 ),
1186 Evidence::with_id_at(
1187 strong_id.clone(),
1188 subject(),
1189 EvidenceKind::Technology,
1190 predicate.clone(),
1191 value.clone(),
1192 EvidenceSource::new("discovery", "test").unwrap(),
1193 ConfidenceScore::from_percent(90).unwrap(),
1194 1_000,
1195 ),
1196 ])
1197 .unwrap();
1198
1199 let independent = EvidenceCalibration::new(
1200 EvidenceSelector::equals(predicate.clone(), value.clone()),
1201 Probability::from_percent(90).unwrap(),
1202 Probability::from_percent(10).unwrap(),
1203 "one semantic fingerprint contribution",
1204 )
1205 .unwrap();
1206 let legacy_wire = serde_json::to_value(&independent).unwrap();
1207 assert!(legacy_wire.get("aggregation").is_none());
1208 assert_eq!(
1209 serde_json::from_value::<EvidenceCalibration>(legacy_wire)
1210 .unwrap()
1211 .aggregation(),
1212 EvidenceAggregation::Independent
1213 );
1214 let mut misspelled_wire = serde_json::to_value(&independent).unwrap();
1215 misspelled_wire["aggregaton"] = serde_json::json!({
1216 "mode": "max_contributions",
1217 "limit": 1
1218 });
1219 assert!(serde_json::from_value::<EvidenceCalibration>(misspelled_wire).is_err());
1220 let bounded =
1221 independent.with_aggregation(EvidenceAggregation::max_contributions(1).unwrap());
1222 let mut malformed_aggregation = serde_json::to_value(&bounded).unwrap();
1223 malformed_aggregation["aggregation"]["unexpected"] = serde_json::json!(true);
1224 assert!(serde_json::from_value::<EvidenceCalibration>(malformed_aggregation).is_err());
1225 let bounded_rule = ReasoningRule::new(
1226 "framework.bounded-signal",
1227 Expression::equals(KnowledgeLayer::Evidence, predicate.clone(), value.clone()),
1228 HypothesisConclusion::new(
1229 KnowledgePredicate::new("stack", "bounded-framework").unwrap(),
1230 value.clone(),
1231 Probability::from_percent(10).unwrap(),
1232 HypothesisStrength::Weak,
1233 HypothesisState::Supported,
1234 vec![bounded],
1235 )
1236 .unwrap(),
1237 )
1238 .unwrap();
1239 let encoded = serde_json::to_value(&bounded_rule).unwrap();
1240 assert_eq!(
1241 serde_json::from_value::<ReasoningRule>(encoded).unwrap(),
1242 bounded_rule
1243 );
1244
1245 let mut engine = RuleEngine::new();
1246 engine.register(bounded_rule).unwrap();
1247 let bounded_result = engine.evaluate(&knowledge, &subject()).unwrap();
1248 assert_eq!(bounded_result[0].condition().evidence_ids().len(), 2);
1249 assert_eq!(
1250 bounded_result[0]
1251 .hypothesis()
1252 .unwrap()
1253 .belief()
1254 .evidence()
1255 .len(),
1256 1
1257 );
1258 assert_eq!(
1259 bounded_result[0].hypothesis().unwrap().belief().evidence()[0].evidence_id(),
1260 &strong_id
1261 );
1262 assert!(matches!(
1263 EvidenceAggregation::max_contributions(0),
1264 Err(RuleEngineError::InvalidAggregationLimit)
1265 ));
1266 }
1267
1268 #[test]
1269 fn rule_registration_and_wire_invariants_are_enforced() {
1270 let rule = laravel_rule("framework.laravel");
1271 let encoded = serde_json::to_value(&rule).unwrap();
1272 assert_eq!(
1273 serde_json::from_value::<ReasoningRule>(encoded).unwrap(),
1274 rule
1275 );
1276 assert!(ReasoningRule::new(" ", rule.condition.clone(), rule.conclusion.clone()).is_err());
1277 assert!(HypothesisConclusion::new(
1278 framework_predicate(),
1279 EvidenceValue::Text("Laravel".into()),
1280 Probability::from_percent(10).unwrap(),
1281 HypothesisStrength::Strong,
1282 HypothesisState::Confirmed,
1283 rule.conclusion.calibrations.clone(),
1284 )
1285 .is_err());
1286
1287 let mut engine = RuleEngine::new();
1288 assert_eq!(engine.register(rule.clone()).unwrap(), RuleWrite::Inserted);
1289 assert_eq!(engine.register(rule.clone()).unwrap(), RuleWrite::Unchanged);
1290 let conflicting = ReasoningRule::new(
1291 rule.id(),
1292 Expression::exists(KnowledgeLayer::Evidence, framework_predicate()),
1293 rule.conclusion.clone(),
1294 )
1295 .unwrap();
1296 assert!(matches!(
1297 engine.register(conflicting),
1298 Err(RuleEngineError::RuleIdentityConflict { .. })
1299 ));
1300 }
1301
1302 #[test]
1303 fn reasoning_rule_and_conclusion_reject_unknown_semantic_fields() {
1304 let rule = laravel_rule("wire.strict-container");
1305
1306 let mut unknown_rule_field = serde_json::to_value(&rule).unwrap();
1307 unknown_rule_field["scope_future"] = serde_json::json!("global");
1308 assert!(serde_json::from_value::<ReasoningRule>(unknown_rule_field).is_err());
1309
1310 let mut unknown_conclusion_field = serde_json::to_value(&rule).unwrap();
1311 unknown_conclusion_field["conclusion"]["transition_future"] =
1312 serde_json::json!("confirmed");
1313 assert!(serde_json::from_value::<ReasoningRule>(unknown_conclusion_field).is_err());
1314
1315 assert_eq!(
1316 serde_json::from_value::<ReasoningRule>(serde_json::to_value(&rule).unwrap()).unwrap(),
1317 rule
1318 );
1319 }
1320
1321 #[test]
1322 fn ambiguous_calibration_fails_before_writing() {
1323 let knowledge = KnowledgeBase::new();
1324 knowledge
1325 .insert_evidence(evidence(
1326 framework_predicate(),
1327 EvidenceValue::Text("Laravel".into()),
1328 ))
1329 .unwrap();
1330 let exact = calibration(
1331 framework_predicate(),
1332 EvidenceValue::Text("Laravel".into()),
1333 80,
1334 20,
1335 );
1336 let overlapping = EvidenceCalibration::new(
1337 EvidenceSelector::exists(framework_predicate()),
1338 Probability::from_percent(90).unwrap(),
1339 Probability::from_percent(10).unwrap(),
1340 "different calibration",
1341 )
1342 .unwrap();
1343 let rule = ReasoningRule::new(
1344 "ambiguous",
1345 Expression::equals(
1346 KnowledgeLayer::Evidence,
1347 framework_predicate(),
1348 EvidenceValue::Text("Laravel".into()),
1349 ),
1350 HypothesisConclusion::new(
1351 framework_predicate(),
1352 EvidenceValue::Text("Laravel".into()),
1353 Probability::from_percent(10).unwrap(),
1354 HypothesisStrength::Weak,
1355 HypothesisState::Supported,
1356 vec![exact, overlapping],
1357 )
1358 .unwrap(),
1359 )
1360 .unwrap();
1361 let mut engine = RuleEngine::new();
1362 engine.register(rule).unwrap();
1363
1364 assert!(matches!(
1365 engine.apply(&knowledge, &subject()),
1366 Err(RuleEngineError::AmbiguousEvidenceCalibration { .. })
1367 ));
1368 assert_eq!(knowledge.stats().hypotheses, 0);
1369 }
1370}