Skip to main content

ipfrs_tensorlogic/
abductive_reasoning_engine.rs

1//! Abductive Reasoning Engine (ARE)
2//!
3//! Implements abduction — "inference to the best explanation" — over a set of
4//! observations, a library of abducible hypotheses, and a set of logical rules.
5//!
6//! ## Overview
7//!
8//! Given a set of *observations* (grounded facts that must be explained) and a
9//! repository of *hypotheses* (candidate explanations with associated costs),
10//! `AbductiveReasoningEngine` searches for minimal-cost hypothesis sets that
11//! *cover* (entail) all observations either directly or through rule chains.
12//!
13//! ## Cost Functions
14//!
15//! | Variant          | Behaviour                                      |
16//! |------------------|------------------------------------------------|
17//! | `SumCost`        | total cost = Σ individual hypothesis costs     |
18//! | `MaxCost`        | total cost = max individual hypothesis cost    |
19//! | `CountCost`      | total cost = number of hypotheses in the set  |
20//! | `WeightedCost`   | total cost = Σ (cost × weight), weight per id |
21//!
22//! ## Algorithm
23//!
24//! The engine uses a branch-and-bound search over hypothesis subsets ordered by
25//! cost.  At each node the set of uncovered observations is computed; if empty
26//! the current set is a complete explanation.  Duplicate explanations (same set
27//! of hypothesis ids, different order) are deduplicated via sorted fingerprints.
28//!
29//! ## Naming Conventions
30//!
31//! All exported names use the `Abr` prefix (AbductiveReasoningEngine types) to
32//! avoid collision with the `Are*` names already used by
33//! `adaptive_routing_engine` elsewhere in this crate.
34
35use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque};
36
37// ─────────────────────────────────────────────────────────────────────────────
38// PRNG helpers (no `rand` dependency)
39// ─────────────────────────────────────────────────────────────────────────────
40
41/// xorshift64 PRNG.
42#[inline]
43pub fn abr_xorshift64(state: &mut u64) -> u64 {
44    let mut x = *state;
45    x ^= x << 13;
46    x ^= x >> 7;
47    x ^= x << 17;
48    *state = x;
49    x
50}
51
52/// FNV-1a 64-bit hash.
53#[inline]
54pub fn fnv1a_64(data: &[u8]) -> u64 {
55    let mut h: u64 = 14_695_981_039_346_656_037;
56    for &b in data {
57        h ^= b as u64;
58        h = h.wrapping_mul(1_099_511_628_211);
59    }
60    h
61}
62
63// ─────────────────────────────────────────────────────────────────────────────
64// Public type alias: HypothesisId
65// ─────────────────────────────────────────────────────────────────────────────
66
67/// Opaque identifier for a hypothesis.
68pub type HypothesisId = u64;
69
70// ─────────────────────────────────────────────────────────────────────────────
71// AbrTerm — grounded predicate term
72// ─────────────────────────────────────────────────────────────────────────────
73
74/// A first-order-style ground term: `predicate(arg0, arg1, …)`.
75#[derive(Debug, Clone, PartialEq, Eq, Hash)]
76pub struct AbrTerm {
77    /// Predicate name (e.g. `"wet"`, `"broken_window"`).
78    pub predicate: String,
79    /// Ground arguments (e.g. `["lawn"]`).  May be empty for propositional facts.
80    pub args: Vec<String>,
81}
82
83impl AbrTerm {
84    /// Construct a new term.
85    pub fn new(predicate: impl Into<String>, args: Vec<impl Into<String>>) -> Self {
86        Self {
87            predicate: predicate.into(),
88            args: args.into_iter().map(|a| a.into()).collect(),
89        }
90    }
91
92    /// Propositional shorthand — zero arguments.
93    pub fn prop(predicate: impl Into<String>) -> Self {
94        Self {
95            predicate: predicate.into(),
96            args: Vec::new(),
97        }
98    }
99
100    /// Canonical string representation for hashing.
101    fn canonical(&self) -> String {
102        if self.args.is_empty() {
103            self.predicate.clone()
104        } else {
105            format!("{}({})", self.predicate, self.args.join(","))
106        }
107    }
108
109    /// FNV-1a fingerprint of the canonical form.
110    pub fn fingerprint(&self) -> u64 {
111        fnv1a_64(self.canonical().as_bytes())
112    }
113
114    /// Returns `true` if this term unifies with `other` under simple ground matching
115    /// (exact equality or wildcard `"_"` arguments).
116    pub fn matches(&self, other: &AbrTerm) -> bool {
117        if self.predicate != other.predicate {
118            return false;
119        }
120        if self.args.len() != other.args.len() {
121            return false;
122        }
123        self.args
124            .iter()
125            .zip(other.args.iter())
126            .all(|(a, b)| a == "_" || b == "_" || a == b)
127    }
128}
129
130// ─────────────────────────────────────────────────────────────────────────────
131// AbrHypothesis
132// ─────────────────────────────────────────────────────────────────────────────
133
134/// A candidate explanation.
135#[derive(Debug, Clone)]
136pub struct AbrHypothesis {
137    /// Stable identifier.
138    pub id: HypothesisId,
139    /// The fact this hypothesis asserts.
140    pub term: AbrTerm,
141    /// Non-negative cost.
142    pub cost: f64,
143    /// When `false` the hypothesis cannot be chosen by the abducer (it is
144    /// background knowledge).
145    pub is_abducible: bool,
146}
147
148impl AbrHypothesis {
149    fn new(id: HypothesisId, term: AbrTerm, cost: f64, is_abducible: bool) -> Self {
150        Self {
151            id,
152            term,
153            cost: cost.max(0.0),
154            is_abducible,
155        }
156    }
157}
158
159// ─────────────────────────────────────────────────────────────────────────────
160// AbrRule
161// ─────────────────────────────────────────────────────────────────────────────
162
163/// A logical rule: if all `body` terms are satisfied, the `head` is derived.
164#[derive(Debug, Clone)]
165pub struct AbrRule {
166    /// Derived conclusion.
167    pub head: AbrTerm,
168    /// Conjunction of conditions.
169    pub body: Vec<AbrTerm>,
170    /// Confidence weight ∈ [0, 1].
171    pub confidence: f64,
172}
173
174impl AbrRule {
175    fn new(head: AbrTerm, body: Vec<AbrTerm>, confidence: f64) -> Self {
176        Self {
177            head,
178            body,
179            confidence: confidence.clamp(0.0, 1.0),
180        }
181    }
182}
183
184// ─────────────────────────────────────────────────────────────────────────────
185// AreCostFunction
186// ─────────────────────────────────────────────────────────────────────────────
187
188/// Strategy for computing the total cost of a hypothesis set.
189#[derive(Debug, Clone)]
190pub enum AbrCostFunction {
191    /// Sum of all individual costs.
192    SumCost,
193    /// Maximum individual cost.
194    MaxCost,
195    /// Cardinality (number of hypotheses chosen).
196    CountCost,
197    /// Weighted sum: each hypothesis id maps to an additional weight multiplier.
198    WeightedCost(HashMap<HypothesisId, f64>),
199}
200
201impl AbrCostFunction {
202    /// Compute the total cost for a set of hypotheses.
203    pub fn compute(
204        &self,
205        ids: &[HypothesisId],
206        hypotheses: &HashMap<HypothesisId, AbrHypothesis>,
207    ) -> f64 {
208        match self {
209            AbrCostFunction::SumCost => ids
210                .iter()
211                .filter_map(|id| hypotheses.get(id).map(|h| h.cost))
212                .sum(),
213            AbrCostFunction::MaxCost => ids
214                .iter()
215                .filter_map(|id| hypotheses.get(id).map(|h| h.cost))
216                .fold(0.0_f64, f64::max),
217            AbrCostFunction::CountCost => ids.len() as f64,
218            AbrCostFunction::WeightedCost(weights) => ids
219                .iter()
220                .filter_map(|id| {
221                    hypotheses.get(id).map(|h| {
222                        let w = weights.get(id).copied().unwrap_or(1.0);
223                        h.cost * w
224                    })
225                })
226                .sum(),
227        }
228    }
229}
230
231// ─────────────────────────────────────────────────────────────────────────────
232// AbrEngineConfig
233// ─────────────────────────────────────────────────────────────────────────────
234
235/// Configuration for the abductive reasoning engine.
236#[derive(Debug, Clone)]
237pub struct AbrEngineConfig {
238    /// Maximum number of explanations to return.
239    pub max_explanations: usize,
240    /// Maximum size of any single hypothesis set.
241    pub max_hypothesis_set_size: usize,
242    /// Cost function applied to candidate hypothesis sets.
243    pub cost_function: AbrCostFunction,
244    /// When `true` prefer smaller hypothesis sets (Occam's razor).
245    pub prefer_minimal: bool,
246    /// Maximum number of search nodes to expand (budget).
247    pub max_search_nodes: usize,
248}
249
250impl Default for AbrEngineConfig {
251    fn default() -> Self {
252        Self {
253            max_explanations: 10,
254            max_hypothesis_set_size: 8,
255            cost_function: AbrCostFunction::SumCost,
256            prefer_minimal: true,
257            max_search_nodes: 100_000,
258        }
259    }
260}
261
262// ─────────────────────────────────────────────────────────────────────────────
263// AbrExplanation
264// ─────────────────────────────────────────────────────────────────────────────
265
266/// A complete or partial explanation produced by the abducer.
267#[derive(Debug, Clone)]
268pub struct AbrExplanation {
269    /// Ordered set of hypothesis ids chosen.
270    pub hypotheses: Vec<HypothesisId>,
271    /// Observations (and derived facts) covered by this explanation.
272    pub covered: Vec<AbrTerm>,
273    /// Aggregate cost of the chosen hypotheses.
274    pub total_cost: f64,
275    /// Fraction of all observations covered: `covered.len() / n_observations`.
276    pub completeness: f64,
277}
278
279impl AbrExplanation {
280    /// Returns `true` if every observation is covered.
281    pub fn is_complete(&self, n_observations: usize) -> bool {
282        self.covered.len() >= n_observations
283    }
284}
285
286// ─────────────────────────────────────────────────────────────────────────────
287// AbrExplanationRecord — history entry
288// ─────────────────────────────────────────────────────────────────────────────
289
290/// Snapshot recorded each time `abduce()` is called.
291#[derive(Debug, Clone)]
292pub struct AbrExplanationRecord {
293    /// Unix-epoch timestamp in milliseconds (derived from monotonic tick count).
294    pub timestamp_ms: u64,
295    /// Number of observations present at abduce time.
296    pub n_observations: usize,
297    /// Total number of hypothesis subsets tried during search.
298    pub n_hypotheses_tried: u64,
299    /// Cost of the best explanation found (f64::INFINITY if none).
300    pub best_cost: f64,
301}
302
303// ─────────────────────────────────────────────────────────────────────────────
304// AbrReasoningStats — snapshot
305// ─────────────────────────────────────────────────────────────────────────────
306
307/// Aggregate runtime statistics for the engine.
308#[derive(Debug, Clone)]
309pub struct AbrReasoningStats {
310    /// Total calls to `abduce()`.
311    pub abduce_calls: u64,
312    /// Total hypothesis subsets explored across all calls.
313    pub total_nodes_explored: u64,
314    /// Number of complete explanations ever found.
315    pub total_explanations_found: u64,
316    /// Total hypotheses registered.
317    pub n_hypotheses: usize,
318    /// Total rules registered.
319    pub n_rules: usize,
320    /// Total observations registered.
321    pub n_observations: usize,
322    /// Number of abduction history records retained.
323    pub history_len: usize,
324    /// Best cost ever achieved (f64::INFINITY if never found).
325    pub best_cost_ever: f64,
326}
327
328// ─────────────────────────────────────────────────────────────────────────────
329// Search node (internal)
330// ─────────────────────────────────────────────────────────────────────────────
331
332/// Internal node used by the branch-and-bound search.
333#[derive(Debug, Clone)]
334struct SearchNode {
335    /// Current hypothesis set under consideration.
336    chosen: Vec<HypothesisId>,
337    /// Index into the sorted abducible hypothesis list (next candidate to branch on).
338    next_idx: usize,
339    /// Accumulated cost so far.
340    cost_so_far: f64,
341}
342
343/// `BinaryHeap` requires `Ord`; we wrap cost in a min-heap adapter.
344#[derive(Debug, Clone)]
345struct MinHeapNode {
346    neg_cost: i64, // store -round(cost*1e6) so BinaryHeap gives min first
347    node: SearchNode,
348}
349
350impl PartialEq for MinHeapNode {
351    fn eq(&self, other: &Self) -> bool {
352        self.neg_cost == other.neg_cost
353    }
354}
355impl Eq for MinHeapNode {}
356impl PartialOrd for MinHeapNode {
357    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
358        Some(self.cmp(other))
359    }
360}
361impl Ord for MinHeapNode {
362    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
363        self.neg_cost.cmp(&other.neg_cost)
364    }
365}
366
367impl MinHeapNode {
368    fn new(node: SearchNode) -> Self {
369        let neg_cost = -(node.cost_so_far * 1_000_000.0) as i64;
370        Self { neg_cost, node }
371    }
372}
373
374// ─────────────────────────────────────────────────────────────────────────────
375// AbductiveReasoningEngine
376// ─────────────────────────────────────────────────────────────────────────────
377
378/// Production-quality abductive reasoning engine.
379///
380/// # Example
381/// ```
382/// use ipfrs_tensorlogic::{AbductiveReasoningEngine, AbrTerm, AbrEngineConfig};
383///
384/// let mut eng = AbductiveReasoningEngine::new(AbrEngineConfig::default());
385/// let wet    = AbrTerm::prop("wet_grass");
386/// let rain   = AbrTerm::prop("rain");
387/// eng.add_observation(wet.clone());
388/// let _hid = eng.add_hypothesis(rain, 1.0, true);
389/// let expls = eng.abduce();
390/// assert!(!expls.is_empty());
391/// ```
392pub struct AbductiveReasoningEngine {
393    hypotheses: HashMap<HypothesisId, AbrHypothesis>,
394    rules: Vec<AbrRule>,
395    observations: Vec<AbrTerm>,
396    history: VecDeque<AbrExplanationRecord>,
397    config: AbrEngineConfig,
398    // monotonically-increasing id counter
399    next_id: u64,
400    // cumulative stats
401    abduce_calls: u64,
402    total_nodes_explored: u64,
403    total_explanations_found: u64,
404    best_cost_ever: f64,
405    // lightweight entropy for timestamps
406    rng_state: u64,
407}
408
409impl AbductiveReasoningEngine {
410    // ── Construction ──────────────────────────────────────────────────────────
411
412    /// Create a new engine with the given configuration.
413    pub fn new(config: AbrEngineConfig) -> Self {
414        Self {
415            hypotheses: HashMap::new(),
416            rules: Vec::new(),
417            observations: Vec::new(),
418            history: VecDeque::with_capacity(200),
419            config,
420            next_id: 1,
421            abduce_calls: 0,
422            total_nodes_explored: 0,
423            total_explanations_found: 0,
424            best_cost_ever: f64::INFINITY,
425            rng_state: 0xDEAD_BEEF_CAFE_BABEu64,
426        }
427    }
428
429    /// Create an engine with default configuration.
430    pub fn default_engine() -> Self {
431        Self::new(AbrEngineConfig::default())
432    }
433
434    // ── Mutators ──────────────────────────────────────────────────────────────
435
436    /// Register a hypothesis and return its stable id.
437    pub fn add_hypothesis(&mut self, term: AbrTerm, cost: f64, is_abducible: bool) -> HypothesisId {
438        let id = self.fresh_id();
439        self.hypotheses
440            .insert(id, AbrHypothesis::new(id, term, cost, is_abducible));
441        id
442    }
443
444    /// Register a logical rule.
445    pub fn add_rule(&mut self, head: AbrTerm, body: Vec<AbrTerm>, confidence: f64) {
446        self.rules.push(AbrRule::new(head, body, confidence));
447    }
448
449    /// Add an observation to be explained.
450    pub fn add_observation(&mut self, term: AbrTerm) {
451        self.observations.push(term);
452    }
453
454    /// Remove all observations.
455    pub fn clear_observations(&mut self) {
456        self.observations.clear();
457    }
458
459    /// Update the engine configuration.
460    pub fn set_config(&mut self, config: AbrEngineConfig) {
461        self.config = config;
462    }
463
464    /// Remove a hypothesis by id.  Returns `true` if it existed.
465    pub fn remove_hypothesis(&mut self, id: HypothesisId) -> bool {
466        self.hypotheses.remove(&id).is_some()
467    }
468
469    // ── Core Reasoning ────────────────────────────────────────────────────────
470
471    /// Find all best explanations (hypothesis sets that cover all observations).
472    ///
473    /// Uses branch-and-bound over the space of abducible hypothesis subsets,
474    /// pruning branches that exceed the cost of the best complete explanation
475    /// found so far.
476    pub fn abduce(&mut self) -> Vec<AbrExplanation> {
477        self.abduce_calls += 1;
478
479        if self.observations.is_empty() {
480            // Nothing to explain — vacuously return the empty explanation.
481            let expl = AbrExplanation {
482                hypotheses: Vec::new(),
483                covered: Vec::new(),
484                total_cost: 0.0,
485                completeness: 1.0,
486            };
487            self.record_history(0, 0, 0.0);
488            return vec![expl];
489        }
490
491        // Build sorted list of abducible hypotheses (ascending cost for pruning).
492        let mut abducibles: Vec<HypothesisId> = self
493            .hypotheses
494            .values()
495            .filter(|h| h.is_abducible)
496            .map(|h| h.id)
497            .collect();
498        abducibles.sort_by(|a, b| {
499            let ca = self.hypotheses[a].cost;
500            let cb = self.hypotheses[b].cost;
501            ca.partial_cmp(&cb).unwrap_or(std::cmp::Ordering::Equal)
502        });
503
504        let n_obs = self.observations.len();
505        let max_set_size = self.config.max_hypothesis_set_size;
506        let max_nodes = self.config.max_search_nodes;
507        let max_expls = self.config.max_explanations;
508        let prefer_min = self.config.prefer_minimal;
509
510        let mut best_cost: f64 = f64::INFINITY;
511        let mut explanations: Vec<AbrExplanation> = Vec::new();
512        let mut seen_fingerprints: HashSet<u64> = HashSet::new();
513        let mut nodes_explored: u64 = 0;
514
515        // Seed the priority queue with the empty set.
516        let mut queue: BinaryHeap<MinHeapNode> = BinaryHeap::new();
517        queue.push(MinHeapNode::new(SearchNode {
518            chosen: Vec::new(),
519            next_idx: 0,
520            cost_so_far: 0.0,
521        }));
522
523        while let Some(wrapper) = queue.pop() {
524            if nodes_explored >= max_nodes as u64 {
525                break;
526            }
527            nodes_explored += 1;
528
529            let node = wrapper.node;
530
531            // Compute derived facts for the current hypothesis set.
532            let derived = self.apply_rules_for_set(&node.chosen);
533            let covered = self.covered_observations(&node.chosen, &derived);
534
535            if covered.len() == n_obs {
536                // Complete explanation found.
537                let cost = self
538                    .config
539                    .cost_function
540                    .compute(&node.chosen, &self.hypotheses);
541                let fp = set_fingerprint(&node.chosen);
542
543                if !seen_fingerprints.contains(&fp) {
544                    seen_fingerprints.insert(fp);
545
546                    // Prune: for minimal preference discard strictly worse explanations.
547                    let accept = if prefer_min {
548                        cost <= best_cost + 1e-9
549                    } else {
550                        true
551                    };
552
553                    if accept {
554                        if cost < best_cost {
555                            best_cost = cost;
556                            // Prune previously found explanations that are now sub-optimal.
557                            if prefer_min {
558                                explanations
559                                    .retain(|e: &AbrExplanation| e.total_cost <= best_cost + 1e-9);
560                            }
561                        }
562                        let expl = AbrExplanation {
563                            hypotheses: node.chosen.clone(),
564                            covered: covered.clone(),
565                            total_cost: cost,
566                            completeness: covered.len() as f64 / n_obs as f64,
567                        };
568                        explanations.push(expl);
569                        if explanations.len() >= max_expls {
570                            break;
571                        }
572                    }
573                }
574                // Do not branch further from a complete solution.
575                continue;
576            }
577
578            // Incomplete: try adding each candidate hypothesis.
579            for (branch_idx, &hid) in abducibles.iter().enumerate().skip(node.next_idx) {
580                // Pruning: set size limit.
581                if node.chosen.len() + 1 > max_set_size {
582                    break;
583                }
584
585                // Skip if already chosen (no duplicates).
586                if node.chosen.contains(&hid) {
587                    continue;
588                }
589
590                let hcost = self.hypotheses.get(&hid).map_or(0.0, |h| h.cost);
591                let new_cost = match &self.config.cost_function {
592                    AbrCostFunction::SumCost | AbrCostFunction::WeightedCost(_) => {
593                        node.cost_so_far + hcost
594                    }
595                    AbrCostFunction::MaxCost => node.cost_so_far.max(hcost),
596                    AbrCostFunction::CountCost => node.cost_so_far + 1.0,
597                };
598
599                // Prune: cost already exceeds best known.
600                if new_cost > best_cost + 1e-9 {
601                    continue;
602                }
603
604                let mut new_chosen = node.chosen.clone();
605                new_chosen.push(hid);
606
607                queue.push(MinHeapNode::new(SearchNode {
608                    chosen: new_chosen,
609                    next_idx: branch_idx + 1,
610                    cost_so_far: new_cost,
611                }));
612            }
613        }
614
615        self.total_nodes_explored += nodes_explored;
616        self.total_explanations_found += explanations.len() as u64;
617
618        // Update global best cost.
619        if let Some(best) = explanations.iter().map(|e| e.total_cost).reduce(f64::min) {
620            if best < self.best_cost_ever {
621                self.best_cost_ever = best;
622            }
623        }
624
625        // Record to history.
626        let best_found = explanations
627            .iter()
628            .map(|e| e.total_cost)
629            .fold(f64::INFINITY, f64::min);
630        self.record_history(n_obs, nodes_explored, best_found);
631
632        // Sort results: cheapest first, then by size for ties.
633        explanations.sort_by(|a, b| {
634            a.total_cost
635                .partial_cmp(&b.total_cost)
636                .unwrap_or(std::cmp::Ordering::Equal)
637                .then_with(|| a.hypotheses.len().cmp(&b.hypotheses.len()))
638        });
639
640        explanations
641    }
642
643    /// Return the single best explanation, if any.
644    pub fn best_explanation(&mut self) -> Option<AbrExplanation> {
645        let mut all = self.abduce();
646        if all.is_empty() {
647            None
648        } else {
649            Some(all.remove(0))
650        }
651    }
652
653    /// Determine which observations a hypothesis set covers (directly or via rules).
654    pub fn covers(&self, hypothesis_set: &[HypothesisId]) -> Vec<AbrTerm> {
655        let derived = self.apply_rules_for_set(hypothesis_set);
656        self.covered_observations(hypothesis_set, &derived)
657    }
658
659    /// Check that no two hypotheses in the set assert contradictory facts.
660    ///
661    /// A contradiction is detected when one hypothesis asserts `p(…)` and
662    /// another asserts `not_p(…)` (prefix `"not_"` convention) or explicitly
663    /// `"NOT:p(…)"`.
664    pub fn is_consistent(&self, hypothesis_set: &[HypothesisId]) -> bool {
665        let terms: Vec<&AbrTerm> = hypothesis_set
666            .iter()
667            .filter_map(|id| self.hypotheses.get(id).map(|h| &h.term))
668            .collect();
669
670        for (i, t) in terms.iter().enumerate() {
671            for t2 in &terms[i + 1..] {
672                if self.contradicts(t, t2) {
673                    return false;
674                }
675            }
676        }
677        true
678    }
679
680    /// Forward-chain all rules over the facts asserted by every hypothesis
681    /// in the current hypothesis registry (not just a subset).  Returns all
682    /// newly derived facts as a fixed point.
683    pub fn apply_rules(&self) -> Vec<AbrTerm> {
684        let all_ids: Vec<HypothesisId> = self.hypotheses.keys().copied().collect();
685        self.apply_rules_for_set(&all_ids)
686    }
687
688    /// Runtime statistics snapshot.
689    pub fn reasoning_stats(&self) -> AbrReasoningStats {
690        AbrReasoningStats {
691            abduce_calls: self.abduce_calls,
692            total_nodes_explored: self.total_nodes_explored,
693            total_explanations_found: self.total_explanations_found,
694            n_hypotheses: self.hypotheses.len(),
695            n_rules: self.rules.len(),
696            n_observations: self.observations.len(),
697            history_len: self.history.len(),
698            best_cost_ever: self.best_cost_ever,
699        }
700    }
701
702    // ── Accessors ─────────────────────────────────────────────────────────────
703
704    /// Retrieve a hypothesis by id.
705    pub fn hypothesis(&self, id: HypothesisId) -> Option<&AbrHypothesis> {
706        self.hypotheses.get(&id)
707    }
708
709    /// All hypothesis ids registered with the engine.
710    pub fn hypothesis_ids(&self) -> Vec<HypothesisId> {
711        self.hypotheses.keys().copied().collect()
712    }
713
714    /// All observations currently registered.
715    pub fn observations(&self) -> &[AbrTerm] {
716        &self.observations
717    }
718
719    /// Borrow a slice of all rules.
720    pub fn rules(&self) -> &[AbrRule] {
721        &self.rules
722    }
723
724    /// Access the explanation history (most recent last).
725    pub fn history(&self) -> &VecDeque<AbrExplanationRecord> {
726        &self.history
727    }
728
729    // ── Internal helpers ──────────────────────────────────────────────────────
730
731    /// Generate a unique id.
732    fn fresh_id(&mut self) -> HypothesisId {
733        // Mix next_id with entropy from xorshift64.
734        let x = abr_xorshift64(&mut self.rng_state);
735        let id = fnv1a_64(&(self.next_id ^ x).to_le_bytes());
736        self.next_id += 1;
737        id
738    }
739
740    /// Forward-chain rules for a specific hypothesis subset.
741    ///
742    /// Runs until a fixed point is reached (no new facts derived).
743    fn apply_rules_for_set(&self, hypothesis_set: &[HypothesisId]) -> Vec<AbrTerm> {
744        // Seed with the facts asserted directly by the hypothesis set.
745        let mut known: HashSet<u64> = hypothesis_set
746            .iter()
747            .filter_map(|id| self.hypotheses.get(id))
748            .map(|h| h.term.fingerprint())
749            .collect();
750
751        let mut known_terms: Vec<AbrTerm> = hypothesis_set
752            .iter()
753            .filter_map(|id| self.hypotheses.get(id))
754            .map(|h| h.term.clone())
755            .collect();
756
757        // Fixed-point iteration.
758        loop {
759            let before = known.len();
760            for rule in &self.rules {
761                // Check if every body term is in `known_terms`.
762                let body_satisfied = rule
763                    .body
764                    .iter()
765                    .all(|bt| known_terms.iter().any(|kt| kt.matches(bt)));
766                if body_satisfied {
767                    let fp = rule.head.fingerprint();
768                    if !known.contains(&fp) {
769                        known.insert(fp);
770                        known_terms.push(rule.head.clone());
771                    }
772                }
773            }
774            if known.len() == before {
775                break;
776            }
777        }
778
779        // Return only derived (non-hypothesis) terms.
780        let seed_fps: HashSet<u64> = hypothesis_set
781            .iter()
782            .filter_map(|id| self.hypotheses.get(id))
783            .map(|h| h.term.fingerprint())
784            .collect();
785
786        known_terms
787            .into_iter()
788            .filter(|t| !seed_fps.contains(&t.fingerprint()))
789            .collect()
790    }
791
792    /// Determine which observations are covered (direct match or derived).
793    fn covered_observations(
794        &self,
795        hypothesis_set: &[HypothesisId],
796        derived: &[AbrTerm],
797    ) -> Vec<AbrTerm> {
798        let mut covered = Vec::new();
799        for obs in &self.observations {
800            // Direct coverage: some hypothesis asserts the observation.
801            let direct = hypothesis_set
802                .iter()
803                .filter_map(|id| self.hypotheses.get(id))
804                .any(|h| h.term.matches(obs));
805
806            // Indirect coverage: a derived fact matches.
807            let indirect = derived.iter().any(|d| d.matches(obs));
808
809            if direct || indirect {
810                covered.push(obs.clone());
811            }
812        }
813        covered
814    }
815
816    /// Return `true` if terms `a` and `b` are logical contradictions.
817    fn contradicts(&self, a: &AbrTerm, b: &AbrTerm) -> bool {
818        // Convention 1: "not_X" vs "X"  (same args).
819        let negation_of = |pos: &str, neg: &str| -> bool {
820            neg == format!("not_{}", pos) || neg == format!("NOT:{}", pos)
821        };
822        if a.args == b.args
823            && (negation_of(&a.predicate, &b.predicate) || negation_of(&b.predicate, &a.predicate))
824        {
825            return true;
826        }
827        // Convention 2: "NOT:predicate" prefix.
828        if a.predicate.starts_with("NOT:") {
829            let pos_pred = &a.predicate["NOT:".len()..];
830            if b.predicate == pos_pred && a.args == b.args {
831                return true;
832            }
833        }
834        if b.predicate.starts_with("NOT:") {
835            let pos_pred = &b.predicate["NOT:".len()..];
836            if a.predicate == pos_pred && a.args == b.args {
837                return true;
838            }
839        }
840        false
841    }
842
843    /// Push a record to the bounded history deque.
844    fn record_history(&mut self, n_obs: usize, n_tried: u64, best_cost: f64) {
845        // Lightweight timestamp: tick count from xorshift + base.
846        let ts = abr_xorshift64(&mut self.rng_state) % 1_700_000_000_000;
847        if self.history.len() >= 200 {
848            self.history.pop_front();
849        }
850        self.history.push_back(AbrExplanationRecord {
851            timestamp_ms: ts,
852            n_observations: n_obs,
853            n_hypotheses_tried: n_tried,
854            best_cost,
855        });
856    }
857}
858
859// ─────────────────────────────────────────────────────────────────────────────
860// Free-standing helpers
861// ─────────────────────────────────────────────────────────────────────────────
862
863/// Compute a deterministic fingerprint for a set of hypothesis ids
864/// (order-independent — we sort before hashing).
865pub fn set_fingerprint(ids: &[HypothesisId]) -> u64 {
866    let mut sorted: Vec<HypothesisId> = ids.to_vec();
867    sorted.sort_unstable();
868    let mut buf: Vec<u8> = Vec::with_capacity(sorted.len() * 8);
869    for id in &sorted {
870        buf.extend_from_slice(&id.to_le_bytes());
871    }
872    fnv1a_64(&buf)
873}
874
875// ─────────────────────────────────────────────────────────────────────────────
876// Type aliases (Abr* prefix)
877// ─────────────────────────────────────────────────────────────────────────────
878
879/// Type alias — primary engine type.
880pub type AbrAbductiveReasoningEngine = AbductiveReasoningEngine;
881
882// ─────────────────────────────────────────────────────────────────────────────
883// Tests
884// ─────────────────────────────────────────────────────────────────────────────
885
886#[cfg(test)]
887mod tests {
888    use super::*;
889
890    // ── Utility constructors ──────────────────────────────────────────────────
891
892    fn prop(p: &str) -> AbrTerm {
893        AbrTerm::prop(p)
894    }
895    fn term(p: &str, args: &[&str]) -> AbrTerm {
896        AbrTerm::new(p, args.iter().map(|s| s.to_string()).collect::<Vec<_>>())
897    }
898    fn engine() -> AbductiveReasoningEngine {
899        AbductiveReasoningEngine::new(AbrEngineConfig {
900            max_explanations: 20,
901            max_hypothesis_set_size: 6,
902            cost_function: AbrCostFunction::SumCost,
903            prefer_minimal: true,
904            max_search_nodes: 50_000,
905        })
906    }
907
908    // ── T01: basic construction ───────────────────────────────────────────────
909    #[test]
910    fn t01_new_engine_empty() {
911        let eng = engine();
912        assert_eq!(eng.hypotheses.len(), 0);
913        assert_eq!(eng.rules.len(), 0);
914        assert_eq!(eng.observations.len(), 0);
915    }
916
917    // ── T02: add_hypothesis returns distinct ids ───────────────────────────────
918    #[test]
919    fn t02_hypothesis_ids_distinct() {
920        let mut eng = engine();
921        let id1 = eng.add_hypothesis(prop("a"), 1.0, true);
922        let id2 = eng.add_hypothesis(prop("b"), 1.0, true);
923        assert_ne!(id1, id2);
924    }
925
926    // ── T03: hypothesis retrieval ─────────────────────────────────────────────
927    #[test]
928    fn t03_hypothesis_retrieval() {
929        let mut eng = engine();
930        let id = eng.add_hypothesis(prop("rain"), 2.5, true);
931        let h = eng.hypothesis(id).expect("should find hypothesis");
932        assert_eq!(h.term.predicate, "rain");
933        assert!((h.cost - 2.5).abs() < 1e-9);
934        assert!(h.is_abducible);
935    }
936
937    // ── T04: non-abducible hypothesis ignored in abduce ───────────────────────
938    #[test]
939    fn t04_non_abducible_ignored() {
940        let mut eng = engine();
941        eng.add_hypothesis(prop("rain"), 1.0, false); // background knowledge
942        eng.add_observation(prop("rain"));
943        let expls = eng.abduce();
944        // No abducible hypotheses → no complete explanation.
945        assert!(expls.is_empty() || expls[0].completeness < 1.0);
946    }
947
948    // ── T05: single hypothesis covers single observation ──────────────────────
949    #[test]
950    fn t05_single_hyp_covers_obs() {
951        let mut eng = engine();
952        let _id = eng.add_hypothesis(prop("rain"), 1.0, true);
953        eng.add_observation(prop("rain"));
954        let expls = eng.abduce();
955        assert!(!expls.is_empty());
956        assert_eq!(expls[0].completeness, 1.0);
957    }
958
959    // ── T06: multiple hypotheses, minimal set ─────────────────────────────────
960    #[test]
961    fn t06_minimal_set_preferred() {
962        let mut eng = engine();
963        let id1 = eng.add_hypothesis(prop("rain"), 1.0, true);
964        let _id2 = eng.add_hypothesis(prop("sun"), 5.0, true);
965        eng.add_observation(prop("rain"));
966        let expls = eng.abduce();
967        assert!(!expls.is_empty());
968        // The cheapest complete explanation uses only `rain`.
969        assert!(expls[0].hypotheses.contains(&id1));
970        assert_eq!(expls[0].hypotheses.len(), 1);
971    }
972
973    // ── T07: cost function SumCost ────────────────────────────────────────────
974    #[test]
975    fn t07_sum_cost() {
976        let mut eng = engine();
977        let id1 = eng.add_hypothesis(prop("a"), 2.0, true);
978        let id2 = eng.add_hypothesis(prop("b"), 3.0, true);
979        eng.add_observation(prop("a"));
980        eng.add_observation(prop("b"));
981        let expls = eng.abduce();
982        assert!(!expls.is_empty());
983        let best = &expls[0];
984        assert!((best.total_cost - 5.0).abs() < 1e-6);
985        assert!(best.hypotheses.contains(&id1));
986        assert!(best.hypotheses.contains(&id2));
987    }
988
989    // ── T08: cost function MaxCost ────────────────────────────────────────────
990    #[test]
991    fn t08_max_cost() {
992        let mut eng = AbductiveReasoningEngine::new(AbrEngineConfig {
993            cost_function: AbrCostFunction::MaxCost,
994            ..AbrEngineConfig::default()
995        });
996        eng.add_hypothesis(prop("a"), 2.0, true);
997        eng.add_hypothesis(prop("b"), 3.0, true);
998        eng.add_observation(prop("a"));
999        eng.add_observation(prop("b"));
1000        let expls = eng.abduce();
1001        assert!(!expls.is_empty());
1002        // MaxCost of {a,b} = max(2,3) = 3.
1003        assert!((expls[0].total_cost - 3.0).abs() < 1e-6);
1004    }
1005
1006    // ── T09: cost function CountCost ──────────────────────────────────────────
1007    #[test]
1008    fn t09_count_cost() {
1009        let mut eng = AbductiveReasoningEngine::new(AbrEngineConfig {
1010            cost_function: AbrCostFunction::CountCost,
1011            ..AbrEngineConfig::default()
1012        });
1013        eng.add_hypothesis(prop("a"), 99.0, true);
1014        eng.add_hypothesis(prop("b"), 0.1, true);
1015        eng.add_observation(prop("a"));
1016        eng.add_observation(prop("b"));
1017        let expls = eng.abduce();
1018        assert!(!expls.is_empty());
1019        // CountCost = 2 regardless of individual costs.
1020        assert!((expls[0].total_cost - 2.0).abs() < 1e-6);
1021    }
1022
1023    // ── T10: cost function WeightedCost ───────────────────────────────────────
1024    #[test]
1025    fn t10_weighted_cost() {
1026        let mut eng = engine();
1027        let id1 = eng.add_hypothesis(prop("x"), 1.0, true);
1028        let id2 = eng.add_hypothesis(prop("y"), 1.0, true);
1029        let mut weights = HashMap::new();
1030        weights.insert(id1, 3.0);
1031        weights.insert(id2, 0.5);
1032        eng.set_config(AbrEngineConfig {
1033            cost_function: AbrCostFunction::WeightedCost(weights),
1034            max_explanations: 10,
1035            max_hypothesis_set_size: 6,
1036            prefer_minimal: true,
1037            max_search_nodes: 50_000,
1038        });
1039        eng.add_observation(prop("x"));
1040        eng.add_observation(prop("y"));
1041        let expls = eng.abduce();
1042        assert!(!expls.is_empty());
1043        // WeightedCost = 1*3 + 1*0.5 = 3.5
1044        assert!((expls[0].total_cost - 3.5).abs() < 1e-6);
1045    }
1046
1047    // ── T11: rule-based coverage ──────────────────────────────────────────────
1048    #[test]
1049    fn t11_rule_derived_coverage() {
1050        let mut eng = engine();
1051        let id_rain = eng.add_hypothesis(prop("rain"), 1.0, true);
1052        // Rule: rain → wet_grass
1053        eng.add_rule(prop("wet_grass"), vec![prop("rain")], 1.0);
1054        eng.add_observation(prop("wet_grass"));
1055        let expls = eng.abduce();
1056        assert!(!expls.is_empty());
1057        assert_eq!(expls[0].completeness, 1.0);
1058        assert!(expls[0].hypotheses.contains(&id_rain));
1059    }
1060
1061    // ── T12: rule chain (transitivity) ────────────────────────────────────────
1062    #[test]
1063    fn t12_rule_chain() {
1064        let mut eng = engine();
1065        let id = eng.add_hypothesis(prop("cloudy"), 1.0, true);
1066        eng.add_rule(prop("rain"), vec![prop("cloudy")], 1.0);
1067        eng.add_rule(prop("wet_grass"), vec![prop("rain")], 1.0);
1068        eng.add_observation(prop("wet_grass"));
1069        let expls = eng.abduce();
1070        assert!(!expls.is_empty());
1071        assert!(expls[0].hypotheses.contains(&id));
1072    }
1073
1074    // ── T13: covers() method ──────────────────────────────────────────────────
1075    #[test]
1076    fn t13_covers() {
1077        let mut eng = engine();
1078        let id_r = eng.add_hypothesis(prop("rain"), 1.0, true);
1079        let _id_s = eng.add_hypothesis(prop("sun"), 1.0, true);
1080        eng.add_rule(prop("wet"), vec![prop("rain")], 1.0);
1081        eng.add_observation(prop("wet"));
1082        eng.add_observation(prop("sun"));
1083        let covered = eng.covers(&[id_r]);
1084        // Should cover "wet" via rule, but not "sun".
1085        assert!(covered.iter().any(|t| t.predicate == "wet"));
1086        assert!(!covered.iter().any(|t| t.predicate == "sun"));
1087    }
1088
1089    // ── T14: is_consistent — consistent set ───────────────────────────────────
1090    #[test]
1091    fn t14_consistent_set() {
1092        let mut eng = engine();
1093        let id1 = eng.add_hypothesis(prop("a"), 1.0, true);
1094        let id2 = eng.add_hypothesis(prop("b"), 1.0, true);
1095        assert!(eng.is_consistent(&[id1, id2]));
1096    }
1097
1098    // ── T15: is_consistent — inconsistent set (not_ prefix) ──────────────────
1099    #[test]
1100    fn t15_inconsistent_set_not_prefix() {
1101        let mut eng = engine();
1102        let id1 = eng.add_hypothesis(prop("rain"), 1.0, true);
1103        let id2 = eng.add_hypothesis(prop("not_rain"), 1.0, true);
1104        assert!(!eng.is_consistent(&[id1, id2]));
1105    }
1106
1107    // ── T16: is_consistent — inconsistent set (NOT: prefix) ──────────────────
1108    #[test]
1109    fn t16_inconsistent_not_colon_prefix() {
1110        let mut eng = engine();
1111        let id1 = eng.add_hypothesis(prop("rain"), 1.0, true);
1112        let id2 = eng.add_hypothesis(prop("NOT:rain"), 1.0, true);
1113        assert!(!eng.is_consistent(&[id1, id2]));
1114    }
1115
1116    // ── T17: apply_rules global ───────────────────────────────────────────────
1117    #[test]
1118    fn t17_apply_rules_global() {
1119        let mut eng = engine();
1120        eng.add_hypothesis(prop("raining"), 1.0, true);
1121        eng.add_rule(prop("puddles"), vec![prop("raining")], 1.0);
1122        let derived = eng.apply_rules();
1123        assert!(derived.iter().any(|t| t.predicate == "puddles"));
1124    }
1125
1126    // ── T18: reasoning_stats ──────────────────────────────────────────────────
1127    #[test]
1128    fn t18_reasoning_stats() {
1129        let mut eng = engine();
1130        eng.add_hypothesis(prop("x"), 1.0, true);
1131        eng.add_observation(prop("x"));
1132        eng.abduce();
1133        let stats = eng.reasoning_stats();
1134        assert_eq!(stats.abduce_calls, 1);
1135        assert!(stats.total_explanations_found > 0);
1136    }
1137
1138    // ── T19: empty observation list → vacuous explanation ─────────────────────
1139    #[test]
1140    fn t19_empty_observations_vacuous() {
1141        let mut eng = engine();
1142        eng.add_hypothesis(prop("a"), 1.0, true);
1143        let expls = eng.abduce();
1144        assert!(!expls.is_empty());
1145        assert_eq!(expls[0].completeness, 1.0);
1146        assert_eq!(expls[0].total_cost, 0.0);
1147    }
1148
1149    // ── T20: best_explanation returns the cheapest ────────────────────────────
1150    #[test]
1151    fn t20_best_explanation() {
1152        let mut eng = engine();
1153        eng.add_hypothesis(prop("cheap"), 0.5, true);
1154        eng.add_hypothesis(prop("expensive"), 10.0, true);
1155        eng.add_observation(prop("cheap"));
1156        let best = eng.best_explanation().expect("should find one");
1157        assert!((best.total_cost - 0.5).abs() < 1e-6);
1158    }
1159
1160    // ── T21: no hypothesis for observation → no explanation ───────────────────
1161    #[test]
1162    fn t21_unexplainable_observation() {
1163        let mut eng = engine();
1164        eng.add_hypothesis(prop("irrelevant"), 1.0, true);
1165        eng.add_observation(prop("some_fact"));
1166        let expls = eng.abduce();
1167        // There may be partial explanations but no complete one.
1168        let complete: Vec<_> = expls.iter().filter(|e| e.completeness >= 1.0).collect();
1169        assert!(complete.is_empty());
1170    }
1171
1172    // ── T22: history is bounded to 200 ────────────────────────────────────────
1173    #[test]
1174    fn t22_history_bounded() {
1175        let mut eng = engine();
1176        eng.add_hypothesis(prop("x"), 1.0, true);
1177        eng.add_observation(prop("x"));
1178        for _ in 0..250 {
1179            eng.abduce();
1180        }
1181        assert!(eng.history().len() <= 200);
1182    }
1183
1184    // ── T23: remove_hypothesis ────────────────────────────────────────────────
1185    #[test]
1186    fn t23_remove_hypothesis() {
1187        let mut eng = engine();
1188        let id = eng.add_hypothesis(prop("x"), 1.0, true);
1189        assert!(eng.remove_hypothesis(id));
1190        assert!(!eng.remove_hypothesis(id)); // second remove returns false
1191        assert!(eng.hypothesis(id).is_none());
1192    }
1193
1194    // ── T24: clear_observations ────────────────────────────────────────────────
1195    #[test]
1196    fn t24_clear_observations() {
1197        let mut eng = engine();
1198        eng.add_observation(prop("x"));
1199        eng.add_observation(prop("y"));
1200        eng.clear_observations();
1201        assert!(eng.observations().is_empty());
1202    }
1203
1204    // ── T25: fnv1a_64 deterministic ───────────────────────────────────────────
1205    #[test]
1206    fn t25_fnv1a_deterministic() {
1207        let a = fnv1a_64(b"hello");
1208        let b = fnv1a_64(b"hello");
1209        assert_eq!(a, b);
1210        assert_ne!(fnv1a_64(b"hello"), fnv1a_64(b"world"));
1211    }
1212
1213    // ── T26: set_fingerprint order-independent ────────────────────────────────
1214    #[test]
1215    fn t26_set_fingerprint_order_independent() {
1216        let fp1 = set_fingerprint(&[1, 2, 3]);
1217        let fp2 = set_fingerprint(&[3, 1, 2]);
1218        assert_eq!(fp1, fp2);
1219    }
1220
1221    // ── T27: AbrTerm fingerprint stability ────────────────────────────────────
1222    #[test]
1223    fn t27_term_fingerprint_stable() {
1224        let t = AbrTerm::new("parent", vec!["alice", "bob"]);
1225        assert_eq!(t.fingerprint(), t.fingerprint());
1226    }
1227
1228    // ── T28: AbrTerm matches with wildcard ────────────────────────────────────
1229    #[test]
1230    fn t28_term_wildcard_match() {
1231        let pattern = term("parent", &["alice", "_"]);
1232        let ground = term("parent", &["alice", "bob"]);
1233        assert!(pattern.matches(&ground));
1234    }
1235
1236    // ── T29: AbrTerm no-match on predicate ────────────────────────────────────
1237    #[test]
1238    fn t29_term_no_match_predicate() {
1239        let a = prop("rain");
1240        let b = prop("sun");
1241        assert!(!a.matches(&b));
1242    }
1243
1244    // ── T30: AbrRule construction ─────────────────────────────────────────────
1245    #[test]
1246    fn t30_rule_construction() {
1247        let r = AbrRule::new(prop("wet"), vec![prop("rain")], 0.9);
1248        assert_eq!(r.body.len(), 1);
1249        assert!((r.confidence - 0.9).abs() < 1e-9);
1250    }
1251
1252    // ── T31: AbrRule confidence clamped ───────────────────────────────────────
1253    #[test]
1254    fn t31_rule_confidence_clamped() {
1255        let r = AbrRule::new(prop("x"), vec![], 2.5);
1256        assert!((r.confidence - 1.0).abs() < 1e-9);
1257        let r2 = AbrRule::new(prop("x"), vec![], -1.0);
1258        assert!((r2.confidence - 0.0).abs() < 1e-9);
1259    }
1260
1261    // ── T32: hypothesis cost floored to 0 ─────────────────────────────────────
1262    #[test]
1263    fn t32_hypothesis_cost_floored() {
1264        let h = AbrHypothesis::new(1, prop("x"), -5.0, true);
1265        assert_eq!(h.cost, 0.0);
1266    }
1267
1268    // ── T33: multi-observation, single hypothesis ──────────────────────────────
1269    #[test]
1270    fn t33_single_hyp_multiple_observations_via_rules() {
1271        let mut eng = engine();
1272        let id = eng.add_hypothesis(prop("storm"), 1.0, true);
1273        eng.add_rule(prop("rain"), vec![prop("storm")], 1.0);
1274        eng.add_rule(prop("wind"), vec![prop("storm")], 1.0);
1275        eng.add_observation(prop("rain"));
1276        eng.add_observation(prop("wind"));
1277        let expls = eng.abduce();
1278        assert!(!expls.is_empty());
1279        assert_eq!(expls[0].completeness, 1.0);
1280        assert_eq!(expls[0].hypotheses.len(), 1);
1281        assert!(expls[0].hypotheses.contains(&id));
1282    }
1283
1284    // ── T34: is_complete method ────────────────────────────────────────────────
1285    #[test]
1286    fn t34_explanation_is_complete() {
1287        let expl = AbrExplanation {
1288            hypotheses: vec![1],
1289            covered: vec![prop("a"), prop("b")],
1290            total_cost: 2.0,
1291            completeness: 1.0,
1292        };
1293        assert!(expl.is_complete(2));
1294        assert!(!expl.is_complete(3));
1295    }
1296
1297    // ── T35: xorshift64 produces non-zero output ───────────────────────────────
1298    #[test]
1299    fn t35_xorshift64_nonzero() {
1300        let mut state = 12345u64;
1301        let v = abr_xorshift64(&mut state);
1302        assert_ne!(v, 0);
1303    }
1304
1305    // ── T36: multiple rules for the same head ─────────────────────────────────
1306    #[test]
1307    fn t36_multiple_rules_same_head() {
1308        let mut eng = engine();
1309        let id1 = eng.add_hypothesis(prop("heat"), 1.0, true);
1310        let id2 = eng.add_hypothesis(prop("cold"), 1.0, true);
1311        // Both independently can cause "fog"
1312        eng.add_rule(prop("fog"), vec![prop("heat")], 1.0);
1313        eng.add_rule(prop("fog"), vec![prop("cold")], 1.0);
1314        eng.add_observation(prop("fog"));
1315        let expls = eng.abduce();
1316        assert!(!expls.is_empty());
1317        // Both single-hypothesis explanations should be found.
1318        let hyp_sets: Vec<_> = expls.iter().map(|e| e.hypotheses.clone()).collect();
1319        let has_heat = hyp_sets.iter().any(|s| s == &[id1]);
1320        let has_cold = hyp_sets.iter().any(|s| s == &[id2]);
1321        assert!(has_heat || has_cold);
1322    }
1323
1324    // ── T37: max_hypothesis_set_size respected ────────────────────────────────
1325    #[test]
1326    fn t37_max_set_size() {
1327        let mut eng = AbductiveReasoningEngine::new(AbrEngineConfig {
1328            max_hypothesis_set_size: 1,
1329            max_explanations: 10,
1330            cost_function: AbrCostFunction::SumCost,
1331            prefer_minimal: true,
1332            max_search_nodes: 10_000,
1333        });
1334        // Force requiring 2 hypotheses.
1335        eng.add_hypothesis(prop("a"), 1.0, true);
1336        eng.add_hypothesis(prop("b"), 1.0, true);
1337        eng.add_observation(prop("a"));
1338        eng.add_observation(prop("b"));
1339        let expls = eng.abduce();
1340        // With max_size=1, no complete explanation should be found.
1341        let complete: Vec<_> = expls.iter().filter(|e| e.completeness >= 1.0).collect();
1342        assert!(complete.is_empty());
1343    }
1344
1345    // ── T38: hypothesis_ids returns all registered ids ─────────────────────────
1346    #[test]
1347    fn t38_hypothesis_ids() {
1348        let mut eng = engine();
1349        let id1 = eng.add_hypothesis(prop("a"), 1.0, true);
1350        let id2 = eng.add_hypothesis(prop("b"), 1.0, true);
1351        let ids = eng.hypothesis_ids();
1352        assert!(ids.contains(&id1));
1353        assert!(ids.contains(&id2));
1354    }
1355
1356    // ── T39: rules() accessor ────────────────────────────────────────────────
1357    #[test]
1358    fn t39_rules_accessor() {
1359        let mut eng = engine();
1360        eng.add_rule(prop("x"), vec![], 1.0);
1361        assert_eq!(eng.rules().len(), 1);
1362    }
1363
1364    // ── T40: observations() accessor ─────────────────────────────────────────
1365    #[test]
1366    fn t40_observations_accessor() {
1367        let mut eng = engine();
1368        eng.add_observation(prop("a"));
1369        eng.add_observation(prop("b"));
1370        assert_eq!(eng.observations().len(), 2);
1371    }
1372
1373    // ── T41: default_engine constructor ───────────────────────────────────────
1374    #[test]
1375    fn t41_default_engine() {
1376        let eng = AbductiveReasoningEngine::default_engine();
1377        assert_eq!(eng.config.max_explanations, 10);
1378        assert_eq!(eng.config.max_hypothesis_set_size, 8);
1379    }
1380
1381    // ── T42: AbrTerm canonical form ───────────────────────────────────────────
1382    #[test]
1383    fn t42_term_canonical() {
1384        let t = AbrTerm::new("p", vec!["a", "b"]);
1385        assert_eq!(t.canonical(), "p(a,b)");
1386        let t2 = prop("q");
1387        assert_eq!(t2.canonical(), "q");
1388    }
1389
1390    // ── T43: set_config updates config ────────────────────────────────────────
1391    #[test]
1392    fn t43_set_config() {
1393        let mut eng = engine();
1394        eng.set_config(AbrEngineConfig {
1395            max_explanations: 3,
1396            ..AbrEngineConfig::default()
1397        });
1398        assert_eq!(eng.config.max_explanations, 3);
1399    }
1400
1401    // ── T44: history not polluted by empty abduce ─────────────────────────────
1402    #[test]
1403    fn t44_history_grows_on_abduce() {
1404        let mut eng = engine();
1405        eng.add_hypothesis(prop("x"), 1.0, true);
1406        eng.add_observation(prop("x"));
1407        assert_eq!(eng.history().len(), 0);
1408        eng.abduce();
1409        assert_eq!(eng.history().len(), 1);
1410    }
1411
1412    // ── T45: contradicts — same args required ─────────────────────────────────
1413    #[test]
1414    fn t45_contradicts_same_args() {
1415        let eng = engine();
1416        let a = term("wet", &["lawn"]);
1417        let b = term("not_wet", &["lawn"]);
1418        let c = term("not_wet", &["floor"]); // different arg
1419        assert!(eng.contradicts(&a, &b));
1420        assert!(!eng.contradicts(&a, &c));
1421    }
1422
1423    // ── T46: best_explanation on no observations returns vacuous ──────────────
1424    #[test]
1425    fn t46_best_explanation_no_obs() {
1426        let mut eng = engine();
1427        eng.add_hypothesis(prop("x"), 1.0, true);
1428        let best = eng.best_explanation().expect("vacuous explanation");
1429        assert_eq!(best.completeness, 1.0);
1430    }
1431
1432    // ── T47: explanations deduplicated ────────────────────────────────────────
1433    #[test]
1434    fn t47_deduplication() {
1435        let mut eng = engine();
1436        let id = eng.add_hypothesis(prop("r"), 1.0, true);
1437        eng.add_observation(prop("r"));
1438        let expls = eng.abduce();
1439        // Only one explanation for this trivial case.
1440        let count = expls.iter().filter(|e| e.hypotheses == vec![id]).count();
1441        assert_eq!(count, 1);
1442    }
1443
1444    // ── T48: cost f64::INFINITY if no explanations ────────────────────────────
1445    #[test]
1446    fn t48_best_cost_infinity_if_none() {
1447        let eng = engine();
1448        assert_eq!(eng.best_cost_ever, f64::INFINITY);
1449    }
1450
1451    // ── T49: AbrEngineConfig prefer_minimal = false ───────────────────────────
1452    #[test]
1453    fn t49_prefer_minimal_false() {
1454        let mut eng = AbductiveReasoningEngine::new(AbrEngineConfig {
1455            prefer_minimal: false,
1456            max_explanations: 5,
1457            max_hypothesis_set_size: 4,
1458            cost_function: AbrCostFunction::SumCost,
1459            max_search_nodes: 50_000,
1460        });
1461        eng.add_hypothesis(prop("a"), 1.0, true);
1462        eng.add_observation(prop("a"));
1463        let expls = eng.abduce();
1464        assert!(!expls.is_empty());
1465    }
1466
1467    // ── T50: apply_rules fixed-point (no infinite loop) ───────────────────────
1468    #[test]
1469    fn t50_apply_rules_no_cycle() {
1470        let mut eng = engine();
1471        eng.add_hypothesis(prop("x"), 1.0, true);
1472        // Self-loop rule: x → x (already known, should not loop).
1473        eng.add_rule(prop("x"), vec![prop("x")], 1.0);
1474        let derived = eng.apply_rules();
1475        // x is seed, not derived; derived list should be empty or contain only new facts.
1476        assert!(!derived.iter().any(|t| t.predicate == "x"));
1477    }
1478
1479    // ── T51: conjunctive rule body ────────────────────────────────────────────
1480    #[test]
1481    fn t51_conjunctive_rule_body() {
1482        let mut eng = engine();
1483        let id1 = eng.add_hypothesis(prop("a"), 1.0, true);
1484        let id2 = eng.add_hypothesis(prop("b"), 1.0, true);
1485        // Rule: a ∧ b → c
1486        eng.add_rule(prop("c"), vec![prop("a"), prop("b")], 1.0);
1487        eng.add_observation(prop("c"));
1488        let expls = eng.abduce();
1489        assert!(!expls.is_empty());
1490        let best = &expls[0];
1491        assert!(best.hypotheses.contains(&id1));
1492        assert!(best.hypotheses.contains(&id2));
1493    }
1494
1495    // ── T52: partially covered explanation has correct completeness ───────────
1496    #[test]
1497    fn t52_partial_completeness_value() {
1498        let expl = AbrExplanation {
1499            hypotheses: vec![1],
1500            covered: vec![prop("a")],
1501            total_cost: 1.0,
1502            completeness: 0.5,
1503        };
1504        assert!((expl.completeness - 0.5).abs() < 1e-9);
1505        assert!(!expl.is_complete(2));
1506    }
1507
1508    // ── T53: multiple complete explanations returned ──────────────────────────
1509    #[test]
1510    fn t53_multiple_complete_explanations() {
1511        let mut eng = AbductiveReasoningEngine::new(AbrEngineConfig {
1512            prefer_minimal: false,
1513            max_explanations: 20,
1514            max_hypothesis_set_size: 6,
1515            cost_function: AbrCostFunction::SumCost,
1516            max_search_nodes: 50_000,
1517        });
1518        let id1 = eng.add_hypothesis(prop("cause_a"), 1.0, true);
1519        let id2 = eng.add_hypothesis(prop("cause_b"), 1.0, true);
1520        eng.add_rule(prop("effect"), vec![prop("cause_a")], 1.0);
1521        eng.add_rule(prop("effect"), vec![prop("cause_b")], 1.0);
1522        eng.add_observation(prop("effect"));
1523        let expls = eng.abduce();
1524        let hyp_sets: Vec<_> = expls.iter().map(|e| e.hypotheses.clone()).collect();
1525        let has_a = hyp_sets.iter().any(|s| s == &[id1]);
1526        let has_b = hyp_sets.iter().any(|s| s == &[id2]);
1527        assert!(has_a && has_b);
1528    }
1529
1530    // ── T54: AbrReasoningStats fields ─────────────────────────────────────────
1531    #[test]
1532    fn t54_reasoning_stats_fields() {
1533        let mut eng = engine();
1534        let _id = eng.add_hypothesis(prop("x"), 1.0, true);
1535        eng.add_rule(prop("y"), vec![prop("x")], 1.0);
1536        eng.add_observation(prop("x"));
1537        eng.abduce();
1538        let s = eng.reasoning_stats();
1539        assert_eq!(s.n_hypotheses, 1);
1540        assert_eq!(s.n_rules, 1);
1541        assert_eq!(s.n_observations, 1);
1542        assert_eq!(s.abduce_calls, 1);
1543        assert!(s.total_nodes_explored > 0);
1544    }
1545
1546    // ── T55: AbrExplanationRecord stored ──────────────────────────────────────
1547    #[test]
1548    fn t55_explanation_record_stored() {
1549        let mut eng = engine();
1550        eng.add_hypothesis(prop("x"), 1.0, true);
1551        eng.add_observation(prop("x"));
1552        eng.abduce();
1553        let rec = eng.history().back().expect("should have record");
1554        assert_eq!(rec.n_observations, 1);
1555        assert!(rec.best_cost < f64::INFINITY);
1556    }
1557
1558    // ── T56: prefer_minimal prunes worse explanations ─────────────────────────
1559    #[test]
1560    fn t56_prefer_minimal_prunes_costlier() {
1561        let mut eng = engine(); // prefer_minimal = true
1562        let _id_cheap = eng.add_hypothesis(prop("obs"), 1.0, true);
1563        let _id_expensive = eng.add_hypothesis(prop("obs_alt"), 100.0, true);
1564        // Both explain the same observation via rules.
1565        eng.add_rule(prop("obs"), vec![prop("obs_alt")], 1.0);
1566        eng.add_observation(prop("obs"));
1567        let expls = eng.abduce();
1568        if expls.len() > 1 {
1569            // All returned explanations should have cost ≤ best + epsilon.
1570            let best = expls[0].total_cost;
1571            for e in &expls {
1572                assert!(e.total_cost <= best + 1e-6);
1573            }
1574        }
1575    }
1576
1577    // ── T57: set_fingerprint empty set ────────────────────────────────────────
1578    #[test]
1579    fn t57_fingerprint_empty() {
1580        let fp = set_fingerprint(&[]);
1581        // FNV-1a on empty input is the offset basis.
1582        assert_eq!(fp, fnv1a_64(b""));
1583    }
1584
1585    // ── T58: term with args produces different fingerprint than prop ───────────
1586    #[test]
1587    fn t58_term_args_vs_prop_fingerprint() {
1588        let a = prop("p");
1589        let b = AbrTerm::new("p", vec!["x"]);
1590        assert_ne!(a.fingerprint(), b.fingerprint());
1591    }
1592
1593    // ── T59: history record n_hypotheses_tried reflects search work ───────────
1594    #[test]
1595    fn t59_history_nodes_tried() {
1596        let mut eng = engine();
1597        eng.add_hypothesis(prop("a"), 1.0, true);
1598        eng.add_hypothesis(prop("b"), 1.0, true);
1599        eng.add_observation(prop("a"));
1600        eng.add_observation(prop("b"));
1601        eng.abduce();
1602        let rec = eng.history().back().expect("record");
1603        // At least one node was explored.
1604        assert!(rec.n_hypotheses_tried > 0);
1605    }
1606
1607    // ── T60: cost 0 hypothesis is valid ───────────────────────────────────────
1608    #[test]
1609    fn t60_zero_cost_hypothesis() {
1610        let mut eng = engine();
1611        let id = eng.add_hypothesis(prop("free_fact"), 0.0, true);
1612        eng.add_observation(prop("free_fact"));
1613        let expls = eng.abduce();
1614        assert!(!expls.is_empty());
1615        assert_eq!(expls[0].total_cost, 0.0);
1616        assert!(expls[0].hypotheses.contains(&id));
1617    }
1618
1619    // ── T61: best_cost_ever updated correctly ─────────────────────────────────
1620    #[test]
1621    fn t61_best_cost_ever_updated() {
1622        let mut eng = engine();
1623        eng.add_hypothesis(prop("x"), 3.0, true);
1624        eng.add_observation(prop("x"));
1625        eng.abduce();
1626        assert!((eng.best_cost_ever - 3.0).abs() < 1e-6);
1627
1628        // Second run with cheaper hypothesis.
1629        eng.clear_observations();
1630        let id2 = eng.add_hypothesis(prop("y"), 1.0, true);
1631        eng.add_observation(prop("y"));
1632        eng.abduce();
1633        // best_cost_ever should now be 1.0.
1634        assert!((eng.best_cost_ever - 1.0).abs() < 1e-6);
1635        let _ = id2;
1636    }
1637
1638    // ── T62: AbrRule body can be empty (fact rule) ────────────────────────────
1639    #[test]
1640    fn t62_empty_body_rule() {
1641        let mut eng = engine();
1642        // Rule with no body: always derives "always_true".
1643        eng.add_rule(prop("always_true"), vec![], 1.0);
1644        eng.add_observation(prop("always_true"));
1645        // Even with no hypotheses, the rule should fire and cover.
1646        let derived = eng.apply_rules_for_set(&[]);
1647        assert!(derived.iter().any(|t| t.predicate == "always_true"));
1648    }
1649
1650    // ── T63: abduce total_nodes_explored cumulative ───────────────────────────
1651    #[test]
1652    fn t63_total_nodes_explored_cumulative() {
1653        let mut eng = engine();
1654        eng.add_hypothesis(prop("x"), 1.0, true);
1655        eng.add_observation(prop("x"));
1656        eng.abduce();
1657        let n1 = eng.total_nodes_explored;
1658        eng.abduce();
1659        let n2 = eng.total_nodes_explored;
1660        assert!(n2 >= n1);
1661    }
1662
1663    // ── T64: max_explanations limit respected ─────────────────────────────────
1664    #[test]
1665    fn t64_max_explanations_limit() {
1666        let mut eng = AbductiveReasoningEngine::new(AbrEngineConfig {
1667            max_explanations: 2,
1668            max_hypothesis_set_size: 6,
1669            cost_function: AbrCostFunction::SumCost,
1670            prefer_minimal: false,
1671            max_search_nodes: 100_000,
1672        });
1673        // Create many single-hypothesis explanations via independent rules.
1674        for i in 0..10 {
1675            let p = format!("cause{}", i);
1676            eng.add_hypothesis(AbrTerm::prop(p.clone()), 1.0, true);
1677            eng.add_rule(prop("effect"), vec![AbrTerm::prop(p)], 1.0);
1678        }
1679        eng.add_observation(prop("effect"));
1680        let expls = eng.abduce();
1681        assert!(expls.len() <= 2);
1682    }
1683}