Skip to main content

ipfrs_tensorlogic/
belief_revision_engine.rs

1//! AGM-style Belief Revision Engine
2//!
3//! Implements the Alchourrón–Gärdenfors–Makinson (AGM) framework for belief revision,
4//! including the three core operations:
5//!
6//! - **Expansion** K+φ: add φ and all entailments — always succeeds
7//! - **Contraction** K÷φ: remove φ (partial-meet contraction, keep maximum)
8//! - **Revision** K*φ: Levi identity — contract by ¬φ, then expand by φ
9//!
10//! Consistency checking detects contradictions of the form "X" / "NOT:X".
11//! Retention on conflict is governed by a pluggable `RetentionFunction`.
12
13use std::collections::HashMap;
14
15// ────────────────────────────────────────────────────────────────────────────
16// PRNG (xorshift64 — no `rand` dependency)
17// ────────────────────────────────────────────────────────────────────────────
18
19/// xorshift64 PRNG — used internally for tests and ID generation.
20#[inline]
21pub fn xorshift64(state: &mut u64) -> u64 {
22    let mut x = *state;
23    x ^= x << 13;
24    x ^= x >> 7;
25    x ^= x << 17;
26    *state = x;
27    x
28}
29
30// ────────────────────────────────────────────────────────────────────────────
31// Core domain types
32// ────────────────────────────────────────────────────────────────────────────
33
34/// A single unit of propositional knowledge held by the agent.
35#[derive(Debug, Clone, PartialEq)]
36pub struct Belief {
37    /// Unique identifier for this belief.
38    pub id: String,
39    /// Propositional formula as a string (e.g. `"rain"`, `"NOT:rain"`, `"rain AND wet"`).
40    pub formula: String,
41    /// Epistemic confidence in [0.0, 1.0].
42    pub confidence: f64,
43    /// Source / provenance label.
44    pub source: String,
45    /// Creation timestamp (Unix epoch, seconds).
46    pub timestamp: u64,
47    /// `true` if derived by entailment rather than directly asserted.
48    pub is_derived: bool,
49}
50
51impl Belief {
52    /// Construct a new directly-asserted belief.
53    pub fn new(
54        id: impl Into<String>,
55        formula: impl Into<String>,
56        confidence: f64,
57        source: impl Into<String>,
58        timestamp: u64,
59    ) -> Self {
60        Self {
61            id: id.into(),
62            formula: formula.into(),
63            confidence: confidence.clamp(0.0, 1.0),
64            source: source.into(),
65            timestamp,
66            is_derived: false,
67        }
68    }
69
70    /// Mark this belief as derived.
71    pub fn derived(mut self) -> Self {
72        self.is_derived = true;
73        self
74    }
75}
76
77/// A set of beliefs together with a cached entailment map.
78#[derive(Debug, Clone)]
79pub struct BeliefSet {
80    /// The beliefs.
81    pub beliefs: Vec<Belief>,
82    /// Cached entailment results: formula → entailed (true/false).
83    pub entailment_cache: HashMap<String, bool>,
84}
85
86impl BeliefSet {
87    /// Create an empty belief set.
88    pub fn new() -> Self {
89        Self {
90            beliefs: Vec::new(),
91            entailment_cache: HashMap::new(),
92        }
93    }
94
95    /// Number of beliefs in the set.
96    pub fn len(&self) -> usize {
97        self.beliefs.len()
98    }
99
100    /// `true` when the set contains no beliefs.
101    pub fn is_empty(&self) -> bool {
102        self.beliefs.is_empty()
103    }
104}
105
106impl Default for BeliefSet {
107    fn default() -> Self {
108        Self::new()
109    }
110}
111
112// ────────────────────────────────────────────────────────────────────────────
113// Revision operations
114// ────────────────────────────────────────────────────────────────────────────
115
116/// The four operations the engine can apply to a belief set.
117#[derive(Debug, Clone)]
118pub enum RevisionOp {
119    /// K+φ — add a belief.
120    Expansion(Belief),
121    /// K÷φ — remove all beliefs entailing a formula.
122    Contraction(String),
123    /// K*φ — Levi: contract ¬φ, expand φ.
124    Revision(Belief),
125    /// Remove all contradictory pairs according to the retention function.
126    Consolidation,
127}
128
129// ────────────────────────────────────────────────────────────────────────────
130// Consistency
131// ────────────────────────────────────────────────────────────────────────────
132
133/// Result of a consistency scan over the current belief set.
134#[derive(Debug, Clone, PartialEq)]
135pub enum ConsistencyCheck {
136    /// No contradictions detected.
137    Consistent,
138    /// One or more contradictory pairs; IDs of conflicting beliefs listed.
139    Inconsistent(Vec<String>),
140    /// Consistency could not be determined (e.g. complex/opaque formulae).
141    Unknown,
142}
143
144// ────────────────────────────────────────────────────────────────────────────
145// Retention functions
146// ────────────────────────────────────────────────────────────────────────────
147
148/// Determines which of two contradictory beliefs to keep.
149#[derive(Debug, Clone)]
150pub enum RetentionFunction {
151    /// Keep the belief with higher confidence (epistemic entrenchment).
152    EpistemicEntrenchment,
153    /// Keep the more recently created belief.
154    RecencyBias,
155    /// Keep the belief whose source has a higher priority score.
156    SourcePriority(HashMap<String, u32>),
157    /// Keep the belief that minimises change to the set (prefer existing).
158    MinimalChange,
159}
160
161impl RetentionFunction {
162    /// Return `true` if `a` should be retained over `b` when they conflict.
163    pub fn prefers(&self, a: &Belief, b: &Belief) -> bool {
164        match self {
165            RetentionFunction::EpistemicEntrenchment => a.confidence >= b.confidence,
166            RetentionFunction::RecencyBias => a.timestamp >= b.timestamp,
167            RetentionFunction::SourcePriority(priorities) => {
168                let pa = priorities.get(&a.source).copied().unwrap_or(0);
169                let pb = priorities.get(&b.source).copied().unwrap_or(0);
170                pa >= pb
171            }
172            RetentionFunction::MinimalChange => !a.is_derived,
173        }
174    }
175}
176
177// ────────────────────────────────────────────────────────────────────────────
178// Configuration
179// ────────────────────────────────────────────────────────────────────────────
180
181/// Configuration for a [`BeliefRevisionEngine`].
182#[derive(Debug, Clone)]
183pub struct RevisionConfig {
184    /// Maximum number of beliefs the engine may hold (0 = unlimited).
185    pub max_beliefs: usize,
186    /// Whether to run consistency checks during expansion.
187    pub consistency_check_enabled: bool,
188    /// Which retention function to use when resolving contradictions.
189    pub retention_function: RetentionFunction,
190    /// If `true`, automatically consolidate after every inconsistency is found.
191    pub auto_consolidate: bool,
192}
193
194impl Default for RevisionConfig {
195    fn default() -> Self {
196        Self {
197            max_beliefs: 0,
198            consistency_check_enabled: true,
199            retention_function: RetentionFunction::EpistemicEntrenchment,
200            auto_consolidate: true,
201        }
202    }
203}
204
205// ────────────────────────────────────────────────────────────────────────────
206// Statistics
207// ────────────────────────────────────────────────────────────────────────────
208
209/// Cumulative operational statistics for a [`BeliefRevisionEngine`].
210#[derive(Debug, Clone, Default)]
211pub struct RevisionStats {
212    /// Total expansions performed.
213    pub expansions: u64,
214    /// Total contractions performed.
215    pub contractions: u64,
216    /// Total revisions performed.
217    pub revisions: u64,
218    /// Total consolidations performed.
219    pub consolidations: u64,
220    /// Total beliefs retracted over the engine's lifetime.
221    pub beliefs_retracted: u64,
222    /// Current number of beliefs in the set.
223    pub current_belief_count: usize,
224}
225
226// ────────────────────────────────────────────────────────────────────────────
227// Error type
228// ────────────────────────────────────────────────────────────────────────────
229
230/// Errors returned by [`BeliefRevisionEngine`] operations.
231#[derive(Debug, Clone, PartialEq)]
232pub enum RevisionError {
233    /// A referenced belief ID does not exist in the set.
234    BeliefNotFound(String),
235    /// Adding the belief would exceed `max_beliefs`.
236    MaxBeliefsExceeded,
237    /// The new belief directly contradicts an existing one.
238    ContradictionDetected {
239        /// Formula of the new / incoming belief.
240        new: String,
241        /// Formula of the existing / conflicting belief.
242        existing: String,
243    },
244    /// Revision failed for an unspecified reason.
245    RevisionFailed(String),
246}
247
248impl std::fmt::Display for RevisionError {
249    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250        match self {
251            RevisionError::BeliefNotFound(id) => write!(f, "belief not found: {id}"),
252            RevisionError::MaxBeliefsExceeded => write!(f, "max belief limit exceeded"),
253            RevisionError::ContradictionDetected { new, existing } => {
254                write!(f, "contradiction: '{new}' vs '{existing}'")
255            }
256            RevisionError::RevisionFailed(msg) => write!(f, "revision failed: {msg}"),
257        }
258    }
259}
260
261impl std::error::Error for RevisionError {}
262
263// ────────────────────────────────────────────────────────────────────────────
264// Simple propositional helpers
265// ────────────────────────────────────────────────────────────────────────────
266
267/// Negate a formula.
268///
269/// * `"NOT:X"` → `"X"`
270/// * `"X"` → `"NOT:X"`
271fn negate(formula: &str) -> String {
272    if let Some(base) = formula.strip_prefix("NOT:") {
273        base.to_owned()
274    } else {
275        format!("NOT:{formula}")
276    }
277}
278
279/// Return `true` if the two formulae are direct contradictions.
280///
281/// Formulae `a` and `b` contradict iff `negate(a) == b`.
282fn are_contradictory(a: &str, b: &str) -> bool {
283    negate(a) == b
284}
285
286/// Parse a conjunction `"A AND B"` into its two parts, if applicable.
287fn parse_conjunction(formula: &str) -> Option<(&str, &str)> {
288    let parts: Vec<&str> = formula.splitn(2, " AND ").collect();
289    if parts.len() == 2 {
290        Some((parts[0].trim(), parts[1].trim()))
291    } else {
292        None
293    }
294}
295
296/// Check whether the given set of formulae directly entails `target`.
297///
298/// Rules (sound but deliberately incomplete for propositional strings):
299/// 1. `target` is identical to one of `formulae`.
300/// 2. `target` is a conjunction `"A AND B"` and both `A` and `B` are in `formulae`.
301fn set_entails_formula(formulae: &[&str], target: &str) -> bool {
302    // Rule 1 — direct membership
303    if formulae.contains(&target) {
304        return true;
305    }
306    // Rule 2 — conjunction introduction
307    if let Some((a, b)) = parse_conjunction(target) {
308        return formulae.contains(&a) && formulae.contains(&b);
309    }
310    false
311}
312
313// ────────────────────────────────────────────────────────────────────────────
314// Core engine
315// ────────────────────────────────────────────────────────────────────────────
316
317/// AGM-style belief revision engine.
318///
319/// # Example
320///
321/// ```
322/// use ipfrs_tensorlogic::{
323///     BeliefRevisionEngine, Belief, RevisionConfig, RevisionError,
324/// };
325///
326/// let cfg = RevisionConfig::default();
327/// let mut engine = BeliefRevisionEngine::new(cfg);
328///
329/// let b = Belief::new("b1", "rain", 0.9, "sensor", 100);
330/// let added = engine.expand(b).expect("example: should succeed in docs");
331/// assert!(!added.is_empty());
332///
333/// let removed = engine.contract("rain").expect("example: should succeed in docs");
334/// assert!(!removed.is_empty());
335/// ```
336pub struct BeliefRevisionEngine {
337    /// Active belief set.
338    belief_set: BeliefSet,
339    /// Engine configuration.
340    config: RevisionConfig,
341    /// Cumulative statistics.
342    stats: RevisionStats,
343    /// Monotonic ID counter seed (xorshift64).
344    id_seed: u64,
345}
346
347impl BeliefRevisionEngine {
348    // ──────────────────────────────────────────────────────────────────────
349    // Construction
350    // ──────────────────────────────────────────────────────────────────────
351
352    /// Create a new engine with the given configuration.
353    pub fn new(config: RevisionConfig) -> Self {
354        Self {
355            belief_set: BeliefSet::new(),
356            config,
357            stats: RevisionStats::default(),
358            id_seed: 0xdeadbeef_cafebabe,
359        }
360    }
361
362    /// Create an engine with default configuration.
363    pub fn default_config() -> Self {
364        Self::new(RevisionConfig::default())
365    }
366
367    // ──────────────────────────────────────────────────────────────────────
368    // Internal helpers
369    // ──────────────────────────────────────────────────────────────────────
370
371    /// Generate a unique belief ID (no external dependencies).
372    fn next_id(&mut self) -> String {
373        let v = xorshift64(&mut self.id_seed);
374        format!("bel-{v:016x}")
375    }
376
377    /// Formulae currently held in the belief set (borrowed slices).
378    fn current_formulae(&self) -> Vec<&str> {
379        self.belief_set
380            .beliefs
381            .iter()
382            .map(|b| b.formula.as_str())
383            .collect()
384    }
385
386    /// Derive additional beliefs implied by the current set plus `new_formula`.
387    ///
388    /// Currently derives beliefs of the form "A AND B" from components
389    /// that are both already in the set (or one is `new_formula`).
390    fn derive_entailed(&mut self, new_formula: &str) -> Vec<Belief> {
391        let existing_formulae: Vec<String> = self
392            .belief_set
393            .beliefs
394            .iter()
395            .map(|b| b.formula.clone())
396            .collect();
397
398        let mut derived = Vec::new();
399
400        // For every existing belief, check if `new_formula AND existing` is
401        // worth asserting as a derived belief.  We only do this for simple
402        // atomic formulae (no nested AND) to avoid combinatorial explosion.
403        if !new_formula.contains(" AND ") {
404            for ef in &existing_formulae {
405                if ef.contains(" AND ") || ef == new_formula {
406                    continue; // skip already-compound formulae and self-conjunction
407                }
408                let conj = format!("{new_formula} AND {ef}");
409                // Only derive if there is a "consumer" (i.e., no identical
410                // belief already in the set and not a tautology).
411                let already_present = existing_formulae.iter().any(|f| f == &conj)
412                    || derived.iter().any(|d: &Belief| d.formula == conj);
413                if !already_present {
414                    let id = self.next_id();
415                    // Confidence is the minimum of the two conjuncts.
416                    let conf_new = self
417                        .belief_set
418                        .beliefs
419                        .iter()
420                        .find(|b| b.formula == new_formula)
421                        .map(|b| b.confidence)
422                        .unwrap_or(0.5_f64);
423                    let conf_ex = self
424                        .belief_set
425                        .beliefs
426                        .iter()
427                        .find(|b| b.formula == *ef)
428                        .map(|b| b.confidence)
429                        .unwrap_or(0.5_f64);
430                    let confidence = conf_new.min(conf_ex);
431                    derived.push(Belief {
432                        id,
433                        formula: conj,
434                        confidence,
435                        source: "derived".to_owned(),
436                        timestamp: 0,
437                        is_derived: true,
438                    });
439                }
440            }
441        }
442
443        derived
444    }
445
446    /// Find IDs of all beliefs whose formula is derived from `formula`:
447    /// i.e., the formula itself, or any conjunction that contains it.
448    fn beliefs_derived_from(&self, formula: &str) -> Vec<String> {
449        self.belief_set
450            .beliefs
451            .iter()
452            .filter(|b| {
453                b.formula == formula || b.formula.split(" AND ").any(|part| part.trim() == formula)
454            })
455            .map(|b| b.id.clone())
456            .collect()
457    }
458
459    /// Remove beliefs by IDs and return the removed IDs.
460    fn remove_beliefs(&mut self, ids: &[String]) -> Vec<String> {
461        let mut removed = Vec::new();
462        self.belief_set.beliefs.retain(|b| {
463            if ids.contains(&b.id) {
464                removed.push(b.id.clone());
465                false
466            } else {
467                true
468            }
469        });
470        // Invalidate entailment cache entries that touched removed formulae.
471        self.belief_set.entailment_cache.clear();
472        self.stats.beliefs_retracted += removed.len() as u64;
473        removed
474    }
475
476    // ──────────────────────────────────────────────────────────────────────
477    // Public API
478    // ──────────────────────────────────────────────────────────────────────
479
480    /// **Expansion** K+φ — add `belief` to the belief set.
481    ///
482    /// Optionally performs a consistency check and auto-consolidates on
483    /// conflict according to the engine's configuration.
484    ///
485    /// Returns the IDs of all beliefs added (the new belief and any
486    /// derived conjunctions).
487    pub fn expand(&mut self, belief: Belief) -> Result<Vec<String>, RevisionError> {
488        // Capacity check.
489        if self.config.max_beliefs > 0 && self.belief_set.beliefs.len() >= self.config.max_beliefs {
490            return Err(RevisionError::MaxBeliefsExceeded);
491        }
492
493        // Assign an ID if blank.
494        let mut belief = belief;
495        if belief.id.is_empty() {
496            belief.id = self.next_id();
497        }
498
499        // Consistency check before insertion.
500        if self.config.consistency_check_enabled {
501            let neg = negate(&belief.formula);
502            if let Some(existing) = self.belief_set.beliefs.iter().find(|b| b.formula == neg) {
503                let existing_formula = existing.formula.clone();
504                if self.config.auto_consolidate {
505                    // Prefer per retention function; insert then consolidate.
506                } else {
507                    return Err(RevisionError::ContradictionDetected {
508                        new: belief.formula.clone(),
509                        existing: existing_formula,
510                    });
511                }
512            }
513        }
514
515        // Insert the belief.
516        let main_id = belief.id.clone();
517        self.belief_set.beliefs.push(belief.clone());
518
519        // Derive additional beliefs entailed by the new belief + existing set.
520        let derived = self.derive_entailed(&belief.formula);
521        let derived_ids: Vec<String> = derived.iter().map(|d| d.id.clone()).collect();
522        for d in derived {
523            // Capacity guard for derived beliefs.
524            if self.config.max_beliefs > 0
525                && self.belief_set.beliefs.len() >= self.config.max_beliefs
526            {
527                break;
528            }
529            self.belief_set.beliefs.push(d);
530        }
531
532        // Invalidate entailment cache.
533        self.belief_set.entailment_cache.clear();
534
535        // Auto-consolidate if enabled and inconsistency detected.
536        if self.config.auto_consolidate
537            && self.config.consistency_check_enabled
538            && matches!(self.check_consistency(), ConsistencyCheck::Inconsistent(_))
539        {
540            self.consolidate();
541        }
542
543        self.stats.expansions += 1;
544        self.stats.current_belief_count = self.belief_set.beliefs.len();
545
546        let mut added = vec![main_id];
547        added.extend(derived_ids);
548        Ok(added)
549    }
550
551    /// **Contraction** K÷φ — remove `formula` and all beliefs derived from it.
552    ///
553    /// Uses the configured `RetentionFunction` to decide which beliefs to
554    /// keep when the formula appears in a contradicting pair.
555    ///
556    /// Returns the IDs of removed beliefs.
557    pub fn contract(&mut self, formula: &str) -> Result<Vec<String>, RevisionError> {
558        let to_remove = self.beliefs_derived_from(formula);
559        if to_remove.is_empty() {
560            self.stats.contractions += 1;
561            self.stats.current_belief_count = self.belief_set.beliefs.len();
562            return Ok(Vec::new());
563        }
564
565        let removed = self.remove_beliefs(&to_remove);
566        self.stats.contractions += 1;
567        self.stats.current_belief_count = self.belief_set.beliefs.len();
568        Ok(removed)
569    }
570
571    /// **Revision** K*φ — Levi identity: contract(¬φ), then expand(φ).
572    ///
573    /// Returns `(removed_ids, added_ids)`.
574    pub fn revise(&mut self, belief: Belief) -> Result<(Vec<String>, Vec<String>), RevisionError> {
575        let neg = negate(&belief.formula);
576
577        // Step 1 — contract by ¬φ.
578        let removed = self
579            .contract(&neg)
580            .map_err(|e| RevisionError::RevisionFailed(format!("contraction step failed: {e}")))?;
581
582        // Step 2 — expand by φ.
583        let added = self
584            .expand(belief)
585            .map_err(|e| RevisionError::RevisionFailed(format!("expansion step failed: {e}")))?;
586
587        // Update stats (revision supersedes the individual expansion/contraction counts).
588        self.stats.revisions += 1;
589        Ok((removed, added))
590    }
591
592    /// **Consolidation** — detect and remove contradictory pairs.
593    ///
594    /// For each `(a, b)` where `are_contradictory(a, b)`, the retention
595    /// function selects which one to drop.  Additionally, any derived
596    /// belief whose formula references a dropped formula is also removed
597    /// (so that "A AND NOT:A" style orphaned conjunctions are cleaned up).
598    ///
599    /// Returns IDs of all removed beliefs.
600    pub fn consolidate(&mut self) -> Vec<String> {
601        let mut drop_ids: Vec<String> = Vec::new();
602        let mut drop_formulae: Vec<String> = Vec::new();
603
604        // Collect all contradictory pairs and decide which to drop.
605        let n = self.belief_set.beliefs.len();
606        for i in 0..n {
607            for j in (i + 1)..n {
608                let a = &self.belief_set.beliefs[i];
609                let b = &self.belief_set.beliefs[j];
610                if are_contradictory(&a.formula, &b.formula) {
611                    // Decide which to keep.
612                    let keep_a = self.config.retention_function.prefers(a, b);
613                    let (drop_id, drop_formula) = if keep_a {
614                        (b.id.clone(), b.formula.clone())
615                    } else {
616                        (a.id.clone(), a.formula.clone())
617                    };
618                    if !drop_ids.contains(&drop_id) {
619                        drop_ids.push(drop_id);
620                        drop_formulae.push(drop_formula);
621                    }
622                }
623            }
624        }
625
626        // Also remove any derived beliefs that reference a dropped formula.
627        for formula in &drop_formulae {
628            for derived_id in self.beliefs_derived_from(formula) {
629                if !drop_ids.contains(&derived_id) {
630                    drop_ids.push(derived_id);
631                }
632            }
633        }
634
635        let removed = self.remove_beliefs(&drop_ids);
636        self.stats.consolidations += 1;
637        self.stats.current_belief_count = self.belief_set.beliefs.len();
638        removed
639    }
640
641    /// **Consistency check** — scan for contradictory belief pairs.
642    pub fn check_consistency(&self) -> ConsistencyCheck {
643        let mut conflicting = Vec::new();
644
645        let beliefs = &self.belief_set.beliefs;
646        let n = beliefs.len();
647        for i in 0..n {
648            for j in (i + 1)..n {
649                if are_contradictory(&beliefs[i].formula, &beliefs[j].formula) {
650                    if !conflicting.contains(&beliefs[i].id) {
651                        conflicting.push(beliefs[i].id.clone());
652                    }
653                    if !conflicting.contains(&beliefs[j].id) {
654                        conflicting.push(beliefs[j].id.clone());
655                    }
656                }
657            }
658        }
659
660        if conflicting.is_empty() {
661            ConsistencyCheck::Consistent
662        } else {
663            ConsistencyCheck::Inconsistent(conflicting)
664        }
665    }
666
667    /// Return `true` if the belief set (semantically) entails `formula`.
668    ///
669    /// Uses the simple string-based entailment rules with a memoisation cache.
670    pub fn entails(&mut self, formula: &str) -> bool {
671        // Fast path: cache hit.
672        if let Some(&cached) = self.belief_set.entailment_cache.get(formula) {
673            return cached;
674        }
675
676        let formulae: Vec<&str> = self.current_formulae();
677        let result = set_entails_formula(&formulae, formula);
678
679        self.belief_set
680            .entailment_cache
681            .insert(formula.to_owned(), result);
682        result
683    }
684
685    /// Look up a belief by its ID.
686    pub fn get_belief(&self, id: &str) -> Option<&Belief> {
687        self.belief_set.beliefs.iter().find(|b| b.id == id)
688    }
689
690    /// Return all beliefs whose formula matches `formula` exactly.
691    pub fn beliefs_about(&self, formula: &str) -> Vec<&Belief> {
692        self.belief_set
693            .beliefs
694            .iter()
695            .filter(|b| b.formula == formula)
696            .collect()
697    }
698
699    /// Apply a revision operation to the belief set.
700    pub fn apply_op(&mut self, op: RevisionOp) -> Result<(), RevisionError> {
701        match op {
702            RevisionOp::Expansion(belief) => {
703                self.expand(belief)?;
704            }
705            RevisionOp::Contraction(formula) => {
706                self.contract(&formula)?;
707            }
708            RevisionOp::Revision(belief) => {
709                self.revise(belief)?;
710            }
711            RevisionOp::Consolidation => {
712                self.consolidate();
713            }
714        }
715        Ok(())
716    }
717
718    /// Return a snapshot of current statistics.
719    pub fn stats(&self) -> RevisionStats {
720        let mut s = self.stats.clone();
721        s.current_belief_count = self.belief_set.beliefs.len();
722        s
723    }
724
725    /// Clone and return the current belief set.
726    pub fn snapshot(&self) -> BeliefSet {
727        self.belief_set.clone()
728    }
729
730    /// Return the number of beliefs currently held.
731    pub fn belief_count(&self) -> usize {
732        self.belief_set.beliefs.len()
733    }
734
735    /// Immutable reference to the internal belief set.
736    pub fn belief_set(&self) -> &BeliefSet {
737        &self.belief_set
738    }
739
740    /// Mutable reference to the internal belief set (for advanced use).
741    pub fn belief_set_mut(&mut self) -> &mut BeliefSet {
742        &mut self.belief_set
743    }
744
745    /// Replace the engine's configuration.
746    pub fn set_config(&mut self, config: RevisionConfig) {
747        self.config = config;
748    }
749
750    /// Reference to the current configuration.
751    pub fn config(&self) -> &RevisionConfig {
752        &self.config
753    }
754}
755
756// ────────────────────────────────────────────────────────────────────────────
757// Tests
758// ────────────────────────────────────────────────────────────────────────────
759
760#[cfg(test)]
761mod tests {
762    use super::*;
763    use std::collections::HashMap;
764
765    // ── helpers ──────────────────────────────────────────────────────────────
766
767    fn engine() -> BeliefRevisionEngine {
768        BeliefRevisionEngine::default_config()
769    }
770
771    fn belief(id: &str, formula: &str, confidence: f64) -> Belief {
772        Belief::new(id, formula, confidence, "test", 100)
773    }
774
775    fn belief_ts(id: &str, formula: &str, confidence: f64, ts: u64) -> Belief {
776        Belief::new(id, formula, confidence, "test", ts)
777    }
778
779    // ── xorshift64 ───────────────────────────────────────────────────────────
780
781    #[test]
782    fn test_xorshift64_non_zero() {
783        let mut state = 1u64;
784        let v = xorshift64(&mut state);
785        assert_ne!(v, 0);
786    }
787
788    #[test]
789    fn test_xorshift64_changes_state() {
790        let mut state = 42u64;
791        let v1 = xorshift64(&mut state);
792        let v2 = xorshift64(&mut state);
793        assert_ne!(v1, v2);
794    }
795
796    #[test]
797    fn test_xorshift64_sequence_deterministic() {
798        let mut s1 = 12345u64;
799        let mut s2 = 12345u64;
800        assert_eq!(xorshift64(&mut s1), xorshift64(&mut s2));
801    }
802
803    // ── Belief construction ──────────────────────────────────────────────────
804
805    #[test]
806    fn test_belief_confidence_clamped() {
807        let b = Belief::new("x", "p", 1.5, "s", 0);
808        assert_eq!(b.confidence, 1.0);
809        let b2 = Belief::new("y", "q", -0.1, "s", 0);
810        assert_eq!(b2.confidence, 0.0);
811    }
812
813    #[test]
814    fn test_belief_derived_flag() {
815        let b = belief("x", "p", 0.9).derived();
816        assert!(b.is_derived);
817    }
818
819    // ── BeliefSet helpers ─────────────────────────────────────────────────────
820
821    #[test]
822    fn test_belief_set_empty() {
823        let bs = BeliefSet::new();
824        assert!(bs.is_empty());
825        assert_eq!(bs.len(), 0);
826    }
827
828    // ── negate / are_contradictory helpers ───────────────────────────────────
829
830    #[test]
831    fn test_negate_positive() {
832        assert_eq!(negate("rain"), "NOT:rain");
833    }
834
835    #[test]
836    fn test_negate_negative() {
837        assert_eq!(negate("NOT:rain"), "rain");
838    }
839
840    #[test]
841    fn test_are_contradictory_true() {
842        assert!(are_contradictory("rain", "NOT:rain"));
843        assert!(are_contradictory("NOT:rain", "rain"));
844    }
845
846    #[test]
847    fn test_are_contradictory_false() {
848        assert!(!are_contradictory("rain", "wet"));
849    }
850
851    // ── Expansion ────────────────────────────────────────────────────────────
852
853    #[test]
854    fn test_expand_adds_belief() {
855        let mut e = engine();
856        let added = e
857            .expand(belief("b1", "rain", 0.9))
858            .expect("test: should succeed");
859        assert!(added.contains(&"b1".to_string()));
860        assert_eq!(e.belief_count(), 1);
861    }
862
863    #[test]
864    fn test_expand_assigns_id_when_blank() {
865        let mut e = engine();
866        let b = Belief::new("", "rain", 0.9, "s", 0);
867        let added = e.expand(b).expect("test: should succeed");
868        assert!(!added[0].is_empty());
869    }
870
871    #[test]
872    fn test_expand_returns_stats() {
873        let mut e = engine();
874        e.expand(belief("b1", "rain", 0.9))
875            .expect("test: should succeed");
876        assert_eq!(e.stats().expansions, 1);
877    }
878
879    #[test]
880    fn test_expand_max_beliefs_exceeded() {
881        let cfg = RevisionConfig {
882            max_beliefs: 1,
883            ..RevisionConfig::default()
884        };
885        let mut e = BeliefRevisionEngine::new(cfg);
886        e.expand(belief("b1", "a", 0.9))
887            .expect("test: should succeed");
888        let err = e.expand(belief("b2", "b", 0.8)).unwrap_err();
889        assert_eq!(err, RevisionError::MaxBeliefsExceeded);
890    }
891
892    #[test]
893    fn test_expand_auto_consolidate_on_contradiction() {
894        let mut e = engine(); // auto_consolidate = true
895        e.expand(belief("b1", "rain", 0.9))
896            .expect("test: should succeed");
897        // Adding NOT:rain should trigger auto-consolidate; lower confidence drops.
898        e.expand(belief("b2", "NOT:rain", 0.3))
899            .expect("test: should succeed");
900        // After consolidation the higher-confidence one remains.
901        assert_eq!(e.belief_count(), 1);
902        assert!(e.beliefs_about("rain").len() == 1);
903    }
904
905    #[test]
906    fn test_expand_contradiction_error_when_no_auto_consolidate() {
907        let cfg = RevisionConfig {
908            auto_consolidate: false,
909            consistency_check_enabled: true,
910            ..RevisionConfig::default()
911        };
912        let mut e = BeliefRevisionEngine::new(cfg);
913        e.expand(belief("b1", "rain", 0.9))
914            .expect("test: should succeed");
915        let err = e.expand(belief("b2", "NOT:rain", 0.3)).unwrap_err();
916        assert!(matches!(err, RevisionError::ContradictionDetected { .. }));
917    }
918
919    #[test]
920    fn test_expand_derives_conjunction() {
921        let mut e = engine();
922        e.expand(belief("b1", "rain", 0.8))
923            .expect("test: should succeed");
924        e.expand(belief("b2", "wet", 0.7))
925            .expect("test: should succeed");
926        // The engine should derive "rain AND wet" or "wet AND rain".
927        let has_conj = e
928            .belief_set()
929            .beliefs
930            .iter()
931            .any(|b| b.formula.contains(" AND ") && b.is_derived);
932        assert!(has_conj);
933    }
934
935    // ── Contraction ──────────────────────────────────────────────────────────
936
937    #[test]
938    fn test_contract_removes_belief() {
939        let mut e = engine();
940        e.expand(belief("b1", "rain", 0.9))
941            .expect("test: should succeed");
942        let removed = e.contract("rain").expect("test: should succeed");
943        assert!(removed.contains(&"b1".to_string()));
944        assert_eq!(e.belief_count(), 0);
945    }
946
947    #[test]
948    fn test_contract_nonexistent_ok() {
949        let mut e = engine();
950        let removed = e.contract("unicorn").expect("test: should succeed");
951        assert!(removed.is_empty());
952    }
953
954    #[test]
955    fn test_contract_removes_derived_conjunction() {
956        let mut e = engine();
957        e.expand(belief("b1", "rain", 0.8))
958            .expect("test: should succeed");
959        e.expand(belief("b2", "wet", 0.7))
960            .expect("test: should succeed");
961        // Contracting "rain" should remove "rain" and any conjunction containing it.
962        e.contract("rain").expect("test: should succeed");
963        for b in &e.belief_set().beliefs {
964            assert!(
965                !b.formula.contains("rain"),
966                "found residual formula: {}",
967                b.formula
968            );
969        }
970    }
971
972    #[test]
973    fn test_contract_stats() {
974        let mut e = engine();
975        e.expand(belief("b1", "rain", 0.9))
976            .expect("test: should succeed");
977        e.contract("rain").expect("test: should succeed");
978        assert_eq!(e.stats().contractions, 1);
979    }
980
981    // ── Revision (Levi identity) ──────────────────────────────────────────────
982
983    #[test]
984    fn test_revise_adds_new_formula() {
985        let mut e = engine();
986        e.expand(belief("b1", "rain", 0.9))
987            .expect("test: should succeed");
988        let (_, added) = e
989            .revise(belief("b2", "NOT:rain", 0.8))
990            .expect("test: should succeed");
991        assert!(!added.is_empty());
992        // After revision, "NOT:rain" should be in the set.
993        assert!(e.entails("NOT:rain"));
994    }
995
996    #[test]
997    fn test_revise_removes_negation() {
998        let mut e = engine();
999        e.expand(belief("b1", "rain", 0.9))
1000            .expect("test: should succeed");
1001        let (removed, _) = e
1002            .revise(belief("b2", "NOT:rain", 0.8))
1003            .expect("test: should succeed");
1004        // "rain" should have been retracted.
1005        assert!(removed.contains(&"b1".to_string()));
1006        assert!(!e.entails("rain"));
1007    }
1008
1009    #[test]
1010    fn test_revise_levi_identity_no_duplicate() {
1011        // After K*(NOT:rain), the set should not contain both "rain" and "NOT:rain".
1012        let mut e = engine();
1013        e.expand(belief("b1", "rain", 0.5))
1014            .expect("test: should succeed");
1015        e.revise(belief("b2", "NOT:rain", 0.9))
1016            .expect("test: should succeed");
1017        assert_eq!(
1018            e.check_consistency(),
1019            ConsistencyCheck::Consistent,
1020            "set should be consistent after revision"
1021        );
1022    }
1023
1024    #[test]
1025    fn test_revise_stats() {
1026        let mut e = engine();
1027        e.expand(belief("b1", "rain", 0.9))
1028            .expect("test: should succeed");
1029        e.revise(belief("b2", "NOT:rain", 0.6))
1030            .expect("test: should succeed");
1031        assert_eq!(e.stats().revisions, 1);
1032    }
1033
1034    #[test]
1035    fn test_revise_fresh_formula() {
1036        // Revising with a completely new formula should just expand.
1037        let mut e = engine();
1038        let (removed, added) = e
1039            .revise(belief("b1", "sun", 0.9))
1040            .expect("test: should succeed");
1041        assert!(removed.is_empty());
1042        assert!(!added.is_empty());
1043    }
1044
1045    // ── Consolidation ─────────────────────────────────────────────────────────
1046
1047    #[test]
1048    fn test_consolidate_removes_lower_confidence() {
1049        let cfg = RevisionConfig {
1050            auto_consolidate: false,
1051            consistency_check_enabled: false,
1052            retention_function: RetentionFunction::EpistemicEntrenchment,
1053            ..RevisionConfig::default()
1054        };
1055        let mut e = BeliefRevisionEngine::new(cfg);
1056        e.expand(belief("b1", "rain", 0.9))
1057            .expect("test: should succeed");
1058        e.expand(belief("b2", "NOT:rain", 0.3))
1059            .expect("test: should succeed");
1060        let removed = e.consolidate();
1061        assert!(removed.contains(&"b2".to_string()));
1062        assert_eq!(e.belief_count(), 1);
1063    }
1064
1065    #[test]
1066    fn test_consolidate_no_contradictions() {
1067        let mut e = engine();
1068        e.expand(belief("b1", "rain", 0.9))
1069            .expect("test: should succeed");
1070        e.expand(belief("b2", "wet", 0.7))
1071            .expect("test: should succeed");
1072        // Before consolidation manually override auto_consolidate to avoid side-effects.
1073        let removed = e.consolidate();
1074        // No contradictions — nothing removed (except possibly derived beliefs if any
1075        // happen to conflict, which they won't here).
1076        for id in &removed {
1077            // If anything was removed it must have been a contradiction.
1078            let _ = id;
1079        }
1080        assert_eq!(e.check_consistency(), ConsistencyCheck::Consistent);
1081    }
1082
1083    #[test]
1084    fn test_consolidate_recency_bias() {
1085        let cfg = RevisionConfig {
1086            auto_consolidate: false,
1087            consistency_check_enabled: false,
1088            retention_function: RetentionFunction::RecencyBias,
1089            ..RevisionConfig::default()
1090        };
1091        let mut e = BeliefRevisionEngine::new(cfg);
1092        e.expand(belief_ts("b1", "rain", 0.9, 100))
1093            .expect("test: should succeed");
1094        e.expand(belief_ts("b2", "NOT:rain", 0.3, 200))
1095            .expect("test: should succeed");
1096        let removed = e.consolidate();
1097        // b2 is newer, so b1 should be removed.
1098        assert!(removed.contains(&"b1".to_string()));
1099    }
1100
1101    #[test]
1102    fn test_consolidate_source_priority() {
1103        let mut priorities = HashMap::new();
1104        priorities.insert("sensor".to_string(), 5u32);
1105        priorities.insert("user".to_string(), 10u32);
1106        let cfg = RevisionConfig {
1107            auto_consolidate: false,
1108            consistency_check_enabled: false,
1109            retention_function: RetentionFunction::SourcePriority(priorities),
1110            ..RevisionConfig::default()
1111        };
1112        let mut e = BeliefRevisionEngine::new(cfg);
1113        let b1 = Belief::new("b1", "rain", 0.9, "sensor", 100);
1114        let b2 = Belief::new("b2", "NOT:rain", 0.3, "user", 100);
1115        e.expand(b1).expect("test: should succeed");
1116        e.expand(b2).expect("test: should succeed");
1117        let removed = e.consolidate();
1118        // "user" has higher priority, so "sensor" (b1) is removed.
1119        assert!(removed.contains(&"b1".to_string()));
1120    }
1121
1122    #[test]
1123    fn test_consolidate_minimal_change() {
1124        let cfg = RevisionConfig {
1125            auto_consolidate: false,
1126            consistency_check_enabled: false,
1127            retention_function: RetentionFunction::MinimalChange,
1128            ..RevisionConfig::default()
1129        };
1130        let mut e = BeliefRevisionEngine::new(cfg);
1131        // is_derived = false → prefer keeping; is_derived = true → prefer dropping
1132        let b1 = Belief::new("b1", "rain", 0.5, "test", 100);
1133        let b2 = Belief {
1134            id: "b2".to_string(),
1135            formula: "NOT:rain".to_string(),
1136            confidence: 0.5,
1137            source: "test".to_string(),
1138            timestamp: 100,
1139            is_derived: true,
1140        };
1141        e.expand(b1).expect("test: should succeed");
1142        e.expand(b2).expect("test: should succeed");
1143        let removed = e.consolidate();
1144        // b2 is derived, so it should be dropped.
1145        assert!(removed.contains(&"b2".to_string()));
1146    }
1147
1148    #[test]
1149    fn test_consolidate_stats() {
1150        let mut e = engine();
1151        e.consolidate();
1152        assert_eq!(e.stats().consolidations, 1);
1153    }
1154
1155    // ── Consistency check ─────────────────────────────────────────────────────
1156
1157    #[test]
1158    fn test_check_consistency_consistent() {
1159        let mut e = engine();
1160        e.expand(belief("b1", "rain", 0.9))
1161            .expect("test: should succeed");
1162        assert_eq!(e.check_consistency(), ConsistencyCheck::Consistent);
1163    }
1164
1165    #[test]
1166    fn test_check_consistency_inconsistent() {
1167        let cfg = RevisionConfig {
1168            auto_consolidate: false,
1169            consistency_check_enabled: false,
1170            ..RevisionConfig::default()
1171        };
1172        let mut e = BeliefRevisionEngine::new(cfg);
1173        e.expand(belief("b1", "rain", 0.9))
1174            .expect("test: should succeed");
1175        e.expand(belief("b2", "NOT:rain", 0.3))
1176            .expect("test: should succeed");
1177        let cc = e.check_consistency();
1178        assert!(matches!(cc, ConsistencyCheck::Inconsistent(_)));
1179        if let ConsistencyCheck::Inconsistent(ids) = cc {
1180            assert!(ids.contains(&"b1".to_string()));
1181            assert!(ids.contains(&"b2".to_string()));
1182        }
1183    }
1184
1185    #[test]
1186    fn test_check_consistency_empty_set() {
1187        let e = engine();
1188        assert_eq!(e.check_consistency(), ConsistencyCheck::Consistent);
1189    }
1190
1191    // ── Entailment ────────────────────────────────────────────────────────────
1192
1193    #[test]
1194    fn test_entails_direct() {
1195        let mut e = engine();
1196        e.expand(belief("b1", "rain", 0.9))
1197            .expect("test: should succeed");
1198        assert!(e.entails("rain"));
1199        assert!(!e.entails("sun"));
1200    }
1201
1202    #[test]
1203    fn test_entails_conjunction() {
1204        let mut e = engine();
1205        e.expand(belief("b1", "rain", 0.8))
1206            .expect("test: should succeed");
1207        e.expand(belief("b2", "wet", 0.7))
1208            .expect("test: should succeed");
1209        assert!(e.entails("rain AND wet") || e.entails("wet AND rain"));
1210    }
1211
1212    #[test]
1213    fn test_entails_cache_hit() {
1214        let mut e = engine();
1215        e.expand(belief("b1", "rain", 0.9))
1216            .expect("test: should succeed");
1217        assert!(e.entails("rain")); // populates cache
1218        assert!(e.entails("rain")); // hits cache
1219    }
1220
1221    #[test]
1222    fn test_entails_not_present() {
1223        let mut e = engine();
1224        assert!(!e.entails("unicorn"));
1225    }
1226
1227    // ── get_belief / beliefs_about ────────────────────────────────────────────
1228
1229    #[test]
1230    fn test_get_belief_found() {
1231        let mut e = engine();
1232        e.expand(belief("b1", "rain", 0.9))
1233            .expect("test: should succeed");
1234        assert!(e.get_belief("b1").is_some());
1235    }
1236
1237    #[test]
1238    fn test_get_belief_not_found() {
1239        let e = engine();
1240        assert!(e.get_belief("missing").is_none());
1241    }
1242
1243    #[test]
1244    fn test_beliefs_about_empty() {
1245        let e = engine();
1246        assert!(e.beliefs_about("rain").is_empty());
1247    }
1248
1249    #[test]
1250    fn test_beliefs_about_found() {
1251        let mut e = engine();
1252        e.expand(belief("b1", "rain", 0.9))
1253            .expect("test: should succeed");
1254        assert_eq!(e.beliefs_about("rain").len(), 1);
1255    }
1256
1257    // ── apply_op ──────────────────────────────────────────────────────────────
1258
1259    #[test]
1260    fn test_apply_op_expansion() {
1261        let mut e = engine();
1262        let op = RevisionOp::Expansion(belief("b1", "rain", 0.9));
1263        e.apply_op(op).expect("test: should succeed");
1264        // At least the directly asserted belief must be present.
1265        assert!(e.belief_count() >= 1);
1266        assert!(e.entails("rain"));
1267    }
1268
1269    #[test]
1270    fn test_apply_op_contraction() {
1271        let mut e = engine();
1272        e.expand(belief("b1", "rain", 0.9))
1273            .expect("test: should succeed");
1274        let op = RevisionOp::Contraction("rain".to_string());
1275        e.apply_op(op).expect("test: should succeed");
1276        assert_eq!(e.belief_count(), 0);
1277    }
1278
1279    #[test]
1280    fn test_apply_op_revision() {
1281        let mut e = engine();
1282        e.expand(belief("b1", "rain", 0.5))
1283            .expect("test: should succeed");
1284        let op = RevisionOp::Revision(belief("b2", "NOT:rain", 0.9));
1285        e.apply_op(op).expect("test: should succeed");
1286        assert!(e.entails("NOT:rain"));
1287    }
1288
1289    #[test]
1290    fn test_apply_op_consolidation() {
1291        let cfg = RevisionConfig {
1292            auto_consolidate: false,
1293            consistency_check_enabled: false,
1294            ..RevisionConfig::default()
1295        };
1296        let mut e = BeliefRevisionEngine::new(cfg);
1297        e.expand(belief("b1", "rain", 0.9))
1298            .expect("test: should succeed");
1299        e.expand(belief("b2", "NOT:rain", 0.3))
1300            .expect("test: should succeed");
1301        e.apply_op(RevisionOp::Consolidation)
1302            .expect("test: should succeed");
1303        // All remaining beliefs should be consistent — no contradictory pair survives.
1304        assert_eq!(e.check_consistency(), ConsistencyCheck::Consistent);
1305    }
1306
1307    // ── stats ─────────────────────────────────────────────────────────────────
1308
1309    #[test]
1310    fn test_stats_initial() {
1311        let e = engine();
1312        let s = e.stats();
1313        assert_eq!(s.expansions, 0);
1314        assert_eq!(s.contractions, 0);
1315        assert_eq!(s.revisions, 0);
1316        assert_eq!(s.consolidations, 0);
1317        assert_eq!(s.beliefs_retracted, 0);
1318        assert_eq!(s.current_belief_count, 0);
1319    }
1320
1321    #[test]
1322    fn test_stats_after_operations() {
1323        let mut e = engine();
1324        e.expand(belief("b1", "rain", 0.9))
1325            .expect("test: should succeed");
1326        e.contract("rain").expect("test: should succeed");
1327        let s = e.stats();
1328        assert_eq!(s.expansions, 1);
1329        assert_eq!(s.contractions, 1);
1330        assert!(s.beliefs_retracted >= 1);
1331        assert_eq!(s.current_belief_count, 0);
1332    }
1333
1334    // ── snapshot ──────────────────────────────────────────────────────────────
1335
1336    #[test]
1337    fn test_snapshot_clone() {
1338        let mut e = engine();
1339        e.expand(belief("b1", "rain", 0.9))
1340            .expect("test: should succeed");
1341        let snap = e.snapshot();
1342        e.contract("rain").expect("test: should succeed");
1343        // Snapshot should still contain the belief.
1344        assert_eq!(snap.beliefs.len(), 1);
1345        assert_eq!(e.belief_count(), 0);
1346    }
1347
1348    // ── error cases ───────────────────────────────────────────────────────────
1349
1350    #[test]
1351    fn test_error_display_belief_not_found() {
1352        let e = RevisionError::BeliefNotFound("x".to_string());
1353        assert!(e.to_string().contains("x"));
1354    }
1355
1356    #[test]
1357    fn test_error_display_max_beliefs_exceeded() {
1358        let e = RevisionError::MaxBeliefsExceeded;
1359        assert!(!e.to_string().is_empty());
1360    }
1361
1362    #[test]
1363    fn test_error_display_contradiction_detected() {
1364        let e = RevisionError::ContradictionDetected {
1365            new: "rain".to_string(),
1366            existing: "NOT:rain".to_string(),
1367        };
1368        let s = e.to_string();
1369        assert!(s.contains("rain"));
1370        assert!(s.contains("NOT:rain"));
1371    }
1372
1373    #[test]
1374    fn test_error_display_revision_failed() {
1375        let e = RevisionError::RevisionFailed("oops".to_string());
1376        assert!(e.to_string().contains("oops"));
1377    }
1378
1379    // ── multiple beliefs + ordering ───────────────────────────────────────────
1380
1381    #[test]
1382    fn test_multiple_expansions_and_count() {
1383        let mut e = engine();
1384        for i in 0..10u32 {
1385            e.expand(belief(&format!("b{i}"), &format!("p{i}"), 0.5))
1386                .expect("test: should succeed");
1387        }
1388        assert!(e.belief_count() >= 10);
1389    }
1390
1391    #[test]
1392    fn test_revision_sequence() {
1393        let mut e = engine();
1394        e.expand(belief("b1", "A", 0.8))
1395            .expect("test: should succeed");
1396        e.revise(belief("b2", "NOT:A", 0.9))
1397            .expect("test: should succeed");
1398        e.revise(belief("b3", "A", 0.95))
1399            .expect("test: should succeed");
1400        // After final revision to A, the set should contain A and not NOT:A.
1401        assert!(e.entails("A"));
1402        assert!(!e.entails("NOT:A"));
1403        assert_eq!(e.check_consistency(), ConsistencyCheck::Consistent);
1404    }
1405
1406    #[test]
1407    fn test_agm_success_postulate_expansion() {
1408        // Expansion postulate: after K+φ, φ is in the set.
1409        let mut e = engine();
1410        e.expand(belief("b1", "rain", 0.9))
1411            .expect("test: should succeed");
1412        assert!(e.entails("rain"));
1413    }
1414
1415    #[test]
1416    fn test_agm_success_postulate_revision() {
1417        // Revision success: after K*φ, φ is in the set.
1418        let mut e = engine();
1419        e.expand(belief("b1", "sun", 0.8))
1420            .expect("test: should succeed");
1421        e.revise(belief("b2", "rain", 0.9))
1422            .expect("test: should succeed");
1423        assert!(e.entails("rain"));
1424    }
1425
1426    #[test]
1427    fn test_agm_consistency_postulate_revision() {
1428        // Revision consistency: after K*φ the set should be consistent.
1429        let mut e = engine();
1430        e.expand(belief("b1", "X", 0.5))
1431            .expect("test: should succeed");
1432        e.expand(belief("b2", "Y", 0.5))
1433            .expect("test: should succeed");
1434        e.revise(belief("b3", "NOT:X", 0.9))
1435            .expect("test: should succeed");
1436        assert_eq!(e.check_consistency(), ConsistencyCheck::Consistent);
1437    }
1438
1439    #[test]
1440    fn test_contract_idempotent() {
1441        let mut e = engine();
1442        e.expand(belief("b1", "rain", 0.9))
1443            .expect("test: should succeed");
1444        e.contract("rain").expect("test: should succeed");
1445        let removed2 = e.contract("rain").expect("test: should succeed");
1446        assert!(removed2.is_empty());
1447    }
1448
1449    #[test]
1450    fn test_set_config() {
1451        let mut e = engine();
1452        let new_cfg = RevisionConfig {
1453            max_beliefs: 5,
1454            ..RevisionConfig::default()
1455        };
1456        e.set_config(new_cfg);
1457        assert_eq!(e.config().max_beliefs, 5);
1458    }
1459
1460    #[test]
1461    fn test_belief_set_ref() {
1462        let mut e = engine();
1463        e.expand(belief("b1", "rain", 0.9))
1464            .expect("test: should succeed");
1465        let bs = e.belief_set();
1466        // At least the directly asserted belief must be present.
1467        assert!(!bs.is_empty());
1468        assert!(bs.beliefs.iter().any(|b| b.id == "b1"));
1469    }
1470
1471    // ── retention function defaults ───────────────────────────────────────────
1472
1473    #[test]
1474    fn test_retention_epistemic_equal_confidence() {
1475        let rf = RetentionFunction::EpistemicEntrenchment;
1476        let a = belief("a", "p", 0.7);
1477        let b = belief("b", "q", 0.7);
1478        // Equal confidence — a is preferred (>=).
1479        assert!(rf.prefers(&a, &b));
1480    }
1481
1482    #[test]
1483    fn test_retention_source_priority_missing_key() {
1484        let rf = RetentionFunction::SourcePriority(HashMap::new());
1485        let a = belief("a", "p", 0.5);
1486        let b = belief("b", "q", 0.5);
1487        // Both map to 0, a is preferred (>=).
1488        assert!(rf.prefers(&a, &b));
1489    }
1490
1491    #[test]
1492    fn test_revision_error_implements_std_error() {
1493        let e: Box<dyn std::error::Error> = Box::new(RevisionError::MaxBeliefsExceeded);
1494        assert!(!e.to_string().is_empty());
1495    }
1496}