Skip to main content

ipfrs_tensorlogic/
neural_symbolic.rs

1//! Neural-Symbolic Integrator — bridges continuous embedding representations
2//! with symbolic logical rule reasoning for hybrid inference.
3//!
4//! This module implements [`NeuralSymbolicIntegrator`], a production-grade
5//! system that combines learned neural patterns with explicit logical rules,
6//! enabling inference that benefits from both paradigms.
7//!
8//! ## Architecture
9//!
10//! The integrator maintains:
11//! - A registry of [`Symbol`]s, each with a name, high-dimensional embedding,
12//!   and a base confidence score.
13//! - A set of [`LogicalRule`]s that define horn-clause-style implication
14//!   relationships between symbols, weighted by confidence and typed by
15//!   [`RuleType`].
16//!
17//! Inference proceeds in three possible modes (controlled by [`InferenceMode`]):
18//! - **Pure Symbolic** — forward-chain through rules only.
19//! - **Pure Neural** — compute embedding similarity to evidence only.
20//! - **Hybrid** — weighted combination of symbolic and neural signals.
21
22use std::collections::HashMap;
23use thiserror::Error;
24
25// ---------------------------------------------------------------------------
26// Error type
27// ---------------------------------------------------------------------------
28
29/// Errors produced by [`NeuralSymbolicIntegrator`].
30#[derive(Debug, Error, Clone, PartialEq)]
31pub enum NsError {
32    /// A symbol id referenced in a query or rule does not exist.
33    #[error("symbol not found: id {0}")]
34    SymbolNotFound(usize),
35
36    /// The provided embedding has a different dimension than the integrator was
37    /// configured with.
38    #[error("dimension mismatch: expected {expected}, got {got}")]
39    DimensionMismatch { expected: usize, got: usize },
40
41    /// The symbol registry is full.
42    #[error("maximum symbol count reached")]
43    MaxSymbolsReached,
44
45    /// A rule references invalid symbol ids or is otherwise structurally
46    /// incorrect.
47    #[error("invalid rule: {0}")]
48    InvalidRule(String),
49}
50
51// ---------------------------------------------------------------------------
52// Core identifiers and data types
53// ---------------------------------------------------------------------------
54
55/// A lightweight newtype wrapper for a symbol index in the integrator's
56/// internal registry.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
58pub struct SymbolId(pub usize);
59
60impl std::fmt::Display for SymbolId {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        write!(f, "sym:{}", self.0)
63    }
64}
65
66/// A named concept with a neural embedding and a base confidence score.
67///
68/// Symbols are the atomic units of knowledge in the integrator.  Each symbol
69/// can participate in logical rules as a head or body atom, and its embedding
70/// allows neural similarity to be computed against evidence embeddings.
71#[derive(Debug, Clone)]
72pub struct Symbol {
73    /// Stable identifier within this integrator instance.
74    pub id: SymbolId,
75    /// Human-readable label.
76    pub name: String,
77    /// Dense embedding vector; length must equal
78    /// [`IntegratorConfig::embedding_dim`].
79    pub embedding: Vec<f64>,
80    /// Prior confidence that this symbol holds in absence of other evidence;
81    /// in `[0.0, 1.0]`.
82    pub confidence: f64,
83}
84
85/// The flavour of a logical rule, controlling how body satisfaction is
86/// translated into rule confidence.
87#[derive(Debug, Clone, PartialEq)]
88pub enum RuleType {
89    /// Classical definite clause — body satisfaction is multiplied by the
90    /// rule weight unchanged.
91    Definite,
92    /// Probabilistic rule — weight is further modulated by the body
93    /// satisfaction probability.
94    Probabilistic,
95    /// Soft rule with temperature-controlled sigmoid.  Lower `temperature`
96    /// makes the sigmoid steeper (closer to a step function).
97    Soft {
98        /// Controls sharpness of the sigmoid applied to body satisfaction.
99        temperature: f64,
100    },
101}
102
103/// A single implication rule: `weight * f(body) → head`.
104#[derive(Debug, Clone)]
105pub struct LogicalRule {
106    /// The symbol that this rule can derive confidence for.
107    pub head: SymbolId,
108    /// Ordered list of body atoms that must be satisfied.
109    pub body: Vec<SymbolId>,
110    /// Base weight of the rule; in `(0.0, 1.0]` by convention.
111    pub weight: f64,
112    /// Determines the transfer function from body satisfaction to rule
113    /// confidence.
114    pub rule_type: RuleType,
115}
116
117// ---------------------------------------------------------------------------
118// Query / result types
119// ---------------------------------------------------------------------------
120
121/// Selects which inference strategy the integrator uses when answering a
122/// [`NsQuery`].
123#[derive(Debug, Clone, PartialEq)]
124pub enum InferenceMode {
125    /// Only symbolic forward-chaining through rules.
126    PureSymbolic,
127    /// Only neural embedding similarity.
128    PureNeural,
129    /// Weighted blend: `neural_weight * neural + (1 - neural_weight) *
130    /// symbolic`.
131    Hybrid {
132        /// Weight given to the neural signal; must be in `[0.0, 1.0]`.
133        neural_weight: f64,
134    },
135}
136
137/// A query to the integrator asking for the confidence of a target symbol
138/// given some observed evidence.
139#[derive(Debug, Clone)]
140pub struct NsQuery {
141    /// The symbol whose confidence we want to estimate.
142    pub target: SymbolId,
143    /// Observed symbols and their associated confidence values.
144    pub evidence: Vec<(SymbolId, f64)>,
145    /// Which inference strategy to apply.
146    pub mode: InferenceMode,
147}
148
149/// The answer returned by [`NeuralSymbolicIntegrator::infer`].
150#[derive(Debug, Clone)]
151pub struct NsResult {
152    /// The queried symbol.
153    pub symbol: SymbolId,
154    /// Combined confidence estimate produced by the chosen inference mode.
155    pub confidence: f64,
156    /// Human-readable strings describing the rules or similarities that
157    /// contributed to this result.
158    pub explanation: Vec<String>,
159    /// The raw neural (embedding-similarity) component of the confidence.
160    pub neural_contribution: f64,
161    /// The raw symbolic (forward-chaining) component of the confidence.
162    pub symbolic_contribution: f64,
163}
164
165// ---------------------------------------------------------------------------
166// Configuration
167// ---------------------------------------------------------------------------
168
169/// Configuration parameters for [`NeuralSymbolicIntegrator`].
170#[derive(Debug, Clone)]
171pub struct IntegratorConfig {
172    /// Dimensionality that every symbol embedding must have.
173    pub embedding_dim: usize,
174    /// Hard cap on the number of symbols that can be registered.
175    pub max_symbols: usize,
176    /// Maximum recursion depth during symbolic forward-chaining.
177    pub inference_depth: usize,
178    /// Cosine-similarity threshold below which a symbol is not considered a
179    /// *similar* symbol in [`NeuralSymbolicIntegrator::similar_symbols`].
180    pub similarity_threshold: f64,
181}
182
183impl Default for IntegratorConfig {
184    fn default() -> Self {
185        Self {
186            embedding_dim: 128,
187            max_symbols: 10_000,
188            inference_depth: 5,
189            similarity_threshold: 0.7,
190        }
191    }
192}
193
194// ---------------------------------------------------------------------------
195// Statistics
196// ---------------------------------------------------------------------------
197
198/// Aggregate statistics for a [`NeuralSymbolicIntegrator`] instance.
199#[derive(Debug, Clone, PartialEq)]
200pub struct NsStats {
201    /// Number of registered symbols.
202    pub symbol_count: usize,
203    /// Number of registered rules.
204    pub rule_count: usize,
205    /// Total number of inference calls that have completed successfully.
206    pub total_inferences: u64,
207    /// Mean L2 norm of all symbol embeddings; useful for sanity-checking that
208    /// embeddings are properly normalised.
209    pub avg_embedding_norm: f64,
210}
211
212// ---------------------------------------------------------------------------
213// Main integrator
214// ---------------------------------------------------------------------------
215
216/// A production-grade neural-symbolic integration engine.
217///
218/// Maintains a registry of [`Symbol`]s with continuous embeddings alongside
219/// a set of weighted [`LogicalRule`]s.  Inference combines cosine-similarity
220/// lookup (neural path) with forward-chaining through rules (symbolic path).
221///
222/// # Thread safety
223///
224/// `NeuralSymbolicIntegrator` is **not** `Sync` by default because `infer`
225/// takes `&mut self` to update the `total_inferences` counter.  Wrap in a
226/// `Mutex` or `RwLock` for shared concurrent access.
227pub struct NeuralSymbolicIntegrator {
228    /// Configuration that was provided at construction time.
229    pub config: IntegratorConfig,
230    /// All registered symbols, indexed by their [`SymbolId`].
231    pub symbols: Vec<Symbol>,
232    /// All registered rules.
233    pub rules: Vec<LogicalRule>,
234    /// Monotonically increasing counter of successful `infer` calls.
235    pub total_inferences: u64,
236}
237
238impl NeuralSymbolicIntegrator {
239    // -----------------------------------------------------------------------
240    // Construction
241    // -----------------------------------------------------------------------
242
243    /// Create a new, empty integrator with the given configuration.
244    pub fn new(config: IntegratorConfig) -> Self {
245        Self {
246            config,
247            symbols: Vec::new(),
248            rules: Vec::new(),
249            total_inferences: 0,
250        }
251    }
252
253    // -----------------------------------------------------------------------
254    // Registry management
255    // -----------------------------------------------------------------------
256
257    /// Register a new symbol.
258    ///
259    /// # Errors
260    ///
261    /// Returns [`NsError::MaxSymbolsReached`] when the symbol count would
262    /// exceed [`IntegratorConfig::max_symbols`].
263    ///
264    /// Returns [`NsError::DimensionMismatch`] when `embedding.len()` differs
265    /// from [`IntegratorConfig::embedding_dim`].
266    pub fn add_symbol(
267        &mut self,
268        name: String,
269        embedding: Vec<f64>,
270        confidence: f64,
271    ) -> Result<SymbolId, NsError> {
272        if self.symbols.len() >= self.config.max_symbols {
273            return Err(NsError::MaxSymbolsReached);
274        }
275        if embedding.len() != self.config.embedding_dim {
276            return Err(NsError::DimensionMismatch {
277                expected: self.config.embedding_dim,
278                got: embedding.len(),
279            });
280        }
281        let id = SymbolId(self.symbols.len());
282        self.symbols.push(Symbol {
283            id,
284            name,
285            embedding,
286            confidence: confidence.clamp(0.0, 1.0),
287        });
288        Ok(id)
289    }
290
291    /// Register a logical rule.
292    ///
293    /// # Errors
294    ///
295    /// Returns [`NsError::InvalidRule`] if the head or any body symbol does
296    /// not exist in the registry.
297    pub fn add_rule(&mut self, rule: LogicalRule) -> Result<(), NsError> {
298        if rule.head.0 >= self.symbols.len() {
299            return Err(NsError::InvalidRule(format!(
300                "head symbol {} does not exist",
301                rule.head.0
302            )));
303        }
304        for &body_sym in &rule.body {
305            if body_sym.0 >= self.symbols.len() {
306                return Err(NsError::InvalidRule(format!(
307                    "body symbol {} does not exist",
308                    body_sym.0
309                )));
310            }
311        }
312        self.rules.push(rule);
313        Ok(())
314    }
315
316    // -----------------------------------------------------------------------
317    // Neural helpers
318    // -----------------------------------------------------------------------
319
320    /// Compute the cosine similarity between two embedding vectors.
321    ///
322    /// Returns `0.0` when either vector has zero norm (to avoid division by
323    /// zero) and clamps the result to `[-1.0, 1.0]`.
324    pub fn neural_similarity(a: &[f64], b: &[f64]) -> f64 {
325        debug_assert_eq!(a.len(), b.len(), "embedding dimension mismatch");
326        let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
327        let norm_a: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
328        let norm_b: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
329        if norm_a == 0.0 || norm_b == 0.0 {
330            return 0.0;
331        }
332        (dot / (norm_a * norm_b)).clamp(-1.0, 1.0)
333    }
334
335    // -----------------------------------------------------------------------
336    // Symbolic inference
337    // -----------------------------------------------------------------------
338
339    /// Forward-chain through rules to estimate confidence in `target`.
340    ///
341    /// Recursively resolves body atoms up to `depth` levels deep.  Each call
342    /// collects evidence from the provided map, multiplies body atoms to obtain
343    /// body satisfaction, applies the rule-type transfer function, and returns
344    /// the maximum confidence obtained across all matching rules.
345    ///
346    /// # Arguments
347    ///
348    /// * `target` — the symbol whose confidence we are computing.
349    /// * `evidence` — the set of directly observed (symbol, confidence) pairs.
350    /// * `depth` — remaining recursion budget.
351    pub fn symbolic_forward_chain(
352        &self,
353        target: SymbolId,
354        evidence: &[(SymbolId, f64)],
355        depth: usize,
356    ) -> f64 {
357        // Build a lookup map from the evidence slice.
358        let ev_map: HashMap<usize, f64> = evidence.iter().map(|(s, c)| (s.0, *c)).collect();
359        self.symbolic_chain_inner(target, &ev_map, depth)
360    }
361
362    /// Internal recursive implementation of symbolic forward chaining.
363    fn symbolic_chain_inner(
364        &self,
365        target: SymbolId,
366        ev_map: &HashMap<usize, f64>,
367        depth: usize,
368    ) -> f64 {
369        // Check direct evidence first.
370        if let Some(&conf) = ev_map.get(&target.0) {
371            return conf;
372        }
373
374        if depth == 0 {
375            // At the recursion limit, fall back to the symbol's own confidence.
376            return self
377                .symbols
378                .get(target.0)
379                .map(|s| s.confidence)
380                .unwrap_or(0.0);
381        }
382
383        let mut best: f64 = 0.0;
384
385        for rule in &self.rules {
386            if rule.head != target {
387                continue;
388            }
389            if rule.body.is_empty() {
390                // A fact (empty body) — body satisfaction is 1.0 (vacuously
391                // true), so apply the transfer function with full satisfaction.
392                let adjusted = self.apply_rule_type(rule, 1.0, rule.weight);
393                if adjusted > best {
394                    best = adjusted;
395                }
396                continue;
397            }
398
399            // Compute body satisfaction as the product of each body atom's
400            // confidence, resolved recursively.
401            let body_satisfaction: f64 = rule.body.iter().fold(1.0, |acc, &body_sym| {
402                let body_conf = self.symbolic_chain_inner(body_sym, ev_map, depth - 1);
403                acc * body_conf
404            });
405
406            let rule_confidence = self.apply_rule_type(rule, body_satisfaction, rule.weight);
407
408            if rule_confidence > best {
409                best = rule_confidence;
410            }
411        }
412
413        best
414    }
415
416    /// Apply the [`RuleType`] transfer function to obtain the final rule
417    /// confidence from the body satisfaction and rule weight.
418    fn apply_rule_type(
419        &self,
420        rule: &LogicalRule,
421        body_satisfaction: f64,
422        _base_weight: f64,
423    ) -> f64 {
424        match &rule.rule_type {
425            RuleType::Definite => rule.weight * body_satisfaction,
426            RuleType::Probabilistic => rule.weight * body_satisfaction * body_satisfaction,
427            RuleType::Soft { temperature } => {
428                let temp = temperature.max(f64::EPSILON);
429                let sigmoid_input = body_satisfaction / temp;
430                let sigmoid = 1.0 / (1.0 + (-sigmoid_input).exp());
431                rule.weight * sigmoid
432            }
433        }
434    }
435
436    // -----------------------------------------------------------------------
437    // Neural inference
438    // -----------------------------------------------------------------------
439
440    /// Estimate confidence in `target` purely via embedding similarity.
441    ///
442    /// For each evidence pair `(sym, conf)`, computes
443    /// `neural_similarity(target.embedding, sym.embedding) * conf` and returns
444    /// the maximum across all evidence items.  Returns `0.0` when `target`
445    /// does not exist or there is no evidence.
446    pub fn neural_forward(&self, target: SymbolId, evidence: &[(SymbolId, f64)]) -> f64 {
447        let target_sym = match self.symbols.get(target.0) {
448            Some(s) => s,
449            None => return 0.0,
450        };
451
452        let mut best: f64 = 0.0;
453        for &(ev_sym_id, conf) in evidence {
454            if let Some(ev_sym) = self.symbols.get(ev_sym_id.0) {
455                let sim = Self::neural_similarity(&target_sym.embedding, &ev_sym.embedding);
456                let score = sim * conf;
457                if score > best {
458                    best = score;
459                }
460            }
461        }
462        best
463    }
464
465    // -----------------------------------------------------------------------
466    // Main inference entry point
467    // -----------------------------------------------------------------------
468
469    /// Answer an [`NsQuery`] using the specified [`InferenceMode`].
470    ///
471    /// # Errors
472    ///
473    /// Returns [`NsError::SymbolNotFound`] when `query.target` does not exist.
474    pub fn infer(&mut self, query: &NsQuery) -> Result<NsResult, NsError> {
475        // Validate target.
476        if query.target.0 >= self.symbols.len() {
477            return Err(NsError::SymbolNotFound(query.target.0));
478        }
479
480        let symbolic =
481            self.symbolic_forward_chain(query.target, &query.evidence, self.config.inference_depth);
482        let neural = self.neural_forward(query.target, &query.evidence);
483
484        let combined = match &query.mode {
485            InferenceMode::PureSymbolic => symbolic,
486            InferenceMode::PureNeural => neural,
487            InferenceMode::Hybrid { neural_weight } => {
488                let nw = neural_weight.clamp(0.0, 1.0);
489                nw * neural + (1.0 - nw) * symbolic
490            }
491        };
492
493        // Collect explanation strings.
494        let explanation = self.build_explanation(query.target, &query.evidence);
495
496        self.total_inferences += 1;
497
498        Ok(NsResult {
499            symbol: query.target,
500            confidence: combined.clamp(0.0, 1.0),
501            explanation,
502            neural_contribution: neural,
503            symbolic_contribution: symbolic,
504        })
505    }
506
507    /// Build a list of human-readable strings describing which rules
508    /// contributed to the inference for `target`.
509    fn build_explanation(&self, target: SymbolId, evidence: &[(SymbolId, f64)]) -> Vec<String> {
510        let mut explanations: Vec<String> = Vec::new();
511
512        // Identify contributing rules.
513        for rule in &self.rules {
514            if rule.head != target {
515                continue;
516            }
517            let body_names: Vec<String> = rule
518                .body
519                .iter()
520                .map(|b| {
521                    self.symbols
522                        .get(b.0)
523                        .map(|s| s.name.clone())
524                        .unwrap_or_else(|| format!("sym:{}", b.0))
525                })
526                .collect();
527            let head_name = self
528                .symbols
529                .get(target.0)
530                .map(|s| s.name.clone())
531                .unwrap_or_else(|| format!("sym:{}", target.0));
532            let body_str = if body_names.is_empty() {
533                "∅".to_string()
534            } else {
535                body_names.join(", ")
536            };
537            explanations.push(format!(
538                "rule_{}_from_[{}] (weight={:.3})",
539                head_name, body_str, rule.weight
540            ));
541        }
542
543        // Identify contributing neural evidence.
544        if let Some(target_sym) = self.symbols.get(target.0) {
545            for &(ev_id, conf) in evidence {
546                if let Some(ev_sym) = self.symbols.get(ev_id.0) {
547                    let sim = Self::neural_similarity(&target_sym.embedding, &ev_sym.embedding);
548                    if sim > 0.0 {
549                        explanations.push(format!(
550                            "neural_similarity({}, {})={:.3} × conf={:.3}",
551                            target_sym.name, ev_sym.name, sim, conf
552                        ));
553                    }
554                }
555            }
556        }
557
558        explanations
559    }
560
561    // -----------------------------------------------------------------------
562    // Utility methods
563    // -----------------------------------------------------------------------
564
565    /// Return human-readable strings describing every rule whose head or body
566    /// involves `id`.
567    ///
568    /// # Errors
569    ///
570    /// Returns [`NsError::SymbolNotFound`] if `id` is not registered.
571    pub fn explain_symbol(&self, id: SymbolId) -> Result<Vec<String>, NsError> {
572        if id.0 >= self.symbols.len() {
573            return Err(NsError::SymbolNotFound(id.0));
574        }
575        let sym_name = &self.symbols[id.0].name;
576        let mut lines: Vec<String> = Vec::new();
577        for rule in &self.rules {
578            if rule.head == id || rule.body.contains(&id) {
579                let head_name = self
580                    .symbols
581                    .get(rule.head.0)
582                    .map(|s| s.name.as_str())
583                    .unwrap_or("?");
584                let body_names: Vec<&str> = rule
585                    .body
586                    .iter()
587                    .map(|b| {
588                        self.symbols
589                            .get(b.0)
590                            .map(|s| s.name.as_str())
591                            .unwrap_or("?")
592                    })
593                    .collect();
594                lines.push(format!(
595                    "{} ← [{}] (w={:.3}, {:?})",
596                    head_name,
597                    body_names.join(", "),
598                    rule.weight,
599                    rule.rule_type
600                ));
601            }
602        }
603        if lines.is_empty() {
604            lines.push(format!("'{}' has no associated rules", sym_name));
605        }
606        Ok(lines)
607    }
608
609    /// Return the top-`k` symbols most similar (by cosine similarity) to the
610    /// embedding of `id`, sorted by descending similarity.
611    ///
612    /// Only symbols with similarity ≥ [`IntegratorConfig::similarity_threshold`]
613    /// are included.  The queried symbol itself is excluded.
614    ///
615    /// # Errors
616    ///
617    /// Returns [`NsError::SymbolNotFound`] if `id` is not registered.
618    pub fn similar_symbols(&self, id: SymbolId, k: usize) -> Result<Vec<(SymbolId, f64)>, NsError> {
619        if id.0 >= self.symbols.len() {
620            return Err(NsError::SymbolNotFound(id.0));
621        }
622        let query_emb = &self.symbols[id.0].embedding;
623        let threshold = self.config.similarity_threshold;
624
625        let mut scored: Vec<(SymbolId, f64)> = self
626            .symbols
627            .iter()
628            .filter(|s| s.id != id)
629            .map(|s| {
630                let sim = Self::neural_similarity(query_emb, &s.embedding);
631                (s.id, sim)
632            })
633            .filter(|(_, sim)| *sim >= threshold)
634            .collect();
635
636        // Sort by descending similarity, then by symbol id for determinism.
637        scored.sort_by(|a, b| {
638            b.1.partial_cmp(&a.1)
639                .unwrap_or(std::cmp::Ordering::Equal)
640                .then_with(|| a.0.cmp(&b.0))
641        });
642        scored.truncate(k);
643        Ok(scored)
644    }
645
646    /// Return aggregate statistics for this integrator.
647    pub fn stats(&self) -> NsStats {
648        let avg_embedding_norm = if self.symbols.is_empty() {
649            0.0
650        } else {
651            let total_norm: f64 = self
652                .symbols
653                .iter()
654                .map(|s| s.embedding.iter().map(|x| x * x).sum::<f64>().sqrt())
655                .sum();
656            total_norm / self.symbols.len() as f64
657        };
658        NsStats {
659            symbol_count: self.symbols.len(),
660            rule_count: self.rules.len(),
661            total_inferences: self.total_inferences,
662            avg_embedding_norm,
663        }
664    }
665}
666
667// ---------------------------------------------------------------------------
668// Tests
669// ---------------------------------------------------------------------------
670
671#[cfg(test)]
672mod tests {
673    use super::{
674        InferenceMode, IntegratorConfig, LogicalRule, NeuralSymbolicIntegrator, NsError, NsQuery,
675        RuleType, SymbolId,
676    };
677
678    // -----------------------------------------------------------------------
679    // Helpers
680    // -----------------------------------------------------------------------
681
682    /// Build a unit vector of the given dimension with component `val`.
683    fn uniform_emb(dim: usize, val: f64) -> Vec<f64> {
684        let norm = (dim as f64 * val * val).sqrt();
685        if norm == 0.0 {
686            return vec![0.0; dim];
687        }
688        vec![val / norm; dim]
689    }
690
691    /// Build a standard-basis embedding with 1.0 at position `idx`.
692    fn basis_emb(dim: usize, idx: usize) -> Vec<f64> {
693        let mut v = vec![0.0; dim];
694        if idx < dim {
695            v[idx] = 1.0;
696        }
697        v
698    }
699
700    fn default_integrator() -> NeuralSymbolicIntegrator {
701        NeuralSymbolicIntegrator::new(IntegratorConfig {
702            embedding_dim: 4,
703            max_symbols: 100,
704            inference_depth: 5,
705            similarity_threshold: 0.5,
706        })
707    }
708
709    // -----------------------------------------------------------------------
710    // SymbolId tests
711    // -----------------------------------------------------------------------
712
713    #[test]
714    fn symbol_id_display() {
715        let id = SymbolId(42);
716        assert_eq!(id.to_string(), "sym:42");
717    }
718
719    #[test]
720    fn symbol_id_ordering() {
721        assert!(SymbolId(0) < SymbolId(1));
722        assert!(SymbolId(5) > SymbolId(3));
723        assert_eq!(SymbolId(7), SymbolId(7));
724    }
725
726    // -----------------------------------------------------------------------
727    // add_symbol tests
728    // -----------------------------------------------------------------------
729
730    #[test]
731    fn add_symbol_returns_sequential_ids() {
732        let mut ig = default_integrator();
733        let id0 = ig
734            .add_symbol("a".into(), basis_emb(4, 0), 0.9)
735            .expect("test setup: add symbol 'a'");
736        let id1 = ig
737            .add_symbol("b".into(), basis_emb(4, 1), 0.8)
738            .expect("test setup: add symbol 'b'");
739        assert_eq!(id0, SymbolId(0));
740        assert_eq!(id1, SymbolId(1));
741    }
742
743    #[test]
744    fn add_symbol_dimension_mismatch() {
745        let mut ig = default_integrator();
746        let err = ig.add_symbol("x".into(), vec![0.1, 0.2], 0.5).unwrap_err();
747        assert_eq!(
748            err,
749            NsError::DimensionMismatch {
750                expected: 4,
751                got: 2
752            }
753        );
754    }
755
756    #[test]
757    fn add_symbol_max_reached() {
758        let mut ig = NeuralSymbolicIntegrator::new(IntegratorConfig {
759            embedding_dim: 2,
760            max_symbols: 2,
761            inference_depth: 3,
762            similarity_threshold: 0.5,
763        });
764        ig.add_symbol("a".into(), vec![1.0, 0.0], 1.0)
765            .expect("test setup: add symbol 'a'");
766        ig.add_symbol("b".into(), vec![0.0, 1.0], 1.0)
767            .expect("test setup: add symbol 'b'");
768        let err = ig.add_symbol("c".into(), vec![0.5, 0.5], 1.0).unwrap_err();
769        assert_eq!(err, NsError::MaxSymbolsReached);
770    }
771
772    #[test]
773    fn add_symbol_clamps_confidence() {
774        let mut ig = default_integrator();
775        let id = ig
776            .add_symbol("over".into(), basis_emb(4, 0), 1.5)
777            .expect("test setup: add symbol 'over'");
778        assert!((ig.symbols[id.0].confidence - 1.0).abs() < 1e-9);
779        let id2 = ig
780            .add_symbol("neg".into(), basis_emb(4, 1), -0.3)
781            .expect("test setup: add symbol 'neg'");
782        assert!((ig.symbols[id2.0].confidence).abs() < 1e-9);
783    }
784
785    // -----------------------------------------------------------------------
786    // add_rule tests
787    // -----------------------------------------------------------------------
788
789    #[test]
790    fn add_rule_valid() {
791        let mut ig = default_integrator();
792        let a = ig
793            .add_symbol("a".into(), basis_emb(4, 0), 1.0)
794            .expect("test setup: add symbol 'a'");
795        let b = ig
796            .add_symbol("b".into(), basis_emb(4, 1), 1.0)
797            .expect("test setup: add symbol 'b'");
798        let result = ig.add_rule(LogicalRule {
799            head: a,
800            body: vec![b],
801            weight: 0.8,
802            rule_type: RuleType::Definite,
803        });
804        assert!(result.is_ok());
805    }
806
807    #[test]
808    fn add_rule_invalid_head() {
809        let mut ig = default_integrator();
810        let err = ig
811            .add_rule(LogicalRule {
812                head: SymbolId(99),
813                body: vec![],
814                weight: 1.0,
815                rule_type: RuleType::Definite,
816            })
817            .unwrap_err();
818        assert!(matches!(err, NsError::InvalidRule(_)));
819    }
820
821    #[test]
822    fn add_rule_invalid_body_sym() {
823        let mut ig = default_integrator();
824        let a = ig
825            .add_symbol("a".into(), basis_emb(4, 0), 1.0)
826            .expect("test setup: add symbol 'a'");
827        let err = ig
828            .add_rule(LogicalRule {
829                head: a,
830                body: vec![SymbolId(50)],
831                weight: 0.9,
832                rule_type: RuleType::Probabilistic,
833            })
834            .unwrap_err();
835        assert!(matches!(err, NsError::InvalidRule(_)));
836    }
837
838    // -----------------------------------------------------------------------
839    // neural_similarity tests
840    // -----------------------------------------------------------------------
841
842    #[test]
843    fn neural_similarity_identical() {
844        let v = vec![1.0, 0.0, 0.0, 0.0];
845        let sim = NeuralSymbolicIntegrator::neural_similarity(&v, &v);
846        assert!((sim - 1.0).abs() < 1e-10);
847    }
848
849    #[test]
850    fn neural_similarity_orthogonal() {
851        let a = vec![1.0, 0.0];
852        let b = vec![0.0, 1.0];
853        let sim = NeuralSymbolicIntegrator::neural_similarity(&a, &b);
854        assert!(sim.abs() < 1e-10);
855    }
856
857    #[test]
858    fn neural_similarity_opposite() {
859        let a = vec![1.0, 0.0];
860        let b = vec![-1.0, 0.0];
861        let sim = NeuralSymbolicIntegrator::neural_similarity(&a, &b);
862        assert!((sim + 1.0).abs() < 1e-10);
863    }
864
865    #[test]
866    fn neural_similarity_zero_vector() {
867        let a = vec![0.0, 0.0];
868        let b = vec![1.0, 0.0];
869        let sim = NeuralSymbolicIntegrator::neural_similarity(&a, &b);
870        assert_eq!(sim, 0.0);
871    }
872
873    #[test]
874    fn neural_similarity_partial() {
875        let a = vec![1.0, 1.0];
876        let b = vec![1.0, 0.0];
877        let sim = NeuralSymbolicIntegrator::neural_similarity(&a, &b);
878        let expected = 1.0 / 2.0_f64.sqrt();
879        assert!((sim - expected).abs() < 1e-10);
880    }
881
882    #[test]
883    fn neural_similarity_clamp() {
884        // Vectors that are exactly parallel should give similarity 1.0;
885        // and the result must be in [-1, 1].
886        let a = vec![3.0, 4.0];
887        let b = vec![6.0, 8.0]; // same direction, double magnitude
888        let sim = NeuralSymbolicIntegrator::neural_similarity(&a, &b);
889        assert!(sim <= 1.0);
890        assert!(sim >= -1.0);
891        assert!((sim - 1.0).abs() < 1e-10);
892    }
893
894    // -----------------------------------------------------------------------
895    // symbolic_forward_chain tests
896    // -----------------------------------------------------------------------
897
898    #[test]
899    fn symbolic_chain_direct_evidence() {
900        let mut ig = default_integrator();
901        let a = ig
902            .add_symbol("a".into(), basis_emb(4, 0), 0.5)
903            .expect("test setup: add symbol 'a'");
904        // Direct evidence overrides symbol confidence.
905        let conf = ig.symbolic_forward_chain(a, &[(a, 0.9)], 3);
906        assert!((conf - 0.9).abs() < 1e-10);
907    }
908
909    #[test]
910    fn symbolic_chain_simple_rule_definite() {
911        let mut ig = default_integrator();
912        let a = ig
913            .add_symbol("a".into(), basis_emb(4, 0), 0.0)
914            .expect("test setup: add symbol 'a'");
915        let b = ig
916            .add_symbol("b".into(), basis_emb(4, 1), 0.0)
917            .expect("test setup: add symbol 'b'");
918        ig.add_rule(LogicalRule {
919            head: a,
920            body: vec![b],
921            weight: 0.9,
922            rule_type: RuleType::Definite,
923        })
924        .expect("test setup: add rule a <- b");
925        let conf = ig.symbolic_forward_chain(a, &[(b, 1.0)], 3);
926        // Definite: 0.9 * 1.0 = 0.9
927        assert!((conf - 0.9).abs() < 1e-9);
928    }
929
930    #[test]
931    fn symbolic_chain_probabilistic_rule() {
932        let mut ig = default_integrator();
933        let a = ig
934            .add_symbol("a".into(), basis_emb(4, 0), 0.0)
935            .expect("test setup: add symbol 'a'");
936        let b = ig
937            .add_symbol("b".into(), basis_emb(4, 1), 0.0)
938            .expect("test setup: add symbol 'b'");
939        ig.add_rule(LogicalRule {
940            head: a,
941            body: vec![b],
942            weight: 1.0,
943            rule_type: RuleType::Probabilistic,
944        })
945        .expect("test setup: add probabilistic rule a <- b");
946        let conf = ig.symbolic_forward_chain(a, &[(b, 0.8)], 3);
947        // Probabilistic: 1.0 * 0.8 * 0.8 = 0.64
948        assert!((conf - 0.64).abs() < 1e-9);
949    }
950
951    #[test]
952    fn symbolic_chain_soft_rule() {
953        let mut ig = default_integrator();
954        let a = ig
955            .add_symbol("a".into(), basis_emb(4, 0), 0.0)
956            .expect("test setup: add symbol 'a'");
957        let b = ig
958            .add_symbol("b".into(), basis_emb(4, 1), 0.0)
959            .expect("test setup: add symbol 'b'");
960        ig.add_rule(LogicalRule {
961            head: a,
962            body: vec![b],
963            weight: 1.0,
964            rule_type: RuleType::Soft { temperature: 1.0 },
965        })
966        .expect("test setup: add soft rule a <- b");
967        let conf = ig.symbolic_forward_chain(a, &[(b, 1.0)], 3);
968        // Soft: sigmoid(1.0/1.0) = 1/(1+e^-1) ≈ 0.731
969        let expected = 1.0 / (1.0 + (-1.0_f64).exp());
970        assert!((conf - expected).abs() < 1e-9);
971    }
972
973    #[test]
974    fn symbolic_chain_empty_body_fact() {
975        let mut ig = default_integrator();
976        let a = ig
977            .add_symbol("a".into(), basis_emb(4, 0), 0.0)
978            .expect("test setup: add symbol 'a'");
979        ig.add_rule(LogicalRule {
980            head: a,
981            body: vec![],
982            weight: 0.7,
983            rule_type: RuleType::Definite,
984        })
985        .expect("test setup: add fact rule for 'a'");
986        // An empty-body rule acts as a fact.
987        let conf = ig.symbolic_forward_chain(a, &[], 3);
988        assert!((conf - 0.7).abs() < 1e-9);
989    }
990
991    #[test]
992    fn symbolic_chain_depth_limit() {
993        let mut ig = default_integrator();
994        let a = ig
995            .add_symbol("a".into(), basis_emb(4, 0), 0.0)
996            .expect("test setup: add symbol 'a'");
997        let b = ig
998            .add_symbol("b".into(), basis_emb(4, 1), 0.0)
999            .expect("test setup: add symbol 'b'");
1000        ig.add_rule(LogicalRule {
1001            head: a,
1002            body: vec![b],
1003            weight: 1.0,
1004            rule_type: RuleType::Definite,
1005        })
1006        .expect("test setup: add rule a <- b");
1007        // depth=0 and no direct evidence → falls back to symbol confidence (0.0)
1008        let conf = ig.symbolic_forward_chain(a, &[], 0);
1009        assert_eq!(conf, 0.0);
1010    }
1011
1012    #[test]
1013    fn symbolic_chain_multi_body() {
1014        let mut ig = default_integrator();
1015        let a = ig
1016            .add_symbol("a".into(), basis_emb(4, 0), 0.0)
1017            .expect("test setup: add symbol 'a'");
1018        let b = ig
1019            .add_symbol("b".into(), basis_emb(4, 1), 0.0)
1020            .expect("test setup: add symbol 'b'");
1021        let c = ig
1022            .add_symbol("c".into(), basis_emb(4, 2), 0.0)
1023            .expect("test setup: add symbol 'c'");
1024        ig.add_rule(LogicalRule {
1025            head: a,
1026            body: vec![b, c],
1027            weight: 1.0,
1028            rule_type: RuleType::Definite,
1029        })
1030        .expect("test setup: add rule a <- b, c");
1031        // body_satisfaction = 0.8 * 0.6 = 0.48; result = 1.0 * 0.48
1032        let conf = ig.symbolic_forward_chain(a, &[(b, 0.8), (c, 0.6)], 3);
1033        assert!((conf - 0.48).abs() < 1e-9);
1034    }
1035
1036    // -----------------------------------------------------------------------
1037    // neural_forward tests
1038    // -----------------------------------------------------------------------
1039
1040    #[test]
1041    fn neural_forward_no_evidence() {
1042        let mut ig = default_integrator();
1043        let a = ig
1044            .add_symbol("a".into(), basis_emb(4, 0), 1.0)
1045            .expect("test setup: add symbol 'a'");
1046        assert_eq!(ig.neural_forward(a, &[]), 0.0);
1047    }
1048
1049    #[test]
1050    fn neural_forward_identical_embedding() {
1051        let mut ig = default_integrator();
1052        let a = ig
1053            .add_symbol("a".into(), basis_emb(4, 0), 1.0)
1054            .expect("test setup: add symbol 'a'");
1055        let b = ig
1056            .add_symbol("b".into(), basis_emb(4, 0), 1.0)
1057            .expect("test setup: add symbol 'b'");
1058        let score = ig.neural_forward(a, &[(b, 0.9)]);
1059        // similarity = 1.0, result = 1.0 * 0.9 = 0.9
1060        assert!((score - 0.9).abs() < 1e-9);
1061    }
1062
1063    #[test]
1064    fn neural_forward_orthogonal_embedding() {
1065        let mut ig = default_integrator();
1066        let a = ig
1067            .add_symbol("a".into(), basis_emb(4, 0), 1.0)
1068            .expect("test setup: add symbol 'a'");
1069        let b = ig
1070            .add_symbol("b".into(), basis_emb(4, 1), 1.0)
1071            .expect("test setup: add symbol 'b'");
1072        let score = ig.neural_forward(a, &[(b, 1.0)]);
1073        assert!(score.abs() < 1e-10);
1074    }
1075
1076    #[test]
1077    fn neural_forward_invalid_target() {
1078        let ig = default_integrator();
1079        // No symbols registered → returns 0.0 gracefully.
1080        assert_eq!(ig.neural_forward(SymbolId(99), &[]), 0.0);
1081    }
1082
1083    // -----------------------------------------------------------------------
1084    // infer tests
1085    // -----------------------------------------------------------------------
1086
1087    #[test]
1088    fn infer_pure_symbolic() {
1089        let mut ig = default_integrator();
1090        let a = ig
1091            .add_symbol("a".into(), basis_emb(4, 0), 0.0)
1092            .expect("test setup: add symbol 'a'");
1093        let b = ig
1094            .add_symbol("b".into(), basis_emb(4, 1), 0.0)
1095            .expect("test setup: add symbol 'b'");
1096        ig.add_rule(LogicalRule {
1097            head: a,
1098            body: vec![b],
1099            weight: 1.0,
1100            rule_type: RuleType::Definite,
1101        })
1102        .expect("test setup: add rule a <- b");
1103        let query = NsQuery {
1104            target: a,
1105            evidence: vec![(b, 1.0)],
1106            mode: InferenceMode::PureSymbolic,
1107        };
1108        let result = ig.infer(&query).expect("test setup: infer pure symbolic");
1109        assert!((result.confidence - 1.0).abs() < 1e-9);
1110        assert!((result.symbolic_contribution - 1.0).abs() < 1e-9);
1111    }
1112
1113    #[test]
1114    fn infer_pure_neural() {
1115        let mut ig = default_integrator();
1116        let a = ig
1117            .add_symbol("a".into(), basis_emb(4, 0), 0.0)
1118            .expect("test setup: add symbol 'a'");
1119        let b = ig
1120            .add_symbol("b".into(), basis_emb(4, 0), 0.0)
1121            .expect("test setup: add symbol 'b'");
1122        let query = NsQuery {
1123            target: a,
1124            evidence: vec![(b, 0.7)],
1125            mode: InferenceMode::PureNeural,
1126        };
1127        let result = ig.infer(&query).expect("test setup: infer pure neural");
1128        // similarity=1.0, conf=0.7 → neural=0.7
1129        assert!((result.confidence - 0.7).abs() < 1e-9);
1130        assert!((result.neural_contribution - 0.7).abs() < 1e-9);
1131    }
1132
1133    #[test]
1134    fn infer_hybrid() {
1135        let mut ig = default_integrator();
1136        let a = ig
1137            .add_symbol("a".into(), basis_emb(4, 0), 0.0)
1138            .expect("test setup: add symbol 'a'");
1139        let b = ig
1140            .add_symbol("b".into(), basis_emb(4, 1), 0.0)
1141            .expect("test setup: add symbol 'b'");
1142        // Rule: a ← b, weight=1.0
1143        ig.add_rule(LogicalRule {
1144            head: a,
1145            body: vec![b],
1146            weight: 1.0,
1147            rule_type: RuleType::Definite,
1148        })
1149        .expect("test setup: add rule a <- b");
1150        // Evidence: b=1.0, but a and b are orthogonal so neural=0
1151        let query = NsQuery {
1152            target: a,
1153            evidence: vec![(b, 1.0)],
1154            mode: InferenceMode::Hybrid { neural_weight: 0.5 },
1155        };
1156        let result = ig.infer(&query).expect("test setup: infer hybrid");
1157        // symbolic=1.0, neural≈0.0, hybrid=0.5*0 + 0.5*1.0 = 0.5
1158        assert!((result.confidence - 0.5).abs() < 1e-9);
1159    }
1160
1161    #[test]
1162    fn infer_target_not_found() {
1163        let mut ig = default_integrator();
1164        let query = NsQuery {
1165            target: SymbolId(99),
1166            evidence: vec![],
1167            mode: InferenceMode::PureSymbolic,
1168        };
1169        assert_eq!(ig.infer(&query).unwrap_err(), NsError::SymbolNotFound(99));
1170    }
1171
1172    #[test]
1173    fn infer_increments_counter() {
1174        let mut ig = default_integrator();
1175        let a = ig
1176            .add_symbol("a".into(), basis_emb(4, 0), 1.0)
1177            .expect("test setup: add symbol 'a'");
1178        let query = NsQuery {
1179            target: a,
1180            evidence: vec![],
1181            mode: InferenceMode::PureSymbolic,
1182        };
1183        ig.infer(&query).expect("test setup: first infer call");
1184        ig.infer(&query).expect("test setup: second infer call");
1185        assert_eq!(ig.total_inferences, 2);
1186    }
1187
1188    #[test]
1189    fn infer_result_confidence_clamped() {
1190        // Rule weight > 1.0 should still produce clamped result.
1191        let mut ig = default_integrator();
1192        let a = ig
1193            .add_symbol("a".into(), basis_emb(4, 0), 0.0)
1194            .expect("test setup: add symbol 'a'");
1195        ig.add_rule(LogicalRule {
1196            head: a,
1197            body: vec![],
1198            weight: 2.0, // intentionally > 1
1199            rule_type: RuleType::Definite,
1200        })
1201        .expect("test setup: add rule with weight > 1");
1202        let query = NsQuery {
1203            target: a,
1204            evidence: vec![],
1205            mode: InferenceMode::PureSymbolic,
1206        };
1207        let result = ig
1208            .infer(&query)
1209            .expect("test setup: infer with oversized weight");
1210        assert!(result.confidence <= 1.0);
1211    }
1212
1213    #[test]
1214    fn infer_explanation_nonempty_for_matching_rule() {
1215        let mut ig = default_integrator();
1216        let a = ig
1217            .add_symbol("a".into(), basis_emb(4, 0), 0.0)
1218            .expect("test setup: add symbol 'a'");
1219        let b = ig
1220            .add_symbol("b".into(), basis_emb(4, 1), 0.0)
1221            .expect("test setup: add symbol 'b'");
1222        ig.add_rule(LogicalRule {
1223            head: a,
1224            body: vec![b],
1225            weight: 0.9,
1226            rule_type: RuleType::Definite,
1227        })
1228        .expect("test setup: add rule a <- b");
1229        let query = NsQuery {
1230            target: a,
1231            evidence: vec![(b, 1.0)],
1232            mode: InferenceMode::PureSymbolic,
1233        };
1234        let result = ig.infer(&query).expect("test setup: infer for explanation");
1235        assert!(!result.explanation.is_empty());
1236        let rule_exp = result
1237            .explanation
1238            .iter()
1239            .any(|e| e.contains("rule_a_from_"));
1240        assert!(
1241            rule_exp,
1242            "expected rule explanation, got: {:?}",
1243            result.explanation
1244        );
1245    }
1246
1247    // -----------------------------------------------------------------------
1248    // explain_symbol tests
1249    // -----------------------------------------------------------------------
1250
1251    #[test]
1252    fn explain_symbol_not_found() {
1253        let ig = default_integrator();
1254        assert_eq!(
1255            ig.explain_symbol(SymbolId(0)),
1256            Err(NsError::SymbolNotFound(0))
1257        );
1258    }
1259
1260    #[test]
1261    fn explain_symbol_no_rules() {
1262        let mut ig = default_integrator();
1263        let a = ig
1264            .add_symbol("a".into(), basis_emb(4, 0), 1.0)
1265            .expect("test setup: add symbol 'a'");
1266        let lines = ig
1267            .explain_symbol(a)
1268            .expect("test setup: explain symbol 'a'");
1269        assert!(!lines.is_empty());
1270        assert!(lines[0].contains("no associated rules"));
1271    }
1272
1273    #[test]
1274    fn explain_symbol_as_head() {
1275        let mut ig = default_integrator();
1276        let a = ig
1277            .add_symbol("a".into(), basis_emb(4, 0), 1.0)
1278            .expect("test setup: add symbol 'a'");
1279        let b = ig
1280            .add_symbol("b".into(), basis_emb(4, 1), 1.0)
1281            .expect("test setup: add symbol 'b'");
1282        ig.add_rule(LogicalRule {
1283            head: a,
1284            body: vec![b],
1285            weight: 0.8,
1286            rule_type: RuleType::Definite,
1287        })
1288        .expect("test setup: add rule a <- b");
1289        let lines = ig
1290            .explain_symbol(a)
1291            .expect("test setup: explain symbol 'a'");
1292        assert!(lines.iter().any(|l| l.contains("a ←")));
1293    }
1294
1295    #[test]
1296    fn explain_symbol_as_body() {
1297        let mut ig = default_integrator();
1298        let a = ig
1299            .add_symbol("a".into(), basis_emb(4, 0), 1.0)
1300            .expect("test setup: add symbol 'a'");
1301        let b = ig
1302            .add_symbol("b".into(), basis_emb(4, 1), 1.0)
1303            .expect("test setup: add symbol 'b'");
1304        ig.add_rule(LogicalRule {
1305            head: a,
1306            body: vec![b],
1307            weight: 0.8,
1308            rule_type: RuleType::Definite,
1309        })
1310        .expect("test setup: add rule a <- b");
1311        // b appears in the body; explain_symbol(b) should mention it.
1312        let lines = ig
1313            .explain_symbol(b)
1314            .expect("test setup: explain symbol 'b'");
1315        assert!(lines.iter().any(|l| l.contains('b')));
1316    }
1317
1318    // -----------------------------------------------------------------------
1319    // similar_symbols tests
1320    // -----------------------------------------------------------------------
1321
1322    #[test]
1323    fn similar_symbols_not_found() {
1324        let ig = default_integrator();
1325        assert_eq!(
1326            ig.similar_symbols(SymbolId(99), 5),
1327            Err(NsError::SymbolNotFound(99))
1328        );
1329    }
1330
1331    #[test]
1332    fn similar_symbols_returns_top_k() {
1333        let mut ig = NeuralSymbolicIntegrator::new(IntegratorConfig {
1334            embedding_dim: 4,
1335            max_symbols: 100,
1336            inference_depth: 3,
1337            similarity_threshold: 0.0, // accept all
1338        });
1339        let a = ig
1340            .add_symbol("a".into(), basis_emb(4, 0), 1.0)
1341            .expect("test setup: add symbol 'a'");
1342        let _b = ig
1343            .add_symbol("b".into(), basis_emb(4, 0), 1.0)
1344            .expect("test setup: add symbol 'b'"); // sim=1
1345        let _c = ig
1346            .add_symbol("c".into(), basis_emb(4, 0), 1.0)
1347            .expect("test setup: add symbol 'c'"); // sim=1
1348        let _d = ig
1349            .add_symbol("d".into(), basis_emb(4, 1), 1.0)
1350            .expect("test setup: add symbol 'd'"); // sim=0
1351
1352        let results = ig
1353            .similar_symbols(a, 2)
1354            .expect("test setup: similar symbols for 'a'");
1355        // Should return at most 2 entries.
1356        assert!(results.len() <= 2);
1357    }
1358
1359    #[test]
1360    fn similar_symbols_excludes_self() {
1361        let mut ig = NeuralSymbolicIntegrator::new(IntegratorConfig {
1362            embedding_dim: 4,
1363            max_symbols: 100,
1364            inference_depth: 3,
1365            similarity_threshold: 0.0,
1366        });
1367        let a = ig
1368            .add_symbol("a".into(), basis_emb(4, 0), 1.0)
1369            .expect("test setup: add symbol 'a'");
1370        let results = ig
1371            .similar_symbols(a, 10)
1372            .expect("test setup: similar symbols for 'a'");
1373        // Only `a` is registered — no results possible.
1374        assert!(results.is_empty());
1375    }
1376
1377    #[test]
1378    fn similar_symbols_threshold_filters() {
1379        let mut ig = NeuralSymbolicIntegrator::new(IntegratorConfig {
1380            embedding_dim: 4,
1381            max_symbols: 100,
1382            inference_depth: 3,
1383            similarity_threshold: 0.9, // very high threshold
1384        });
1385        let a = ig
1386            .add_symbol("a".into(), basis_emb(4, 0), 1.0)
1387            .expect("test setup: add symbol 'a'");
1388        // b is not identical → similarity < 1.0 with basis vectors...
1389        // but let us use an orthogonal one to ensure it's filtered.
1390        let _b = ig
1391            .add_symbol("b".into(), basis_emb(4, 1), 1.0)
1392            .expect("test setup: add symbol 'b'");
1393        let results = ig
1394            .similar_symbols(a, 10)
1395            .expect("test setup: similar symbols for 'a'");
1396        assert!(
1397            results.is_empty(),
1398            "orthogonal vector should be below threshold"
1399        );
1400    }
1401
1402    #[test]
1403    fn similar_symbols_sorted_desc() {
1404        let mut ig = NeuralSymbolicIntegrator::new(IntegratorConfig {
1405            embedding_dim: 4,
1406            max_symbols: 100,
1407            inference_depth: 3,
1408            similarity_threshold: 0.0,
1409        });
1410        let a = ig
1411            .add_symbol("a".into(), basis_emb(4, 0), 1.0)
1412            .expect("test setup: add symbol 'a'");
1413        // b has high similarity (same direction), c has lower.
1414        ig.add_symbol("b".into(), basis_emb(4, 0), 1.0)
1415            .expect("test setup: add symbol 'b'");
1416        let partial: Vec<f64> = {
1417            let mut v = vec![0.0; 4];
1418            v[0] = 0.7;
1419            v[1] = 0.3;
1420            // normalise
1421            let norm = (0.7_f64 * 0.7 + 0.3_f64 * 0.3).sqrt();
1422            v.iter_mut().for_each(|x| *x /= norm);
1423            v
1424        };
1425        ig.add_symbol("c".into(), partial, 1.0)
1426            .expect("test setup: add symbol 'c'");
1427        let results = ig
1428            .similar_symbols(a, 10)
1429            .expect("test setup: similar symbols for 'a'");
1430        // Sorted descending: similarity of b > c.
1431        if results.len() >= 2 {
1432            assert!(results[0].1 >= results[1].1);
1433        }
1434    }
1435
1436    // -----------------------------------------------------------------------
1437    // stats tests
1438    // -----------------------------------------------------------------------
1439
1440    #[test]
1441    fn stats_empty_integrator() {
1442        let ig = default_integrator();
1443        let s = ig.stats();
1444        assert_eq!(s.symbol_count, 0);
1445        assert_eq!(s.rule_count, 0);
1446        assert_eq!(s.total_inferences, 0);
1447        assert_eq!(s.avg_embedding_norm, 0.0);
1448    }
1449
1450    #[test]
1451    fn stats_counts_symbols_and_rules() {
1452        let mut ig = default_integrator();
1453        let a = ig
1454            .add_symbol("a".into(), basis_emb(4, 0), 1.0)
1455            .expect("test setup: add symbol 'a'");
1456        let b = ig
1457            .add_symbol("b".into(), basis_emb(4, 1), 1.0)
1458            .expect("test setup: add symbol 'b'");
1459        ig.add_rule(LogicalRule {
1460            head: a,
1461            body: vec![b],
1462            weight: 0.9,
1463            rule_type: RuleType::Definite,
1464        })
1465        .expect("test setup: add rule a <- b");
1466        let s = ig.stats();
1467        assert_eq!(s.symbol_count, 2);
1468        assert_eq!(s.rule_count, 1);
1469    }
1470
1471    #[test]
1472    fn stats_avg_norm_unit_vectors() {
1473        let mut ig = default_integrator();
1474        ig.add_symbol("a".into(), basis_emb(4, 0), 1.0)
1475            .expect("test setup: add symbol 'a'");
1476        ig.add_symbol("b".into(), basis_emb(4, 1), 1.0)
1477            .expect("test setup: add symbol 'b'");
1478        let s = ig.stats();
1479        // Both are unit vectors; average norm should be 1.0.
1480        assert!((s.avg_embedding_norm - 1.0).abs() < 1e-10);
1481    }
1482
1483    #[test]
1484    fn stats_tracks_inferences() {
1485        let mut ig = default_integrator();
1486        let a = ig
1487            .add_symbol("a".into(), basis_emb(4, 0), 0.5)
1488            .expect("test setup: add symbol 'a'");
1489        let q = NsQuery {
1490            target: a,
1491            evidence: vec![],
1492            mode: InferenceMode::PureSymbolic,
1493        };
1494        ig.infer(&q).expect("test setup: first infer call");
1495        ig.infer(&q).expect("test setup: second infer call");
1496        ig.infer(&q).expect("test setup: third infer call");
1497        assert_eq!(ig.stats().total_inferences, 3);
1498    }
1499
1500    // -----------------------------------------------------------------------
1501    // Integration / scenario tests
1502    // -----------------------------------------------------------------------
1503
1504    #[test]
1505    fn scenario_chain_of_rules() {
1506        // a ← b ← c, with c in evidence.
1507        let mut ig = default_integrator();
1508        let a = ig
1509            .add_symbol("a".into(), uniform_emb(4, 1.0), 0.0)
1510            .expect("test setup: add symbol 'a'");
1511        let b = ig
1512            .add_symbol("b".into(), uniform_emb(4, 1.0), 0.0)
1513            .expect("test setup: add symbol 'b'");
1514        let c = ig
1515            .add_symbol("c".into(), uniform_emb(4, 1.0), 0.0)
1516            .expect("test setup: add symbol 'c'");
1517        ig.add_rule(LogicalRule {
1518            head: a,
1519            body: vec![b],
1520            weight: 0.9,
1521            rule_type: RuleType::Definite,
1522        })
1523        .expect("test setup: add rule a <- b");
1524        ig.add_rule(LogicalRule {
1525            head: b,
1526            body: vec![c],
1527            weight: 0.8,
1528            rule_type: RuleType::Definite,
1529        })
1530        .expect("test setup: add rule b <- c");
1531        let q = NsQuery {
1532            target: a,
1533            evidence: vec![(c, 1.0)],
1534            mode: InferenceMode::PureSymbolic,
1535        };
1536        let result = ig.infer(&q).expect("test setup: infer chain a <- b <- c");
1537        // Chain: 0.9 * (0.8 * 1.0) = 0.72
1538        assert!((result.confidence - 0.72).abs() < 1e-9);
1539    }
1540
1541    #[test]
1542    fn scenario_multiple_rules_max_selected() {
1543        let mut ig = default_integrator();
1544        let a = ig
1545            .add_symbol("a".into(), basis_emb(4, 0), 0.0)
1546            .expect("test setup: add symbol 'a'");
1547        let b = ig
1548            .add_symbol("b".into(), basis_emb(4, 1), 0.0)
1549            .expect("test setup: add symbol 'b'");
1550        let c = ig
1551            .add_symbol("c".into(), basis_emb(4, 2), 0.0)
1552            .expect("test setup: add symbol 'c'");
1553        // Two rules for a; one has higher confidence.
1554        ig.add_rule(LogicalRule {
1555            head: a,
1556            body: vec![b],
1557            weight: 0.3,
1558            rule_type: RuleType::Definite,
1559        })
1560        .expect("test setup: add low-weight rule a <- b");
1561        ig.add_rule(LogicalRule {
1562            head: a,
1563            body: vec![c],
1564            weight: 0.9,
1565            rule_type: RuleType::Definite,
1566        })
1567        .expect("test setup: add high-weight rule a <- c");
1568        let q = NsQuery {
1569            target: a,
1570            evidence: vec![(b, 1.0), (c, 1.0)],
1571            mode: InferenceMode::PureSymbolic,
1572        };
1573        let result = ig
1574            .infer(&q)
1575            .expect("test setup: infer with competing rules");
1576        // Max of 0.3 and 0.9 → 0.9
1577        assert!((result.confidence - 0.9).abs() < 1e-9);
1578    }
1579
1580    #[test]
1581    fn scenario_hybrid_blends_both() {
1582        let mut ig = default_integrator();
1583        // Target and evidence share the same embedding → neural=1*conf=0.6
1584        let a = ig
1585            .add_symbol("a".into(), basis_emb(4, 0), 0.0)
1586            .expect("test setup: add symbol 'a'");
1587        let b = ig
1588            .add_symbol("b".into(), basis_emb(4, 0), 0.0)
1589            .expect("test setup: add symbol 'b'"); // identical emb
1590                                                   // Symbolic: a ← b, weight=0.8
1591        ig.add_rule(LogicalRule {
1592            head: a,
1593            body: vec![b],
1594            weight: 0.8,
1595            rule_type: RuleType::Definite,
1596        })
1597        .expect("test setup: add rule a <- b");
1598        let q = NsQuery {
1599            target: a,
1600            evidence: vec![(b, 0.6)],
1601            mode: InferenceMode::Hybrid { neural_weight: 0.4 },
1602        };
1603        let result = ig.infer(&q).expect("test setup: infer hybrid blend");
1604        // neural=0.6, symbolic=0.8*0.6=0.48
1605        // hybrid = 0.4*0.6 + 0.6*0.48 = 0.24 + 0.288 = 0.528
1606        assert!((result.confidence - 0.528).abs() < 1e-9);
1607    }
1608
1609    #[test]
1610    fn scenario_soft_rule_low_temperature() {
1611        // At very low temperature, soft rule approaches a step function.
1612        let mut ig = default_integrator();
1613        let a = ig
1614            .add_symbol("a".into(), basis_emb(4, 0), 0.0)
1615            .expect("test setup: add symbol 'a'");
1616        let b = ig
1617            .add_symbol("b".into(), basis_emb(4, 1), 0.0)
1618            .expect("test setup: add symbol 'b'");
1619        ig.add_rule(LogicalRule {
1620            head: a,
1621            body: vec![b],
1622            weight: 1.0,
1623            rule_type: RuleType::Soft { temperature: 0.01 },
1624        })
1625        .expect("test setup: add soft rule a <- b");
1626        // Body sat = 1.0 → sigmoid(1.0/0.01)=sigmoid(100)≈1.0
1627        let conf = ig.symbolic_forward_chain(a, &[(b, 1.0)], 3);
1628        assert!(conf > 0.99, "expected ≈1.0, got {}", conf);
1629    }
1630}