Skip to main content

ipfrs_tensorlogic/
probabilistic_logic_network.rs

1//! Probabilistic Logic Network (PLN) — uncertain reasoning combining
2//! probability theory with logic.
3//!
4//! PLN represents uncertain beliefs as *indefinite truth values* — (strength,
5//! confidence) pairs — and propagates uncertainty through a rich set of
6//! inference rules (Deduction, Induction, Abduction, Revision, Conjunction,
7//! Disjunction, Negation, Modus Ponens).  The atom/link hypergraph follows the
8//! OpenCog AtomSpace model.
9//!
10//! # Quick Example
11//!
12//! ```
13//! use ipfrs_tensorlogic::{
14//!     ProbabilisticLogicNetwork, PlnAtom, PlnLink, TruthValue,
15//!     AtomType, LinkType, PlnInferenceRule, PlnConfig,
16//! };
17//!
18//! let mut pln = ProbabilisticLogicNetwork::new(PlnConfig::default());
19//!
20//! // Add concept nodes
21//! let tv_high = TruthValue::new(0.9, 0.8);
22//! let cat = PlnAtom::new("cat", "Cat", tv_high, AtomType::Node("ConceptNode".into()));
23//! pln.add_atom(cat).expect("example: should succeed in docs");
24//!
25//! let tv_med = TruthValue::new(0.7, 0.6);
26//! let animal = PlnAtom::new("animal", "Animal", tv_med, AtomType::Node("ConceptNode".into()));
27//! pln.add_atom(animal).expect("example: should succeed in docs");
28//!
29//! // Add inheritance link: Cat → Animal
30//! let link = PlnLink::new(
31//!     "cat_animal",
32//!     LinkType::Inheritance,
33//!     vec!["cat".into(), "animal".into()],
34//!     TruthValue::new(0.95, 0.9),
35//! );
36//! pln.add_link(link).expect("example: should succeed in docs");
37//!
38//! let tv = pln.get_tv("cat_animal").expect("example: should succeed in docs");
39//! assert!(tv.strength > 0.9);
40//! ```
41
42use std::collections::{HashMap, HashSet, VecDeque};
43use std::fmt;
44
45// ── Truth Value ──────────────────────────────────────────────────────────────
46
47/// PLN indefinite truth value: (strength, confidence).
48///
49/// * `strength` — probability estimate ∈ \[0, 1\]
50/// * `confidence` — certainty in the strength estimate ∈ \[0, 1\]
51///   (0 = no information, 1 = complete certainty)
52#[derive(Debug, Clone, Copy, PartialEq)]
53pub struct TruthValue {
54    /// Probability estimate.
55    pub strength: f64,
56    /// Confidence in the strength estimate.
57    pub confidence: f64,
58}
59
60impl TruthValue {
61    /// Create a new truth value, clamping both components to \[0, 1\].
62    #[inline]
63    pub fn new(strength: f64, confidence: f64) -> Self {
64        Self {
65            strength: strength.clamp(0.0, 1.0),
66            confidence: confidence.clamp(0.0, 1.0),
67        }
68    }
69
70    /// Return the *unknown* truth value (strength = 0.5, confidence = 0).
71    #[inline]
72    pub fn unknown() -> Self {
73        Self {
74            strength: 0.5,
75            confidence: 0.0,
76        }
77    }
78
79    /// Return the *true* truth value (strength = 1, confidence = 1).
80    #[inline]
81    pub fn certain_true() -> Self {
82        Self {
83            strength: 1.0,
84            confidence: 1.0,
85        }
86    }
87
88    /// Return the *false* truth value (strength = 0, confidence = 1).
89    #[inline]
90    pub fn certain_false() -> Self {
91        Self {
92            strength: 0.0,
93            confidence: 1.0,
94        }
95    }
96
97    /// Effective sample count `n = c / (1 − c)`.
98    /// Returns `f64::INFINITY` when `confidence == 1`.
99    #[inline]
100    pub fn count(self) -> f64 {
101        let denom = 1.0 - self.confidence;
102        if denom <= 0.0 {
103            f64::INFINITY
104        } else {
105            self.confidence / denom
106        }
107    }
108
109    /// PLN negation: `NOT A`.
110    #[inline]
111    pub fn negate(self) -> Self {
112        Self::new(1.0 - self.strength, self.confidence)
113    }
114
115    /// PLN conjunction: `A AND B`.
116    #[inline]
117    pub fn conjunction(self, other: Self) -> Self {
118        Self::new(
119            self.strength * other.strength,
120            self.confidence.min(other.confidence),
121        )
122    }
123
124    /// PLN disjunction: `A OR B`.
125    #[inline]
126    pub fn disjunction(self, other: Self) -> Self {
127        Self::new(
128            self.strength + other.strength - self.strength * other.strength,
129            self.confidence.min(other.confidence),
130        )
131    }
132
133    /// PLN revision: merge two independent estimates of the same proposition.
134    ///
135    /// Uses weighted average by effective sample count:
136    /// ```text
137    /// s_rev = (s1 * n1 + s2 * n2) / (n1 + n2)
138    /// c_rev = (n1 + n2) / (n1 + n2 + 1)
139    /// ```
140    pub fn revise(self, other: Self) -> Self {
141        let n1 = self.count();
142        let n2 = other.count();
143
144        // Handle infinite counts (certain beliefs)
145        if n1.is_infinite() && n2.is_infinite() {
146            // Both certain — average strengths with full confidence
147            return Self::new((self.strength + other.strength) / 2.0, 1.0);
148        }
149        if n1.is_infinite() {
150            return self;
151        }
152        if n2.is_infinite() {
153            return other;
154        }
155
156        let n_total = n1 + n2;
157        if n_total <= 0.0 {
158            return Self::unknown();
159        }
160        let s_rev = (self.strength * n1 + other.strength * n2) / n_total;
161        let c_rev = n_total / (n_total + 1.0);
162        Self::new(s_rev, c_rev)
163    }
164
165    /// PLN deduction: given A→B and B→C, infer A→C.
166    ///
167    /// ```text
168    /// s_AC = s_AB * s_BC + (1 − s_AB) * (s_C − s_B * s_BC) / (1 − s_B)
169    /// c_AC = c_AB * c_BC * min(1, c_AB * c_BC)
170    /// ```
171    pub fn deduction(ab: Self, bc: Self, b: Self, c: Self) -> Self {
172        let s_ab = ab.strength;
173        let s_bc = bc.strength;
174        let s_b = b.strength;
175        let s_c = c.strength;
176
177        let s_ac = if (1.0 - s_b).abs() < 1e-12 {
178            // s_B ≈ 1 — degenerate; fall back to s_BC
179            s_bc
180        } else {
181            let correction = (s_c - s_b * s_bc) / (1.0 - s_b);
182            (s_ab * s_bc + (1.0 - s_ab) * correction).clamp(0.0, 1.0)
183        };
184
185        let product = ab.confidence * bc.confidence;
186        let c_ac = (product * product.min(1.0)).clamp(0.0, 1.0);
187
188        Self::new(s_ac, c_ac)
189    }
190
191    /// PLN induction: given A→B and A→C, infer B→C.
192    ///
193    /// Simplified formula:
194    /// ```text
195    /// s_BC = s_AC / max(s_AB, 0.01), clamped [0, 1]
196    /// c_BC = c_AB * c_AC * min(1, c_AB * c_AC)
197    /// ```
198    pub fn induction(ab: Self, ac: Self) -> Self {
199        let s_bc = (ac.strength / ab.strength.max(0.01)).clamp(0.0, 1.0);
200        let product = ab.confidence * ac.confidence;
201        let c_bc = (product * product.min(1.0)).clamp(0.0, 1.0);
202        Self::new(s_bc, c_bc)
203    }
204
205    /// PLN abduction: given B→A and C→A, infer B→C.
206    ///
207    /// Dual of induction (swap roles of B and C):
208    /// ```text
209    /// s_BC = s_BA * s_CA / max(s_A, 0.01), clamped [0, 1]  (simplified)
210    /// c_BC = c_BA * c_CA * min(1, c_BA * c_CA)
211    /// ```
212    pub fn abduction(ba: Self, ca: Self) -> Self {
213        let s_bc = (ba.strength * ca.strength).clamp(0.0, 1.0);
214        let product = ba.confidence * ca.confidence;
215        let c_bc = (product * product.min(1.0)).clamp(0.0, 1.0);
216        Self::new(s_bc, c_bc)
217    }
218
219    /// PLN Modus Ponens: given A (premise) and A→B (implication), infer B.
220    ///
221    /// ```text
222    /// s_B  = s_A * s_AB + (1 − s_A) * (1 − s_AB) * 0.5   (mixture)
223    /// c_B  = c_A * c_AB
224    /// ```
225    pub fn modus_ponens(a: Self, ab: Self) -> Self {
226        let s_b = (a.strength * ab.strength + (1.0 - a.strength) * (1.0 - ab.strength) * 0.5)
227            .clamp(0.0, 1.0);
228        let c_b = (a.confidence * ab.confidence).clamp(0.0, 1.0);
229        Self::new(s_b, c_b)
230    }
231}
232
233impl fmt::Display for TruthValue {
234    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
235        write!(f, "TV(s={:.4}, c={:.4})", self.strength, self.confidence)
236    }
237}
238
239impl Default for TruthValue {
240    fn default() -> Self {
241        Self::unknown()
242    }
243}
244
245// ── Atom types ───────────────────────────────────────────────────────────────
246
247/// Type of a PLN link.
248#[derive(Debug, Clone, PartialEq, Eq, Hash)]
249pub enum LinkType {
250    /// Inheritance (subtype/instance-of relationship).
251    Inheritance,
252    /// Similarity (symmetric relation).
253    Similarity,
254    /// Logical implication.
255    Implication,
256    /// Logical equivalence.
257    Equivalence,
258    /// Logical AND.
259    AND,
260    /// Logical OR.
261    OR,
262    /// Logical NOT.
263    NOT,
264    /// Predicate evaluation (wraps a predicate application).
265    Evaluation,
266}
267
268/// Type of a PLN atom.
269#[derive(Debug, Clone, PartialEq, Eq)]
270pub enum AtomType {
271    /// A node atom (e.g., `"ConceptNode"`, `"PredicateNode"`).
272    Node(String),
273    /// A link atom of the given link type.
274    Link(LinkType),
275}
276
277// ── Core structs ─────────────────────────────────────────────────────────────
278
279/// A PLN atom (node or link wrapper).
280#[derive(Debug, Clone)]
281pub struct PlnAtom {
282    /// Unique identifier.
283    pub id: String,
284    /// Human-readable name.
285    pub name: String,
286    /// Associated truth value.
287    pub tv: TruthValue,
288    /// Atom type.
289    pub atom_type: AtomType,
290}
291
292impl PlnAtom {
293    /// Create a new atom.
294    pub fn new(
295        id: impl Into<String>,
296        name: impl Into<String>,
297        tv: TruthValue,
298        atom_type: AtomType,
299    ) -> Self {
300        Self {
301            id: id.into(),
302            name: name.into(),
303            tv,
304            atom_type,
305        }
306    }
307}
308
309/// A PLN link (hyperedge connecting one or more source atoms).
310#[derive(Debug, Clone)]
311pub struct PlnLink {
312    /// Unique identifier.
313    pub id: String,
314    /// Link type.
315    pub link_type: LinkType,
316    /// Ordered list of source atom IDs.
317    pub source_ids: Vec<String>,
318    /// Associated truth value.
319    pub tv: TruthValue,
320}
321
322impl PlnLink {
323    /// Create a new link.
324    pub fn new(
325        id: impl Into<String>,
326        link_type: LinkType,
327        source_ids: Vec<String>,
328        tv: TruthValue,
329    ) -> Self {
330        Self {
331            id: id.into(),
332            link_type,
333            source_ids,
334            tv,
335        }
336    }
337}
338
339// ── Inference rules ──────────────────────────────────────────────────────────
340
341/// PLN inference rules.
342#[derive(Debug, Clone, PartialEq, Eq, Hash)]
343pub enum PlnInferenceRule {
344    /// A→B, B→C ⊢ A→C
345    Deduction,
346    /// A→B, A→C ⊢ B→C
347    Induction,
348    /// B→A, C→A ⊢ B→C
349    Abduction,
350    /// Merge two independent estimates of the same proposition.
351    Revision,
352    /// A AND B
353    Conjunction,
354    /// A OR B
355    Disjunction,
356    /// NOT A
357    Negation,
358    /// A, A→B ⊢ B
359    ModusPonens,
360}
361
362impl fmt::Display for PlnInferenceRule {
363    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
364        let s = match self {
365            Self::Deduction => "Deduction",
366            Self::Induction => "Induction",
367            Self::Abduction => "Abduction",
368            Self::Revision => "Revision",
369            Self::Conjunction => "Conjunction",
370            Self::Disjunction => "Disjunction",
371            Self::Negation => "Negation",
372            Self::ModusPonens => "ModusPonens",
373        };
374        f.write_str(s)
375    }
376}
377
378/// The result of a single PLN inference step.
379#[derive(Debug, Clone)]
380pub struct PlnInferenceResult {
381    /// ID of the concluded atom (may be new or existing).
382    pub conclusion_id: String,
383    /// The derived truth value for the conclusion.
384    pub tv: TruthValue,
385    /// Which rule was used.
386    pub rule_used: PlnInferenceRule,
387    /// IDs of the premises consumed.
388    pub premise_ids: Vec<String>,
389    /// Change in confidence relative to any prior belief (0 if new).
390    pub confidence_delta: f64,
391}
392
393// ── Configuration & statistics ────────────────────────────────────────────────
394
395/// Configuration for a `ProbabilisticLogicNetwork`.
396#[derive(Debug, Clone)]
397pub struct PlnConfig {
398    /// Maximum number of atoms (nodes + links) allowed.
399    pub max_atoms: usize,
400    /// Maximum inference chain depth for `find_chains`.
401    pub inference_depth: u8,
402    /// Minimum confidence required for an inference to be accepted.
403    pub min_confidence_threshold: f64,
404    /// If `true`, applying an existing inference revises (merges) the prior TV.
405    /// If `false`, the new TV silently replaces the old one.
406    pub enable_revision: bool,
407}
408
409impl Default for PlnConfig {
410    fn default() -> Self {
411        Self {
412            max_atoms: 100_000,
413            inference_depth: 6,
414            min_confidence_threshold: 0.01,
415            enable_revision: true,
416        }
417    }
418}
419
420/// Runtime statistics for a `ProbabilisticLogicNetwork`.
421#[derive(Debug, Clone, Default)]
422pub struct PlnStats {
423    /// Number of atoms currently stored.
424    pub atom_count: usize,
425    /// Number of links currently stored.
426    pub link_count: usize,
427    /// Total inference operations performed.
428    pub inferences_performed: u64,
429    /// Average confidence across all atoms.
430    pub avg_confidence: f64,
431}
432
433// ── Error type ────────────────────────────────────────────────────────────────
434
435/// Errors produced by the PLN engine.
436#[derive(Debug, Clone, PartialEq)]
437pub enum PlnError {
438    /// An atom with the given ID was not found.
439    AtomNotFound(String),
440    /// The derived confidence is below the configured threshold.
441    InsufficientConfidence(f64),
442    /// Cycle detected while tracing inference chains.
443    CyclicInference(Vec<String>),
444    /// The rule cannot be applied with the supplied premises.
445    InvalidRule(String),
446    /// The atom store has reached `max_atoms`.
447    MaxAtomsExceeded,
448}
449
450impl fmt::Display for PlnError {
451    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
452        match self {
453            Self::AtomNotFound(id) => write!(f, "atom not found: {id}"),
454            Self::InsufficientConfidence(c) => {
455                write!(f, "confidence {c:.4} below threshold")
456            }
457            Self::CyclicInference(path) => write!(f, "cyclic inference: {path:?}"),
458            Self::InvalidRule(msg) => write!(f, "invalid rule: {msg}"),
459            Self::MaxAtomsExceeded => write!(f, "max atom limit exceeded"),
460        }
461    }
462}
463
464impl std::error::Error for PlnError {}
465
466// ── Internal adjacency helpers ────────────────────────────────────────────────
467
468/// Lightweight adjacency record stored per source atom.
469#[derive(Debug, Clone)]
470struct OutEdge {
471    /// The link atom ID.
472    link_id: String,
473    /// The other end of the edge (second source for binary links, etc.).
474    targets: Vec<String>,
475}
476
477// ── Main engine ───────────────────────────────────────────────────────────────
478
479/// Probabilistic Logic Network engine.
480///
481/// Maintains a hypergraph of atoms and links, executes PLN inference rules,
482/// and provides chain-search utilities.
483pub struct ProbabilisticLogicNetwork {
484    /// All atoms, keyed by ID.
485    atoms: HashMap<String, PlnAtom>,
486    /// All links, keyed by ID.
487    links: HashMap<String, PlnLink>,
488    /// Adjacency list: source_id → list of outgoing edges.
489    adjacency: HashMap<String, Vec<OutEdge>>,
490    /// Engine configuration.
491    config: PlnConfig,
492    /// Running statistics.
493    inferences_performed: u64,
494}
495
496impl ProbabilisticLogicNetwork {
497    /// Create a new PLN engine with the given configuration.
498    pub fn new(config: PlnConfig) -> Self {
499        Self {
500            atoms: HashMap::new(),
501            links: HashMap::new(),
502            adjacency: HashMap::new(),
503            config,
504            inferences_performed: 0,
505        }
506    }
507
508    // ── Mutation ──────────────────────────────────────────────────────────────
509
510    /// Add an atom to the network.
511    ///
512    /// Returns `Err(PlnError::MaxAtomsExceeded)` when the atom limit is reached.
513    /// Silently replaces any existing atom with the same ID.
514    pub fn add_atom(&mut self, atom: PlnAtom) -> Result<(), PlnError> {
515        let total = self.atoms.len() + self.links.len();
516        if !self.atoms.contains_key(&atom.id) && total >= self.config.max_atoms {
517            return Err(PlnError::MaxAtomsExceeded);
518        }
519        self.atoms.insert(atom.id.clone(), atom);
520        Ok(())
521    }
522
523    /// Add a link to the network.
524    ///
525    /// The link is also registered as an atom so its TV can be queried via
526    /// `get_tv`.  All `source_ids` must already exist as atoms or links.
527    pub fn add_link(&mut self, link: PlnLink) -> Result<(), PlnError> {
528        // Validate sources
529        for src in &link.source_ids {
530            if !self.atoms.contains_key(src) && !self.links.contains_key(src) {
531                return Err(PlnError::AtomNotFound(src.clone()));
532            }
533        }
534
535        let total = self.atoms.len() + self.links.len();
536        let is_new = !self.links.contains_key(&link.id);
537        if is_new && total >= self.config.max_atoms {
538            return Err(PlnError::MaxAtomsExceeded);
539        }
540
541        // Build adjacency for binary directed links (first source → second source)
542        if link.source_ids.len() >= 2 {
543            let src = link.source_ids[0].clone();
544            let tgts: Vec<String> = link.source_ids[1..].to_vec();
545            let edge = OutEdge {
546                link_id: link.id.clone(),
547                targets: tgts,
548            };
549            self.adjacency.entry(src).or_default().push(edge);
550        }
551
552        self.links.insert(link.id.clone(), link);
553        Ok(())
554    }
555
556    // ── Query ─────────────────────────────────────────────────────────────────
557
558    /// Return the truth value of an atom or link by ID.
559    pub fn get_tv(&self, id: &str) -> Result<TruthValue, PlnError> {
560        if let Some(a) = self.atoms.get(id) {
561            return Ok(a.tv);
562        }
563        if let Some(l) = self.links.get(id) {
564            return Ok(l.tv);
565        }
566        Err(PlnError::AtomNotFound(id.to_string()))
567    }
568
569    // ── Inference ─────────────────────────────────────────────────────────────
570
571    /// Apply an inference rule to the given premises.
572    ///
573    /// Validates that the premises exist and that the rule can be applied,
574    /// then returns a `PlnInferenceResult` describing the conclusion.  The
575    /// caller decides whether to materialise the conclusion via
576    /// `apply_inference`.
577    pub fn infer(
578        &mut self,
579        rule: PlnInferenceRule,
580        premise_ids: Vec<String>,
581    ) -> Result<PlnInferenceResult, PlnError> {
582        let tv = self.apply_rule(&rule, &premise_ids)?;
583
584        if tv.confidence < self.config.min_confidence_threshold {
585            return Err(PlnError::InsufficientConfidence(tv.confidence));
586        }
587
588        // Derive a conclusion ID deterministically from rule + premises
589        let conclusion_id = conclusion_id_for(&rule, &premise_ids);
590
591        // Compute confidence_delta relative to any prior belief
592        let prior_confidence = self
593            .atoms
594            .get(&conclusion_id)
595            .map(|a| a.tv.confidence)
596            .or_else(|| self.links.get(&conclusion_id).map(|l| l.tv.confidence))
597            .unwrap_or(0.0);
598
599        let confidence_delta = tv.confidence - prior_confidence;
600
601        self.inferences_performed += 1;
602
603        Ok(PlnInferenceResult {
604            conclusion_id,
605            tv,
606            rule_used: rule,
607            premise_ids,
608            confidence_delta,
609        })
610    }
611
612    /// Materialise an inference result.
613    ///
614    /// * If the conclusion atom does not exist, it is created.
615    /// * If it exists and `enable_revision` is `true`, its TV is revised.
616    /// * If it exists and `enable_revision` is `false`, its TV is replaced.
617    pub fn apply_inference(&mut self, result: PlnInferenceResult) -> Result<(), PlnError> {
618        let id = &result.conclusion_id;
619
620        if let Some(existing) = self.atoms.get_mut(id) {
621            if self.config.enable_revision {
622                existing.tv = existing.tv.revise(result.tv);
623            } else {
624                existing.tv = result.tv;
625            }
626        } else if let Some(existing) = self.links.get_mut(id) {
627            if self.config.enable_revision {
628                existing.tv = existing.tv.revise(result.tv);
629            } else {
630                existing.tv = result.tv;
631            }
632        } else {
633            // New atom — materialise as a node
634            let total = self.atoms.len() + self.links.len();
635            if total >= self.config.max_atoms {
636                return Err(PlnError::MaxAtomsExceeded);
637            }
638            let atom = PlnAtom {
639                id: result.conclusion_id.clone(),
640                name: result.conclusion_id.clone(),
641                tv: result.tv,
642                atom_type: AtomType::Node(format!("InferredBy({})", result.rule_used)),
643            };
644            self.atoms.insert(result.conclusion_id, atom);
645        }
646        Ok(())
647    }
648
649    // ── Chain search ──────────────────────────────────────────────────────────
650
651    /// Find all inference chains from `from` to `to` up to `max_depth` hops.
652    ///
653    /// Returns a list of paths, where each path is a sequence of atom/link IDs
654    /// starting at `from` and ending at `to`.
655    pub fn find_chains(
656        &self,
657        from: &str,
658        to: &str,
659        max_depth: u8,
660    ) -> Result<Vec<Vec<String>>, PlnError> {
661        // Validate endpoints
662        if !self.atoms.contains_key(from) && !self.links.contains_key(from) {
663            return Err(PlnError::AtomNotFound(from.to_string()));
664        }
665        if !self.atoms.contains_key(to) && !self.links.contains_key(to) {
666            return Err(PlnError::AtomNotFound(to.to_string()));
667        }
668
669        let mut results: Vec<Vec<String>> = Vec::new();
670        // BFS state: (current_node, current_path, visited_set)
671        let mut queue: VecDeque<(String, Vec<String>, HashSet<String>)> = VecDeque::new();
672        {
673            let mut init_visited = HashSet::new();
674            init_visited.insert(from.to_string());
675            queue.push_back((from.to_string(), vec![from.to_string()], init_visited));
676        }
677
678        while let Some((node, path, visited)) = queue.pop_front() {
679            if path.len() as u8 > max_depth + 1 {
680                continue;
681            }
682
683            if let Some(edges) = self.adjacency.get(&node) {
684                for edge in edges {
685                    for tgt in &edge.targets {
686                        if visited.contains(tgt) {
687                            // Cycle detected — record it but do not follow further
688                            continue;
689                        }
690
691                        let mut new_path = path.clone();
692                        new_path.push(edge.link_id.clone());
693                        new_path.push(tgt.clone());
694
695                        if tgt == to {
696                            results.push(new_path);
697                        } else if (new_path.len() as u8) <= max_depth * 2 + 1 {
698                            let mut new_visited = visited.clone();
699                            new_visited.insert(tgt.clone());
700                            queue.push_back((tgt.clone(), new_path, new_visited));
701                        }
702                    }
703                }
704            }
705        }
706
707        Ok(results)
708    }
709
710    // ── Aggregated queries ────────────────────────────────────────────────────
711
712    /// Return the top `n` atoms sorted descending by confidence.
713    pub fn most_confident(&self, n: usize) -> Vec<PlnAtom> {
714        let mut atoms: Vec<&PlnAtom> = self.atoms.values().collect();
715        atoms.sort_by(|a, b| {
716            b.tv.confidence
717                .partial_cmp(&a.tv.confidence)
718                .unwrap_or(std::cmp::Ordering::Equal)
719        });
720        atoms.into_iter().take(n).cloned().collect()
721    }
722
723    /// Return all atoms whose confidence is strictly below `threshold`.
724    pub fn uncertain_atoms(&self, threshold: f64) -> Vec<PlnAtom> {
725        self.atoms
726            .values()
727            .filter(|a| a.tv.confidence < threshold)
728            .cloned()
729            .collect()
730    }
731
732    // ── Statistics ────────────────────────────────────────────────────────────
733
734    /// Return a snapshot of current statistics.
735    pub fn stats(&self) -> PlnStats {
736        let atom_count = self.atoms.len();
737        let link_count = self.links.len();
738        let total = atom_count + link_count;
739
740        let sum_conf: f64 = self.atoms.values().map(|a| a.tv.confidence).sum::<f64>()
741            + self.links.values().map(|l| l.tv.confidence).sum::<f64>();
742
743        let avg_confidence = if total > 0 {
744            sum_conf / total as f64
745        } else {
746            0.0
747        };
748
749        PlnStats {
750            atom_count,
751            link_count,
752            inferences_performed: self.inferences_performed,
753            avg_confidence,
754        }
755    }
756
757    // ── Private helpers ───────────────────────────────────────────────────────
758
759    /// Route the rule to the correct formula and return the derived TV.
760    fn apply_rule(
761        &self,
762        rule: &PlnInferenceRule,
763        premise_ids: &[String],
764    ) -> Result<TruthValue, PlnError> {
765        match rule {
766            PlnInferenceRule::Negation => self.rule_negation(premise_ids),
767            PlnInferenceRule::Conjunction => self.rule_conjunction(premise_ids),
768            PlnInferenceRule::Disjunction => self.rule_disjunction(premise_ids),
769            PlnInferenceRule::Revision => self.rule_revision(premise_ids),
770            PlnInferenceRule::Deduction => self.rule_deduction(premise_ids),
771            PlnInferenceRule::Induction => self.rule_induction(premise_ids),
772            PlnInferenceRule::Abduction => self.rule_abduction(premise_ids),
773            PlnInferenceRule::ModusPonens => self.rule_modus_ponens(premise_ids),
774        }
775    }
776
777    fn require_tv(&self, id: &str) -> Result<TruthValue, PlnError> {
778        self.get_tv(id)
779    }
780
781    fn rule_negation(&self, ids: &[String]) -> Result<TruthValue, PlnError> {
782        if ids.len() != 1 {
783            return Err(PlnError::InvalidRule(
784                "Negation requires exactly 1 premise".into(),
785            ));
786        }
787        Ok(self.require_tv(&ids[0])?.negate())
788    }
789
790    fn rule_conjunction(&self, ids: &[String]) -> Result<TruthValue, PlnError> {
791        if ids.len() < 2 {
792            return Err(PlnError::InvalidRule(
793                "Conjunction requires at least 2 premises".into(),
794            ));
795        }
796        let mut tv = self.require_tv(&ids[0])?;
797        for id in &ids[1..] {
798            tv = tv.conjunction(self.require_tv(id)?);
799        }
800        Ok(tv)
801    }
802
803    fn rule_disjunction(&self, ids: &[String]) -> Result<TruthValue, PlnError> {
804        if ids.len() < 2 {
805            return Err(PlnError::InvalidRule(
806                "Disjunction requires at least 2 premises".into(),
807            ));
808        }
809        let mut tv = self.require_tv(&ids[0])?;
810        for id in &ids[1..] {
811            tv = tv.disjunction(self.require_tv(id)?);
812        }
813        Ok(tv)
814    }
815
816    fn rule_revision(&self, ids: &[String]) -> Result<TruthValue, PlnError> {
817        if ids.len() != 2 {
818            return Err(PlnError::InvalidRule(
819                "Revision requires exactly 2 premises".into(),
820            ));
821        }
822        let tv1 = self.require_tv(&ids[0])?;
823        let tv2 = self.require_tv(&ids[1])?;
824        Ok(tv1.revise(tv2))
825    }
826
827    /// Deduction: premises must be [AB_link_id, BC_link_id, B_id, C_id].
828    fn rule_deduction(&self, ids: &[String]) -> Result<TruthValue, PlnError> {
829        if ids.len() != 4 {
830            return Err(PlnError::InvalidRule(
831                "Deduction requires 4 premises: [AB, BC, B, C]".into(),
832            ));
833        }
834        let ab = self.require_tv(&ids[0])?;
835        let bc = self.require_tv(&ids[1])?;
836        let b = self.require_tv(&ids[2])?;
837        let c = self.require_tv(&ids[3])?;
838        Ok(TruthValue::deduction(ab, bc, b, c))
839    }
840
841    /// Induction: premises must be [AB_link_id, AC_link_id].
842    fn rule_induction(&self, ids: &[String]) -> Result<TruthValue, PlnError> {
843        if ids.len() != 2 {
844            return Err(PlnError::InvalidRule(
845                "Induction requires 2 premises: [AB, AC]".into(),
846            ));
847        }
848        let ab = self.require_tv(&ids[0])?;
849        let ac = self.require_tv(&ids[1])?;
850        Ok(TruthValue::induction(ab, ac))
851    }
852
853    /// Abduction: premises must be [BA_link_id, CA_link_id].
854    fn rule_abduction(&self, ids: &[String]) -> Result<TruthValue, PlnError> {
855        if ids.len() != 2 {
856            return Err(PlnError::InvalidRule(
857                "Abduction requires 2 premises: [BA, CA]".into(),
858            ));
859        }
860        let ba = self.require_tv(&ids[0])?;
861        let ca = self.require_tv(&ids[1])?;
862        Ok(TruthValue::abduction(ba, ca))
863    }
864
865    /// Modus Ponens: premises must be [A_id, AB_link_id].
866    fn rule_modus_ponens(&self, ids: &[String]) -> Result<TruthValue, PlnError> {
867        if ids.len() != 2 {
868            return Err(PlnError::InvalidRule(
869                "ModusPonens requires 2 premises: [A, A→B]".into(),
870            ));
871        }
872        let a = self.require_tv(&ids[0])?;
873        let ab = self.require_tv(&ids[1])?;
874        Ok(TruthValue::modus_ponens(a, ab))
875    }
876}
877
878// ── Utility ───────────────────────────────────────────────────────────────────
879
880/// Build a deterministic conclusion ID from the rule and premise IDs.
881fn conclusion_id_for(rule: &PlnInferenceRule, premise_ids: &[String]) -> String {
882    let premises_joined = premise_ids.join("_");
883    format!("inferred__{rule}__{premises_joined}")
884}
885
886// ── Inline xorshift64 for tests ───────────────────────────────────────────────
887// (used only inside #[cfg(test)])
888
889#[cfg(test)]
890fn xorshift64(state: &mut u64) -> u64 {
891    let mut x = *state;
892    x ^= x << 13;
893    x ^= x >> 7;
894    x ^= x << 17;
895    *state = x;
896    x
897}
898
899#[cfg(test)]
900fn rng_f64(state: &mut u64) -> f64 {
901    (xorshift64(state) as f64) / (u64::MAX as f64)
902}
903
904// ── Tests ─────────────────────────────────────────────────────────────────────
905
906#[cfg(test)]
907mod tests {
908    use super::*;
909
910    // ── helpers ──────────────────────────────────────────────────────────────
911
912    fn concept(id: &str, s: f64, c: f64) -> PlnAtom {
913        PlnAtom::new(
914            id,
915            id,
916            TruthValue::new(s, c),
917            AtomType::Node("ConceptNode".into()),
918        )
919    }
920
921    fn inh_link(id: &str, src: &str, dst: &str, s: f64, c: f64) -> PlnLink {
922        PlnLink::new(
923            id,
924            LinkType::Inheritance,
925            vec![src.into(), dst.into()],
926            TruthValue::new(s, c),
927        )
928    }
929
930    fn basic_net() -> ProbabilisticLogicNetwork {
931        let mut pln = ProbabilisticLogicNetwork::new(PlnConfig::default());
932        pln.add_atom(concept("a", 0.8, 0.9))
933            .expect("test: should succeed");
934        pln.add_atom(concept("b", 0.7, 0.8))
935            .expect("test: should succeed");
936        pln.add_atom(concept("c", 0.6, 0.7))
937            .expect("test: should succeed");
938        pln.add_link(inh_link("ab", "a", "b", 0.9, 0.8))
939            .expect("test: should succeed");
940        pln.add_link(inh_link("bc", "b", "c", 0.85, 0.75))
941            .expect("test: should succeed");
942        pln
943    }
944
945    // ── TruthValue construction ───────────────────────────────────────────────
946
947    #[test]
948    fn tv_clamp() {
949        let tv = TruthValue::new(-0.5, 1.5);
950        assert_eq!(tv.strength, 0.0);
951        assert_eq!(tv.confidence, 1.0);
952    }
953
954    #[test]
955    fn tv_unknown() {
956        let tv = TruthValue::unknown();
957        assert_eq!(tv.strength, 0.5);
958        assert_eq!(tv.confidence, 0.0);
959    }
960
961    #[test]
962    fn tv_certain_true() {
963        let tv = TruthValue::certain_true();
964        assert_eq!(tv.strength, 1.0);
965        assert_eq!(tv.confidence, 1.0);
966    }
967
968    #[test]
969    fn tv_certain_false() {
970        let tv = TruthValue::certain_false();
971        assert_eq!(tv.strength, 0.0);
972        assert_eq!(tv.confidence, 1.0);
973    }
974
975    // ── count() ───────────────────────────────────────────────────────────────
976
977    #[test]
978    fn tv_count_zero_confidence() {
979        let tv = TruthValue::new(0.5, 0.0);
980        assert_eq!(tv.count(), 0.0);
981    }
982
983    #[test]
984    fn tv_count_full_confidence() {
985        let tv = TruthValue::certain_true();
986        assert!(tv.count().is_infinite());
987    }
988
989    #[test]
990    fn tv_count_half_confidence() {
991        let tv = TruthValue::new(0.8, 0.5);
992        // n = 0.5 / 0.5 = 1.0
993        assert!((tv.count() - 1.0).abs() < 1e-10);
994    }
995
996    // ── Negation ─────────────────────────────────────────────────────────────
997
998    #[test]
999    fn tv_negate_basic() {
1000        let tv = TruthValue::new(0.7, 0.8);
1001        let neg = tv.negate();
1002        assert!((neg.strength - 0.3).abs() < 1e-10);
1003        assert_eq!(neg.confidence, 0.8);
1004    }
1005
1006    #[test]
1007    fn tv_negate_double() {
1008        let tv = TruthValue::new(0.4, 0.6);
1009        let double_neg = tv.negate().negate();
1010        assert!((double_neg.strength - tv.strength).abs() < 1e-10);
1011        assert_eq!(double_neg.confidence, tv.confidence);
1012    }
1013
1014    // ── Conjunction ──────────────────────────────────────────────────────────
1015
1016    #[test]
1017    fn tv_conjunction_basic() {
1018        let a = TruthValue::new(0.8, 0.9);
1019        let b = TruthValue::new(0.6, 0.7);
1020        let c = a.conjunction(b);
1021        assert!((c.strength - 0.48).abs() < 1e-10);
1022        assert_eq!(c.confidence, 0.7);
1023    }
1024
1025    #[test]
1026    fn tv_conjunction_with_false() {
1027        let a = TruthValue::new(0.9, 0.9);
1028        let b = TruthValue::certain_false();
1029        let c = a.conjunction(b);
1030        assert!(c.strength < 1e-10);
1031    }
1032
1033    #[test]
1034    fn tv_conjunction_commutativity() {
1035        let a = TruthValue::new(0.7, 0.8);
1036        let b = TruthValue::new(0.5, 0.6);
1037        let ab = a.conjunction(b);
1038        let ba = b.conjunction(a);
1039        assert!((ab.strength - ba.strength).abs() < 1e-10);
1040        assert_eq!(ab.confidence, ba.confidence);
1041    }
1042
1043    // ── Disjunction ──────────────────────────────────────────────────────────
1044
1045    #[test]
1046    fn tv_disjunction_basic() {
1047        let a = TruthValue::new(0.5, 0.8);
1048        let b = TruthValue::new(0.5, 0.7);
1049        let d = a.disjunction(b);
1050        // s = 0.5 + 0.5 - 0.25 = 0.75
1051        assert!((d.strength - 0.75).abs() < 1e-10);
1052        assert_eq!(d.confidence, 0.7);
1053    }
1054
1055    #[test]
1056    fn tv_disjunction_with_true() {
1057        let a = TruthValue::new(0.3, 0.8);
1058        let b = TruthValue::certain_true();
1059        let d = a.disjunction(b);
1060        assert!((d.strength - 1.0).abs() < 1e-10);
1061    }
1062
1063    #[test]
1064    fn tv_disjunction_commutativity() {
1065        let a = TruthValue::new(0.3, 0.9);
1066        let b = TruthValue::new(0.7, 0.6);
1067        let ab = a.disjunction(b);
1068        let ba = b.disjunction(a);
1069        assert!((ab.strength - ba.strength).abs() < 1e-10);
1070    }
1071
1072    // ── Revision ─────────────────────────────────────────────────────────────
1073
1074    #[test]
1075    fn tv_revision_basic() {
1076        let t1 = TruthValue::new(0.8, 0.4); // n1 = 0.4/0.6 ≈ 0.667
1077        let t2 = TruthValue::new(0.6, 0.6); // n2 = 0.6/0.4 = 1.5
1078        let rev = t1.revise(t2);
1079        // n_total ≈ 2.167, s_rev ≈ (0.8*0.667 + 0.6*1.5) / 2.167 ≈ 0.665
1080        assert!(rev.confidence > t1.confidence);
1081        assert!(rev.confidence > t2.confidence);
1082        assert!((0.0..=1.0).contains(&rev.strength));
1083    }
1084
1085    #[test]
1086    fn tv_revision_idempotent_certain() {
1087        let t1 = TruthValue::certain_true();
1088        let t2 = TruthValue::certain_true();
1089        let rev = t1.revise(t2);
1090        assert_eq!(rev.confidence, 1.0);
1091        assert_eq!(rev.strength, 1.0);
1092    }
1093
1094    #[test]
1095    fn tv_revision_unknown_improves() {
1096        // unknown has n=0; evidence has n=1; total n=1 → c_rev = 1/2 = 0.5
1097        // Adding more evidence (c=0.8, n=4) should yield strictly higher confidence.
1098        let unknown = TruthValue::unknown();
1099        let strong_evidence = TruthValue::new(0.9, 0.8); // n=4
1100        let rev = unknown.revise(strong_evidence);
1101        // n_total = 4, c_rev = 4/5 = 0.8 >= strong_evidence.confidence
1102        assert!(rev.confidence >= strong_evidence.confidence);
1103        // Two independent high-confidence observations should compound
1104        let second = TruthValue::new(0.85, 0.8);
1105        let rev2 = rev.revise(second);
1106        assert!(rev2.confidence > rev.confidence);
1107    }
1108
1109    #[test]
1110    fn tv_revision_symmetry() {
1111        let a = TruthValue::new(0.7, 0.5);
1112        let b = TruthValue::new(0.4, 0.3);
1113        let ab = a.revise(b);
1114        let ba = b.revise(a);
1115        assert!((ab.strength - ba.strength).abs() < 1e-10);
1116        assert!((ab.confidence - ba.confidence).abs() < 1e-10);
1117    }
1118
1119    // ── Deduction ────────────────────────────────────────────────────────────
1120
1121    #[test]
1122    fn tv_deduction_basic() {
1123        let ab = TruthValue::new(0.9, 0.8);
1124        let bc = TruthValue::new(0.85, 0.75);
1125        let b = TruthValue::new(0.7, 0.8);
1126        let c = TruthValue::new(0.6, 0.7);
1127        let ac = TruthValue::deduction(ab, bc, b, c);
1128        assert!((0.0..=1.0).contains(&ac.strength));
1129        assert!((0.0..=1.0).contains(&ac.confidence));
1130    }
1131
1132    #[test]
1133    fn tv_deduction_high_confidence_chain() {
1134        let ab = TruthValue::new(0.95, 0.9);
1135        let bc = TruthValue::new(0.95, 0.9);
1136        let b = TruthValue::new(0.8, 0.9);
1137        let c = TruthValue::new(0.8, 0.9);
1138        let ac = TruthValue::deduction(ab, bc, b, c);
1139        // Should be high confidence and high strength
1140        assert!(ac.strength > 0.8);
1141    }
1142
1143    #[test]
1144    fn tv_deduction_degenerate_sb_one() {
1145        // s_B == 1.0 should not panic
1146        let ab = TruthValue::new(0.9, 0.8);
1147        let bc = TruthValue::new(0.8, 0.7);
1148        let b = TruthValue::new(1.0, 0.9);
1149        let c = TruthValue::new(0.9, 0.8);
1150        let ac = TruthValue::deduction(ab, bc, b, c);
1151        assert!((0.0..=1.0).contains(&ac.strength));
1152    }
1153
1154    // ── Induction ────────────────────────────────────────────────────────────
1155
1156    #[test]
1157    fn tv_induction_basic() {
1158        let ab = TruthValue::new(0.8, 0.7);
1159        let ac = TruthValue::new(0.6, 0.6);
1160        let bc = TruthValue::induction(ab, ac);
1161        assert!((0.0..=1.0).contains(&bc.strength));
1162        assert!((0.0..=1.0).contains(&bc.confidence));
1163    }
1164
1165    #[test]
1166    fn tv_induction_perfect_ab() {
1167        // s_AB == 1.0 → s_BC should equal s_AC
1168        let ab = TruthValue::new(1.0, 0.9);
1169        let ac = TruthValue::new(0.7, 0.8);
1170        let bc = TruthValue::induction(ab, ac);
1171        assert!((bc.strength - 0.7).abs() < 1e-6);
1172    }
1173
1174    #[test]
1175    fn tv_induction_low_ab() {
1176        // Very low s_AB → s_BC clamped to 1
1177        let ab = TruthValue::new(0.001, 0.5);
1178        let ac = TruthValue::new(0.9, 0.8);
1179        let bc = TruthValue::induction(ab, ac);
1180        assert!(bc.strength <= 1.0);
1181    }
1182
1183    // ── Abduction ────────────────────────────────────────────────────────────
1184
1185    #[test]
1186    fn tv_abduction_basic() {
1187        let ba = TruthValue::new(0.7, 0.8);
1188        let ca = TruthValue::new(0.8, 0.7);
1189        let bc = TruthValue::abduction(ba, ca);
1190        assert!((0.0..=1.0).contains(&bc.strength));
1191        assert!((0.0..=1.0).contains(&bc.confidence));
1192    }
1193
1194    #[test]
1195    fn tv_abduction_product_strength() {
1196        let ba = TruthValue::new(0.6, 0.8);
1197        let ca = TruthValue::new(0.5, 0.7);
1198        let bc = TruthValue::abduction(ba, ca);
1199        assert!((bc.strength - 0.3).abs() < 1e-10);
1200    }
1201
1202    // ── Modus Ponens ─────────────────────────────────────────────────────────
1203
1204    #[test]
1205    fn tv_modus_ponens_basic() {
1206        let a = TruthValue::new(0.9, 0.8);
1207        let ab = TruthValue::new(0.95, 0.9);
1208        let b = TruthValue::modus_ponens(a, ab);
1209        assert!(b.strength > 0.5);
1210        assert!((0.0..=1.0).contains(&b.confidence));
1211    }
1212
1213    #[test]
1214    fn tv_modus_ponens_false_premise() {
1215        let a = TruthValue::certain_false();
1216        let ab = TruthValue::new(0.9, 0.9);
1217        let b = TruthValue::modus_ponens(a, ab);
1218        // s_A=0, s_AB=0.9 → s_B = 0 + 1 * 0.1 * 0.5 = 0.05
1219        assert!(b.strength < 0.2);
1220    }
1221
1222    // ── add_atom ──────────────────────────────────────────────────────────────
1223
1224    #[test]
1225    fn add_atom_ok() {
1226        let mut pln = ProbabilisticLogicNetwork::new(PlnConfig::default());
1227        pln.add_atom(concept("x", 0.5, 0.5))
1228            .expect("test: should succeed");
1229        assert!(pln.get_tv("x").is_ok());
1230    }
1231
1232    #[test]
1233    fn add_atom_max_exceeded() {
1234        let cfg = PlnConfig {
1235            max_atoms: 2,
1236            ..Default::default()
1237        };
1238        let mut pln = ProbabilisticLogicNetwork::new(cfg);
1239        pln.add_atom(concept("a", 0.5, 0.5))
1240            .expect("test: should succeed");
1241        pln.add_atom(concept("b", 0.5, 0.5))
1242            .expect("test: should succeed");
1243        let r = pln.add_atom(concept("c", 0.5, 0.5));
1244        assert_eq!(r, Err(PlnError::MaxAtomsExceeded));
1245    }
1246
1247    #[test]
1248    fn add_atom_replace_existing() {
1249        let mut pln = ProbabilisticLogicNetwork::new(PlnConfig::default());
1250        pln.add_atom(concept("x", 0.3, 0.4))
1251            .expect("test: should succeed");
1252        pln.add_atom(concept("x", 0.9, 0.9))
1253            .expect("test: should succeed"); // replace
1254        let tv = pln.get_tv("x").expect("test: should succeed");
1255        assert!((tv.strength - 0.9).abs() < 1e-10);
1256    }
1257
1258    // ── add_link ──────────────────────────────────────────────────────────────
1259
1260    #[test]
1261    fn add_link_ok() {
1262        let pln = basic_net();
1263        let tv = pln.get_tv("ab").expect("test: should succeed");
1264        assert!((tv.strength - 0.9).abs() < 1e-10);
1265    }
1266
1267    #[test]
1268    fn add_link_missing_source() {
1269        let mut pln = ProbabilisticLogicNetwork::new(PlnConfig::default());
1270        pln.add_atom(concept("a", 0.5, 0.5))
1271            .expect("test: should succeed");
1272        let link = inh_link("ab", "a", "missing", 0.9, 0.8);
1273        let r = pln.add_link(link);
1274        assert!(matches!(r, Err(PlnError::AtomNotFound(_))));
1275    }
1276
1277    // ── get_tv ────────────────────────────────────────────────────────────────
1278
1279    #[test]
1280    fn get_tv_missing() {
1281        let pln = ProbabilisticLogicNetwork::new(PlnConfig::default());
1282        assert!(matches!(pln.get_tv("nope"), Err(PlnError::AtomNotFound(_))));
1283    }
1284
1285    // ── Inference: Negation ──────────────────────────────────────────────────
1286
1287    #[test]
1288    fn infer_negation() {
1289        let mut pln = basic_net();
1290        let r = pln
1291            .infer(PlnInferenceRule::Negation, vec!["a".into()])
1292            .expect("test: should succeed");
1293        assert!((r.tv.strength - (1.0 - 0.8)).abs() < 1e-10);
1294        assert!((r.tv.confidence - 0.9).abs() < 1e-10);
1295    }
1296
1297    #[test]
1298    fn infer_negation_wrong_arity() {
1299        let mut pln = basic_net();
1300        let r = pln.infer(PlnInferenceRule::Negation, vec!["a".into(), "b".into()]);
1301        assert!(matches!(r, Err(PlnError::InvalidRule(_))));
1302    }
1303
1304    // ── Inference: Conjunction ───────────────────────────────────────────────
1305
1306    #[test]
1307    fn infer_conjunction() {
1308        let mut pln = basic_net();
1309        let r = pln
1310            .infer(PlnInferenceRule::Conjunction, vec!["a".into(), "b".into()])
1311            .expect("test: should succeed");
1312        assert!((r.tv.strength - (0.8 * 0.7)).abs() < 1e-10);
1313        assert_eq!(r.tv.confidence, 0.8_f64.min(0.9));
1314    }
1315
1316    #[test]
1317    fn infer_conjunction_three_way() {
1318        let mut pln = basic_net();
1319        let r = pln
1320            .infer(
1321                PlnInferenceRule::Conjunction,
1322                vec!["a".into(), "b".into(), "c".into()],
1323            )
1324            .expect("test: should succeed");
1325        assert!((r.tv.strength - (0.8 * 0.7 * 0.6)).abs() < 1e-8);
1326    }
1327
1328    // ── Inference: Disjunction ───────────────────────────────────────────────
1329
1330    #[test]
1331    fn infer_disjunction() {
1332        let mut pln = basic_net();
1333        let r = pln
1334            .infer(PlnInferenceRule::Disjunction, vec!["a".into(), "b".into()])
1335            .expect("test: should succeed");
1336        // 0.8 + 0.7 - 0.56 = 0.94
1337        assert!((r.tv.strength - 0.94).abs() < 1e-10);
1338    }
1339
1340    // ── Inference: Revision ──────────────────────────────────────────────────
1341
1342    #[test]
1343    fn infer_revision() {
1344        let mut pln = basic_net();
1345        let r = pln
1346            .infer(PlnInferenceRule::Revision, vec!["a".into(), "b".into()])
1347            .expect("test: should succeed");
1348        assert!((0.0..=1.0).contains(&r.tv.strength));
1349        assert!((0.0..=1.0).contains(&r.tv.confidence));
1350    }
1351
1352    #[test]
1353    fn infer_revision_wrong_arity() {
1354        let mut pln = basic_net();
1355        let r = pln.infer(PlnInferenceRule::Revision, vec!["a".into()]);
1356        assert!(matches!(r, Err(PlnError::InvalidRule(_))));
1357    }
1358
1359    // ── Inference: Deduction ─────────────────────────────────────────────────
1360
1361    #[test]
1362    fn infer_deduction() {
1363        let mut pln = basic_net();
1364        let r = pln
1365            .infer(
1366                PlnInferenceRule::Deduction,
1367                vec!["ab".into(), "bc".into(), "b".into(), "c".into()],
1368            )
1369            .expect("test: should succeed");
1370        assert!((0.0..=1.0).contains(&r.tv.strength));
1371        assert_eq!(r.rule_used, PlnInferenceRule::Deduction);
1372    }
1373
1374    #[test]
1375    fn infer_deduction_wrong_arity() {
1376        let mut pln = basic_net();
1377        let r = pln.infer(PlnInferenceRule::Deduction, vec!["ab".into(), "bc".into()]);
1378        assert!(matches!(r, Err(PlnError::InvalidRule(_))));
1379    }
1380
1381    // ── Inference: Induction ─────────────────────────────────────────────────
1382
1383    #[test]
1384    fn infer_induction() {
1385        let mut pln = basic_net();
1386        let r = pln
1387            .infer(PlnInferenceRule::Induction, vec!["ab".into(), "bc".into()])
1388            .expect("test: should succeed");
1389        assert!((0.0..=1.0).contains(&r.tv.strength));
1390    }
1391
1392    // ── Inference: Abduction ─────────────────────────────────────────────────
1393
1394    #[test]
1395    fn infer_abduction() {
1396        let mut pln = basic_net();
1397        let r = pln
1398            .infer(PlnInferenceRule::Abduction, vec!["ab".into(), "bc".into()])
1399            .expect("test: should succeed");
1400        assert!((0.0..=1.0).contains(&r.tv.strength));
1401    }
1402
1403    // ── Inference: ModusPonens ───────────────────────────────────────────────
1404
1405    #[test]
1406    fn infer_modus_ponens() {
1407        let mut pln = basic_net();
1408        let r = pln
1409            .infer(PlnInferenceRule::ModusPonens, vec!["a".into(), "ab".into()])
1410            .expect("test: should succeed");
1411        assert!((0.0..=1.0).contains(&r.tv.strength));
1412    }
1413
1414    // ── apply_inference ───────────────────────────────────────────────────────
1415
1416    #[test]
1417    fn apply_inference_creates_atom() {
1418        let mut pln = basic_net();
1419        let r = pln
1420            .infer(PlnInferenceRule::Negation, vec!["a".into()])
1421            .expect("test: should succeed");
1422        let cid = r.conclusion_id.clone();
1423        pln.apply_inference(r).expect("test: should succeed");
1424        assert!(pln.get_tv(&cid).is_ok());
1425    }
1426
1427    #[test]
1428    fn apply_inference_revises_existing() {
1429        let mut pln = basic_net();
1430        // First inference
1431        let r1 = pln
1432            .infer(PlnInferenceRule::Negation, vec!["a".into()])
1433            .expect("test: should succeed");
1434        let cid = r1.conclusion_id.clone();
1435        pln.apply_inference(r1).expect("test: should succeed");
1436        let tv_before = pln.get_tv(&cid).expect("test: should succeed");
1437
1438        // Second inference (revision should increase confidence)
1439        let r2 = pln
1440            .infer(PlnInferenceRule::Negation, vec!["a".into()])
1441            .expect("test: should succeed");
1442        pln.apply_inference(r2).expect("test: should succeed");
1443        let tv_after = pln.get_tv(&cid).expect("test: should succeed");
1444
1445        // Revised TV should have higher or equal confidence
1446        assert!(tv_after.confidence >= tv_before.confidence);
1447    }
1448
1449    #[test]
1450    fn apply_inference_no_revision_replaces() {
1451        let mut pln = ProbabilisticLogicNetwork::new(PlnConfig {
1452            enable_revision: false,
1453            ..Default::default()
1454        });
1455        pln.add_atom(concept("a", 0.8, 0.9))
1456            .expect("test: should succeed");
1457        let r = pln
1458            .infer(PlnInferenceRule::Negation, vec!["a".into()])
1459            .expect("test: should succeed");
1460        let cid = r.conclusion_id.clone();
1461        let expected_tv = r.tv;
1462        pln.apply_inference(r).expect("test: should succeed");
1463        let tv = pln.get_tv(&cid).expect("test: should succeed");
1464        assert!((tv.strength - expected_tv.strength).abs() < 1e-10);
1465    }
1466
1467    // ── confidence_delta ──────────────────────────────────────────────────────
1468
1469    #[test]
1470    fn inference_result_confidence_delta_new_atom() {
1471        let mut pln = basic_net();
1472        let r = pln
1473            .infer(PlnInferenceRule::Negation, vec!["a".into()])
1474            .expect("test: should succeed");
1475        // New atom → prior confidence is 0 → delta == tv.confidence
1476        assert!((r.confidence_delta - r.tv.confidence).abs() < 1e-10);
1477    }
1478
1479    // ── find_chains ───────────────────────────────────────────────────────────
1480
1481    #[test]
1482    fn find_chains_direct() {
1483        let pln = basic_net();
1484        let chains = pln.find_chains("a", "b", 4).expect("test: should succeed");
1485        // a --ab--> b is one hop
1486        assert!(!chains.is_empty());
1487        let first = &chains[0];
1488        assert_eq!(first[0], "a");
1489        assert_eq!(*first.last().expect("test: should succeed"), "b");
1490    }
1491
1492    #[test]
1493    fn find_chains_two_hop() {
1494        let pln = basic_net();
1495        let chains = pln.find_chains("a", "c", 4).expect("test: should succeed");
1496        assert!(!chains.is_empty());
1497        // Should contain a path through b
1498        let path = &chains[0];
1499        assert!(path.contains(&"b".to_string()));
1500    }
1501
1502    #[test]
1503    fn find_chains_no_path() {
1504        let pln = basic_net();
1505        // c -> a doesn't exist (edges go a→b→c)
1506        let chains = pln.find_chains("c", "a", 4).expect("test: should succeed");
1507        assert!(chains.is_empty());
1508    }
1509
1510    #[test]
1511    fn find_chains_missing_from() {
1512        let pln = basic_net();
1513        let r = pln.find_chains("missing", "a", 4);
1514        assert!(matches!(r, Err(PlnError::AtomNotFound(_))));
1515    }
1516
1517    #[test]
1518    fn find_chains_missing_to() {
1519        let pln = basic_net();
1520        let r = pln.find_chains("a", "missing", 4);
1521        assert!(matches!(r, Err(PlnError::AtomNotFound(_))));
1522    }
1523
1524    // ── most_confident ────────────────────────────────────────────────────────
1525
1526    #[test]
1527    fn most_confident_top1() {
1528        let pln = basic_net();
1529        let top = pln.most_confident(1);
1530        assert_eq!(top.len(), 1);
1531        assert_eq!(top[0].id, "a"); // confidence 0.9
1532    }
1533
1534    #[test]
1535    fn most_confident_all() {
1536        let pln = basic_net();
1537        let top = pln.most_confident(10);
1538        assert_eq!(top.len(), 3); // only 3 atoms
1539    }
1540
1541    #[test]
1542    fn most_confident_zero() {
1543        let pln = basic_net();
1544        let top = pln.most_confident(0);
1545        assert!(top.is_empty());
1546    }
1547
1548    #[test]
1549    fn most_confident_sorted() {
1550        let pln = basic_net();
1551        let top = pln.most_confident(3);
1552        for i in 0..top.len() - 1 {
1553            assert!(top[i].tv.confidence >= top[i + 1].tv.confidence);
1554        }
1555    }
1556
1557    // ── uncertain_atoms ───────────────────────────────────────────────────────
1558
1559    #[test]
1560    fn uncertain_atoms_basic() {
1561        let pln = basic_net();
1562        // All atoms have confidence < 1.0
1563        let uncertain = pln.uncertain_atoms(1.0);
1564        assert_eq!(uncertain.len(), 3);
1565    }
1566
1567    #[test]
1568    fn uncertain_atoms_high_threshold() {
1569        let pln = basic_net();
1570        // Only atoms with confidence < 0.75 → only "c" (0.7)
1571        let uncertain = pln.uncertain_atoms(0.75);
1572        assert_eq!(uncertain.len(), 1);
1573        assert_eq!(uncertain[0].id, "c");
1574    }
1575
1576    #[test]
1577    fn uncertain_atoms_none() {
1578        let pln = basic_net();
1579        let uncertain = pln.uncertain_atoms(0.0);
1580        assert!(uncertain.is_empty());
1581    }
1582
1583    // ── stats ─────────────────────────────────────────────────────────────────
1584
1585    #[test]
1586    fn stats_basic() {
1587        let pln = basic_net();
1588        let s = pln.stats();
1589        assert_eq!(s.atom_count, 3);
1590        assert_eq!(s.link_count, 2);
1591        assert_eq!(s.inferences_performed, 0);
1592        assert!(s.avg_confidence > 0.0);
1593    }
1594
1595    #[test]
1596    fn stats_inferences_count() {
1597        let mut pln = basic_net();
1598        pln.infer(PlnInferenceRule::Negation, vec!["a".into()])
1599            .expect("test: should succeed");
1600        pln.infer(PlnInferenceRule::Negation, vec!["b".into()])
1601            .expect("test: should succeed");
1602        assert_eq!(pln.stats().inferences_performed, 2);
1603    }
1604
1605    #[test]
1606    fn stats_avg_confidence_empty() {
1607        let pln = ProbabilisticLogicNetwork::new(PlnConfig::default());
1608        let s = pln.stats();
1609        assert_eq!(s.avg_confidence, 0.0);
1610    }
1611
1612    // ── Insufficient confidence threshold ────────────────────────────────────
1613
1614    #[test]
1615    fn infer_below_threshold_rejected() {
1616        let cfg = PlnConfig {
1617            min_confidence_threshold: 0.99,
1618            ..Default::default()
1619        };
1620        let mut pln = ProbabilisticLogicNetwork::new(cfg);
1621        // Atoms with low confidence
1622        pln.add_atom(concept("a", 0.5, 0.1))
1623            .expect("test: should succeed");
1624        let r = pln.infer(PlnInferenceRule::Negation, vec!["a".into()]);
1625        assert!(matches!(r, Err(PlnError::InsufficientConfidence(_))));
1626    }
1627
1628    // ── Randomised stress test ────────────────────────────────────────────────
1629
1630    #[test]
1631    fn random_atoms_and_queries() {
1632        let mut state: u64 = 0xDEAD_BEEF_CAFE_BABE;
1633        let mut pln = ProbabilisticLogicNetwork::new(PlnConfig::default());
1634
1635        // Add 20 random atoms
1636        for i in 0..20u32 {
1637            let s = rng_f64(&mut state);
1638            let c = rng_f64(&mut state);
1639            let id = format!("node_{i}");
1640            pln.add_atom(concept(&id, s, c))
1641                .expect("test: should succeed");
1642        }
1643
1644        let s = pln.stats();
1645        assert_eq!(s.atom_count, 20);
1646        assert!(s.avg_confidence >= 0.0);
1647        assert!(s.avg_confidence <= 1.0);
1648    }
1649
1650    // ── Display / formatting ──────────────────────────────────────────────────
1651
1652    #[test]
1653    fn tv_display() {
1654        let tv = TruthValue::new(0.75, 0.5);
1655        let s = format!("{tv}");
1656        assert!(s.contains("0.7500"));
1657        assert!(s.contains("0.5000"));
1658    }
1659
1660    #[test]
1661    fn rule_display() {
1662        assert_eq!(format!("{}", PlnInferenceRule::Deduction), "Deduction");
1663        assert_eq!(format!("{}", PlnInferenceRule::ModusPonens), "ModusPonens");
1664    }
1665
1666    // ── Edge cases ────────────────────────────────────────────────────────────
1667
1668    #[test]
1669    fn deduction_sb_near_one_no_panic() {
1670        let ab = TruthValue::new(0.9, 0.8);
1671        let bc = TruthValue::new(0.8, 0.7);
1672        let b = TruthValue::new(0.9999999, 0.9);
1673        let c = TruthValue::new(0.9, 0.8);
1674        let ac = TruthValue::deduction(ab, bc, b, c);
1675        assert!((0.0..=1.0).contains(&ac.strength));
1676    }
1677
1678    #[test]
1679    fn revision_with_zero_confidence_atoms() {
1680        let a = TruthValue::new(0.8, 0.0);
1681        let b = TruthValue::new(0.3, 0.0);
1682        let rev = a.revise(b);
1683        // Both have n=0 → unknown
1684        assert_eq!(rev.strength, 0.5);
1685    }
1686
1687    #[test]
1688    fn conjunction_chain_three() {
1689        let a = TruthValue::new(0.9, 0.8);
1690        let b = TruthValue::new(0.8, 0.7);
1691        let c = TruthValue::new(0.7, 0.6);
1692        let abc = a.conjunction(b).conjunction(c);
1693        assert!((abc.strength - (0.9 * 0.8 * 0.7)).abs() < 1e-8);
1694        assert_eq!(abc.confidence, 0.6);
1695    }
1696
1697    #[test]
1698    fn find_chains_self_loop_not_followed() {
1699        let mut pln = ProbabilisticLogicNetwork::new(PlnConfig::default());
1700        pln.add_atom(concept("x", 0.5, 0.5))
1701            .expect("test: should succeed");
1702        // Find chain from x to x with depth 0
1703        let chains = pln.find_chains("x", "x", 0).expect("test: should succeed");
1704        // No self-loops in result — adjacency is empty
1705        assert!(chains.is_empty());
1706    }
1707}