Skip to main content

termivar_scanner/
rules.rs

1//! Deterministic expression evaluation and Bayesian reasoning rules.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** always/default.
6//! - **Execution:** Surface B (deterministic decision runtime).
7//! - **Default `termivar scan`:** yes, through `StandardWebDecisionRuntime`.
8//! - **Support:** implemented and tested.
9//!
10//! See `docs/internals/runtime-map.md`.
11//!
12//! Rules consume an immutable [`crate::KnowledgeSnapshot`]. They never execute
13//! plugins, schedule scans, or mutate evidence. A matched rule may materialize
14//! one stable, evidence-backed [`termivar_core::Hypothesis`].
15
16use 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
46/// Returns the stable identity materialized by a rule for one knowledge subject.
47///
48/// Keeping this legacy format in one place ensures projections can locate the
49/// canonical hypothesis without depending on a private `RuleEngine` detail.
50pub(crate) fn hypothesis_id_for_rule(rule_id: &str, subject: &EntityId) -> String {
51    format!("rule:{}:{rule_id}:{subject}", rule_id.len())
52}
53
54/// Errors raised while validating or evaluating deterministic rules.
55#[derive(Debug, Error)]
56#[non_exhaustive]
57pub enum RuleEngineError {
58    /// A required rule identifier or explanation was empty.
59    #[error("{field} must not be empty")]
60    EmptyValue { field: &'static str },
61
62    /// A logical group did not contain any child expression.
63    #[error("{operator} expression must contain at least one child")]
64    EmptyExpression { operator: &'static str },
65
66    /// A hypothesis conclusion did not define any evidence calibration.
67    #[error("hypothesis conclusion must contain at least one evidence calibration")]
68    EmptyCalibrations,
69
70    /// A bounded evidence aggregation requested zero contributions.
71    #[error("evidence aggregation limit must be greater than zero")]
72    InvalidAggregationLimit,
73
74    /// A rule attempted to assign a state reserved for a verifier.
75    #[error("hypothesis state {state:?} can only be assigned by a verifier")]
76    VerifierOnlyState { state: HypothesisState },
77
78    /// A rule identity was reused with different semantics.
79    #[error("rule identity {id} already has a different definition")]
80    RuleIdentityConflict { id: String },
81
82    /// A matched rule could not bind any contributing evidence.
83    #[error("matched rule {rule_id} has no calibrated contributing evidence")]
84    MissingCalibratedEvidence { rule_id: String },
85
86    /// Two calibrations assigned different likelihoods to one observation.
87    #[error("rule {rule_id} assigns ambiguous calibration to evidence {evidence_id}")]
88    AmbiguousEvidenceCalibration {
89        /// Rule containing the conflicting calibration.
90        rule_id: String,
91        /// Evidence that matched more than one incompatible selector.
92        evidence_id: EvidenceId,
93    },
94
95    /// Ontology evaluation failed.
96    #[error(transparent)]
97    Ontology(#[from] OntologyError),
98
99    /// A reasoning-domain invariant failed.
100    #[error(transparent)]
101    Reasoning(#[from] ReasoningModelError),
102
103    /// A materialized hypothesis conflicted with stored knowledge.
104    #[error(transparent)]
105    Knowledge(#[from] KnowledgeBaseError),
106
107    /// Concurrent rule-visible writes prevented a stable reasoning commit.
108    #[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        // Exact element membership: the value equals a complete list element,
430        // never a substring and never a scalar text fallback.
431        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            // A scalar Text value never satisfies a list-membership predicate.
442            (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        // Empty / whitespace-only values are rejected at both construction and
493        // deserialization; values are never silently trimmed.
494        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        // Exact element membership only: a list with the exact element matches; a
516        // substring-only element and a scalar Text value do not.
517        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        // Empty value rejected, and matchers are mutually exclusive on the wire.
532        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}