Skip to main content

ipfrs_tensorlogic/
rule_index.rs

1//! Multi-dimensional index over TensorLogic rules.
2//!
3//! Enables fast lookup by predicate, arity, confidence range, and dependency relationships.
4
5use std::collections::HashMap;
6
7// ---------------------------------------------------------------------------
8// RuleArity
9// ---------------------------------------------------------------------------
10
11/// Categorizes the number of arguments (head arity) of a rule.
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
13pub enum RuleArity {
14    /// 0 arguments
15    Nullary,
16    /// 1 argument
17    Unary,
18    /// 2 arguments
19    Binary,
20    /// 3 arguments
21    Ternary,
22    /// 4 or more arguments
23    NAry(usize),
24}
25
26impl RuleArity {
27    /// Canonical numeric rank used for ordering.
28    fn rank(self) -> usize {
29        match self {
30            Self::Nullary => 0,
31            Self::Unary => 1,
32            Self::Binary => 2,
33            Self::Ternary => 3,
34            Self::NAry(n) => n,
35        }
36    }
37}
38
39impl PartialOrd for RuleArity {
40    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
41        Some(self.cmp(other))
42    }
43}
44
45impl Ord for RuleArity {
46    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
47        self.rank().cmp(&other.rank())
48    }
49}
50
51// ---------------------------------------------------------------------------
52// IndexedRule
53// ---------------------------------------------------------------------------
54
55/// A rule stored inside the [`TensorRuleIndex`].
56#[derive(Clone, Debug)]
57pub struct IndexedRule {
58    /// Unique identifier for the rule.
59    pub rule_id: u64,
60    /// The predicate symbol at the rule head.
61    pub head_predicate: String,
62    /// Number of head arguments expressed as an arity category.
63    pub arity: RuleArity,
64    /// Confidence score in `[0.0, 1.0]`.
65    pub confidence: f64,
66    /// Predicate symbols that appear in the rule body.
67    pub body_predicates: Vec<String>,
68    /// Rule IDs that this rule explicitly depends on.
69    pub depends_on: Vec<u64>,
70    /// Whether the rule is currently active.
71    pub active: bool,
72}
73
74// ---------------------------------------------------------------------------
75// RuleQuery
76// ---------------------------------------------------------------------------
77
78/// Filter specification for [`TensorRuleIndex::query`].
79#[derive(Clone, Debug, Default)]
80pub struct RuleQuery {
81    /// Restrict to rules whose head predicate equals this value.
82    pub head_predicate: Option<String>,
83    /// Restrict to rules with this arity.
84    pub arity: Option<RuleArity>,
85    /// Restrict to rules with confidence >= this threshold.
86    pub min_confidence: Option<f64>,
87    /// Restrict to rules whose body contains this predicate.
88    pub body_contains: Option<String>,
89    /// When `true`, inactive rules are excluded.
90    pub active_only: bool,
91}
92
93// ---------------------------------------------------------------------------
94// RuleIndexStats
95// ---------------------------------------------------------------------------
96
97/// Aggregate statistics about a [`TensorRuleIndex`].
98#[derive(Clone, Debug, PartialEq)]
99pub struct RuleIndexStats {
100    /// Total number of rules (active and inactive).
101    pub total_rules: usize,
102    /// Number of active rules.
103    pub active_rules: usize,
104    /// Number of distinct head predicates.
105    pub unique_predicates: usize,
106    /// Mean confidence; `0.0` when there are no rules.
107    pub avg_confidence: f64,
108}
109
110// ---------------------------------------------------------------------------
111// TensorRuleIndex
112// ---------------------------------------------------------------------------
113
114/// Multi-dimensional index over [`IndexedRule`] entries.
115///
116/// Supports O(1) lookup by rule ID and predicate, plus flexible filtered
117/// queries across predicate, arity, confidence, body content, and active status.
118#[derive(Debug, Default)]
119pub struct TensorRuleIndex {
120    /// Primary store: rule_id → rule.
121    pub rules: HashMap<u64, IndexedRule>,
122    /// Secondary index: head_predicate → list of rule_ids.
123    pub predicate_index: HashMap<String, Vec<u64>>,
124}
125
126impl TensorRuleIndex {
127    /// Create an empty index.
128    pub fn new() -> Self {
129        Self::default()
130    }
131
132    /// Insert a rule, updating both the primary store and the predicate index.
133    ///
134    /// If a rule with the same `rule_id` already exists it is replaced, and the
135    /// predicate index is kept consistent.
136    pub fn insert(&mut self, rule: IndexedRule) {
137        // If there is an existing entry with the same id, remove its old
138        // predicate-index entry first to avoid stale references.
139        if let Some(old) = self.rules.get(&rule.rule_id) {
140            let old_pred = old.head_predicate.clone();
141            if old_pred != rule.head_predicate {
142                if let Some(ids) = self.predicate_index.get_mut(&old_pred) {
143                    ids.retain(|&id| id != rule.rule_id);
144                    if ids.is_empty() {
145                        self.predicate_index.remove(&old_pred);
146                    }
147                }
148            }
149        }
150
151        // Update predicate index.
152        self.predicate_index
153            .entry(rule.head_predicate.clone())
154            .or_default()
155            .push(rule.rule_id);
156
157        self.rules.insert(rule.rule_id, rule);
158    }
159
160    /// Remove the rule with the given `rule_id`.
161    ///
162    /// Returns `true` on success, `false` when no such rule exists.
163    pub fn remove(&mut self, rule_id: u64) -> bool {
164        match self.rules.remove(&rule_id) {
165            None => false,
166            Some(rule) => {
167                if let Some(ids) = self.predicate_index.get_mut(&rule.head_predicate) {
168                    ids.retain(|&id| id != rule_id);
169                    if ids.is_empty() {
170                        self.predicate_index.remove(&rule.head_predicate);
171                    }
172                }
173                true
174            }
175        }
176    }
177
178    /// Set a rule's `active` field to `false`.
179    ///
180    /// Returns `true` on success, `false` when no such rule exists.
181    pub fn deactivate(&mut self, rule_id: u64) -> bool {
182        match self.rules.get_mut(&rule_id) {
183            None => false,
184            Some(rule) => {
185                rule.active = false;
186                true
187            }
188        }
189    }
190
191    /// Set a rule's `active` field to `true`.
192    ///
193    /// Returns `true` on success, `false` when no such rule exists.
194    pub fn activate(&mut self, rule_id: u64) -> bool {
195        match self.rules.get_mut(&rule_id) {
196            None => false,
197            Some(rule) => {
198                rule.active = true;
199                true
200            }
201        }
202    }
203
204    /// Return rules that match all criteria in `q`.
205    ///
206    /// Results are sorted by confidence descending; ties are broken by
207    /// `rule_id` ascending.
208    pub fn query(&self, q: &RuleQuery) -> Vec<&IndexedRule> {
209        let mut results: Vec<&IndexedRule> = self
210            .rules
211            .values()
212            .filter(|r| {
213                if q.active_only && !r.active {
214                    return false;
215                }
216                if let Some(ref pred) = q.head_predicate {
217                    if &r.head_predicate != pred {
218                        return false;
219                    }
220                }
221                if let Some(arity) = q.arity {
222                    if r.arity != arity {
223                        return false;
224                    }
225                }
226                if let Some(min_conf) = q.min_confidence {
227                    if r.confidence < min_conf {
228                        return false;
229                    }
230                }
231                if let Some(ref body_pred) = q.body_contains {
232                    if !r.body_predicates.iter().any(|bp| bp == body_pred) {
233                        return false;
234                    }
235                }
236                true
237            })
238            .collect();
239
240        results.sort_by(|a, b| {
241            b.confidence
242                .partial_cmp(&a.confidence)
243                .unwrap_or(std::cmp::Ordering::Equal)
244                .then_with(|| a.rule_id.cmp(&b.rule_id))
245        });
246
247        results
248    }
249
250    /// Return all rules whose `depends_on` list contains `rule_id`, sorted by
251    /// `rule_id` ascending.
252    pub fn dependents_of(&self, rule_id: u64) -> Vec<&IndexedRule> {
253        let mut results: Vec<&IndexedRule> = self
254            .rules
255            .values()
256            .filter(|r| r.depends_on.contains(&rule_id))
257            .collect();
258        results.sort_by_key(|r| r.rule_id);
259        results
260    }
261
262    /// Return all rules with the given head predicate, sorted by confidence
263    /// descending.
264    ///
265    /// Uses the predicate index for efficient lookup.
266    pub fn rules_for_predicate(&self, predicate: &str) -> Vec<&IndexedRule> {
267        let ids = match self.predicate_index.get(predicate) {
268            None => return Vec::new(),
269            Some(ids) => ids,
270        };
271
272        let mut results: Vec<&IndexedRule> =
273            ids.iter().filter_map(|id| self.rules.get(id)).collect();
274
275        results.sort_by(|a, b| {
276            b.confidence
277                .partial_cmp(&a.confidence)
278                .unwrap_or(std::cmp::Ordering::Equal)
279                .then_with(|| a.rule_id.cmp(&b.rule_id))
280        });
281
282        results
283    }
284
285    /// Compute aggregate statistics over the current rule set.
286    pub fn stats(&self) -> RuleIndexStats {
287        let total_rules = self.rules.len();
288        let active_rules = self.rules.values().filter(|r| r.active).count();
289        let unique_predicates = self.predicate_index.len();
290
291        let avg_confidence = if total_rules == 0 {
292            0.0
293        } else {
294            let sum: f64 = self.rules.values().map(|r| r.confidence).sum();
295            sum / total_rules as f64
296        };
297
298        RuleIndexStats {
299            total_rules,
300            active_rules,
301            unique_predicates,
302            avg_confidence,
303        }
304    }
305}
306
307// ---------------------------------------------------------------------------
308// Tests
309// ---------------------------------------------------------------------------
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    // ---- helpers -----------------------------------------------------------
316
317    fn make_rule(
318        rule_id: u64,
319        head_predicate: &str,
320        arity: RuleArity,
321        confidence: f64,
322        body_predicates: Vec<&str>,
323        depends_on: Vec<u64>,
324        active: bool,
325    ) -> IndexedRule {
326        IndexedRule {
327            rule_id,
328            head_predicate: head_predicate.to_string(),
329            arity,
330            confidence,
331            body_predicates: body_predicates.into_iter().map(str::to_string).collect(),
332            depends_on,
333            active,
334        }
335    }
336
337    // ---- new() -------------------------------------------------------------
338
339    #[test]
340    fn new_starts_empty() {
341        let idx = TensorRuleIndex::new();
342        assert!(idx.rules.is_empty());
343        assert!(idx.predicate_index.is_empty());
344    }
345
346    // ---- insert ------------------------------------------------------------
347
348    #[test]
349    fn insert_stores_rule() {
350        let mut idx = TensorRuleIndex::new();
351        let rule = make_rule(1, "parent", RuleArity::Binary, 0.9, vec![], vec![], true);
352        idx.insert(rule);
353        assert_eq!(idx.rules.len(), 1);
354        assert!(idx.rules.contains_key(&1));
355    }
356
357    #[test]
358    fn insert_updates_predicate_index() {
359        let mut idx = TensorRuleIndex::new();
360        let rule = make_rule(1, "parent", RuleArity::Binary, 0.9, vec![], vec![], true);
361        idx.insert(rule);
362        assert!(idx.predicate_index.contains_key("parent"));
363        assert_eq!(idx.predicate_index["parent"], vec![1]);
364    }
365
366    #[test]
367    fn insert_multiple_same_predicate() {
368        let mut idx = TensorRuleIndex::new();
369        idx.insert(make_rule(
370            1,
371            "parent",
372            RuleArity::Binary,
373            0.9,
374            vec![],
375            vec![],
376            true,
377        ));
378        idx.insert(make_rule(
379            2,
380            "parent",
381            RuleArity::Binary,
382            0.7,
383            vec![],
384            vec![],
385            true,
386        ));
387        let ids = &idx.predicate_index["parent"];
388        assert_eq!(ids.len(), 2);
389        assert!(ids.contains(&1));
390        assert!(ids.contains(&2));
391    }
392
393    // ---- remove ------------------------------------------------------------
394
395    #[test]
396    fn remove_removes_from_both_stores() {
397        let mut idx = TensorRuleIndex::new();
398        idx.insert(make_rule(
399            1,
400            "parent",
401            RuleArity::Binary,
402            0.9,
403            vec![],
404            vec![],
405            true,
406        ));
407        let removed = idx.remove(1);
408        assert!(removed);
409        assert!(!idx.rules.contains_key(&1));
410        assert!(!idx.predicate_index.contains_key("parent"));
411    }
412
413    #[test]
414    fn remove_returns_false_for_unknown() {
415        let mut idx = TensorRuleIndex::new();
416        assert!(!idx.remove(999));
417    }
418
419    #[test]
420    fn remove_keeps_other_rules_with_same_predicate() {
421        let mut idx = TensorRuleIndex::new();
422        idx.insert(make_rule(
423            1,
424            "parent",
425            RuleArity::Binary,
426            0.9,
427            vec![],
428            vec![],
429            true,
430        ));
431        idx.insert(make_rule(
432            2,
433            "parent",
434            RuleArity::Binary,
435            0.7,
436            vec![],
437            vec![],
438            true,
439        ));
440        idx.remove(1);
441        assert!(idx.predicate_index.contains_key("parent"));
442        assert_eq!(idx.predicate_index["parent"], vec![2]);
443    }
444
445    // ---- deactivate / activate ---------------------------------------------
446
447    #[test]
448    fn deactivate_sets_active_false() {
449        let mut idx = TensorRuleIndex::new();
450        idx.insert(make_rule(
451            1,
452            "parent",
453            RuleArity::Binary,
454            0.9,
455            vec![],
456            vec![],
457            true,
458        ));
459        assert!(idx.deactivate(1));
460        assert!(!idx.rules[&1].active);
461    }
462
463    #[test]
464    fn activate_sets_active_true() {
465        let mut idx = TensorRuleIndex::new();
466        idx.insert(make_rule(
467            1,
468            "parent",
469            RuleArity::Binary,
470            0.9,
471            vec![],
472            vec![],
473            false,
474        ));
475        assert!(idx.activate(1));
476        assert!(idx.rules[&1].active);
477    }
478
479    #[test]
480    fn deactivate_returns_false_for_unknown() {
481        let mut idx = TensorRuleIndex::new();
482        assert!(!idx.deactivate(42));
483    }
484
485    #[test]
486    fn activate_returns_false_for_unknown() {
487        let mut idx = TensorRuleIndex::new();
488        assert!(!idx.activate(42));
489    }
490
491    // ---- query: head_predicate filter --------------------------------------
492
493    #[test]
494    fn query_head_predicate_filter() {
495        let mut idx = TensorRuleIndex::new();
496        idx.insert(make_rule(
497            1,
498            "parent",
499            RuleArity::Binary,
500            0.9,
501            vec![],
502            vec![],
503            true,
504        ));
505        idx.insert(make_rule(
506            2,
507            "ancestor",
508            RuleArity::Binary,
509            0.8,
510            vec![],
511            vec![],
512            true,
513        ));
514
515        let q = RuleQuery {
516            head_predicate: Some("parent".to_string()),
517            ..Default::default()
518        };
519        let results = idx.query(&q);
520        assert_eq!(results.len(), 1);
521        assert_eq!(results[0].rule_id, 1);
522    }
523
524    // ---- query: arity filter -----------------------------------------------
525
526    #[test]
527    fn query_arity_filter() {
528        let mut idx = TensorRuleIndex::new();
529        idx.insert(make_rule(
530            1,
531            "a",
532            RuleArity::Unary,
533            0.9,
534            vec![],
535            vec![],
536            true,
537        ));
538        idx.insert(make_rule(
539            2,
540            "b",
541            RuleArity::Binary,
542            0.8,
543            vec![],
544            vec![],
545            true,
546        ));
547
548        let q = RuleQuery {
549            arity: Some(RuleArity::Unary),
550            ..Default::default()
551        };
552        let results = idx.query(&q);
553        assert_eq!(results.len(), 1);
554        assert_eq!(results[0].rule_id, 1);
555    }
556
557    // ---- query: min_confidence filter --------------------------------------
558
559    #[test]
560    fn query_min_confidence_filter() {
561        let mut idx = TensorRuleIndex::new();
562        idx.insert(make_rule(
563            1,
564            "a",
565            RuleArity::Nullary,
566            0.9,
567            vec![],
568            vec![],
569            true,
570        ));
571        idx.insert(make_rule(
572            2,
573            "b",
574            RuleArity::Nullary,
575            0.3,
576            vec![],
577            vec![],
578            true,
579        ));
580
581        let q = RuleQuery {
582            min_confidence: Some(0.5),
583            ..Default::default()
584        };
585        let results = idx.query(&q);
586        assert_eq!(results.len(), 1);
587        assert_eq!(results[0].rule_id, 1);
588    }
589
590    // ---- query: body_contains filter ---------------------------------------
591
592    #[test]
593    fn query_body_contains_filter() {
594        let mut idx = TensorRuleIndex::new();
595        idx.insert(make_rule(
596            1,
597            "a",
598            RuleArity::Unary,
599            0.9,
600            vec!["child", "parent"],
601            vec![],
602            true,
603        ));
604        idx.insert(make_rule(
605            2,
606            "b",
607            RuleArity::Unary,
608            0.8,
609            vec!["sibling"],
610            vec![],
611            true,
612        ));
613
614        let q = RuleQuery {
615            body_contains: Some("parent".to_string()),
616            ..Default::default()
617        };
618        let results = idx.query(&q);
619        assert_eq!(results.len(), 1);
620        assert_eq!(results[0].rule_id, 1);
621    }
622
623    // ---- query: active_only filter -----------------------------------------
624
625    #[test]
626    fn query_active_only_filter() {
627        let mut idx = TensorRuleIndex::new();
628        idx.insert(make_rule(
629            1,
630            "a",
631            RuleArity::Nullary,
632            0.9,
633            vec![],
634            vec![],
635            true,
636        ));
637        idx.insert(make_rule(
638            2,
639            "b",
640            RuleArity::Nullary,
641            0.8,
642            vec![],
643            vec![],
644            false,
645        ));
646
647        let q = RuleQuery {
648            active_only: true,
649            ..Default::default()
650        };
651        let results = idx.query(&q);
652        assert_eq!(results.len(), 1);
653        assert_eq!(results[0].rule_id, 1);
654    }
655
656    // ---- query: multiple filters ANDed ------------------------------------
657
658    #[test]
659    fn query_multiple_filters_anded() {
660        let mut idx = TensorRuleIndex::new();
661        idx.insert(make_rule(
662            1,
663            "parent",
664            RuleArity::Binary,
665            0.9,
666            vec!["child"],
667            vec![],
668            true,
669        ));
670        idx.insert(make_rule(
671            2,
672            "parent",
673            RuleArity::Binary,
674            0.4,
675            vec!["child"],
676            vec![],
677            true,
678        ));
679        idx.insert(make_rule(
680            3,
681            "parent",
682            RuleArity::Unary,
683            0.9,
684            vec!["child"],
685            vec![],
686            true,
687        ));
688
689        let q = RuleQuery {
690            head_predicate: Some("parent".to_string()),
691            arity: Some(RuleArity::Binary),
692            min_confidence: Some(0.8),
693            body_contains: Some("child".to_string()),
694            active_only: true,
695        };
696        let results = idx.query(&q);
697        assert_eq!(results.len(), 1);
698        assert_eq!(results[0].rule_id, 1);
699    }
700
701    // ---- query: sorted by confidence desc ----------------------------------
702
703    #[test]
704    fn query_sorted_by_confidence_desc() {
705        let mut idx = TensorRuleIndex::new();
706        idx.insert(make_rule(
707            1,
708            "a",
709            RuleArity::Nullary,
710            0.3,
711            vec![],
712            vec![],
713            true,
714        ));
715        idx.insert(make_rule(
716            2,
717            "a",
718            RuleArity::Nullary,
719            0.9,
720            vec![],
721            vec![],
722            true,
723        ));
724        idx.insert(make_rule(
725            3,
726            "a",
727            RuleArity::Nullary,
728            0.6,
729            vec![],
730            vec![],
731            true,
732        ));
733
734        let q = RuleQuery::default();
735        let results = idx.query(&q);
736        assert_eq!(results[0].confidence, 0.9);
737        assert_eq!(results[1].confidence, 0.6);
738        assert_eq!(results[2].confidence, 0.3);
739    }
740
741    // ---- query: confidence tiebreak by rule_id asc ------------------------
742
743    #[test]
744    fn query_confidence_tiebreak_by_rule_id_asc() {
745        let mut idx = TensorRuleIndex::new();
746        idx.insert(make_rule(
747            3,
748            "a",
749            RuleArity::Nullary,
750            0.7,
751            vec![],
752            vec![],
753            true,
754        ));
755        idx.insert(make_rule(
756            1,
757            "a",
758            RuleArity::Nullary,
759            0.7,
760            vec![],
761            vec![],
762            true,
763        ));
764        idx.insert(make_rule(
765            2,
766            "a",
767            RuleArity::Nullary,
768            0.7,
769            vec![],
770            vec![],
771            true,
772        ));
773
774        let q = RuleQuery::default();
775        let results = idx.query(&q);
776        let ids: Vec<u64> = results.iter().map(|r| r.rule_id).collect();
777        assert_eq!(ids, vec![1, 2, 3]);
778    }
779
780    // ---- dependents_of -----------------------------------------------------
781
782    #[test]
783    fn dependents_of_returns_correct_rules() {
784        let mut idx = TensorRuleIndex::new();
785        idx.insert(make_rule(
786            1,
787            "base",
788            RuleArity::Nullary,
789            0.9,
790            vec![],
791            vec![],
792            true,
793        ));
794        idx.insert(make_rule(
795            2,
796            "derived",
797            RuleArity::Unary,
798            0.8,
799            vec![],
800            vec![1],
801            true,
802        ));
803        idx.insert(make_rule(
804            3,
805            "derived2",
806            RuleArity::Unary,
807            0.7,
808            vec![],
809            vec![1, 2],
810            true,
811        ));
812        idx.insert(make_rule(
813            4,
814            "unrelated",
815            RuleArity::Nullary,
816            0.6,
817            vec![],
818            vec![],
819            true,
820        ));
821
822        let deps = idx.dependents_of(1);
823        let ids: Vec<u64> = deps.iter().map(|r| r.rule_id).collect();
824        assert_eq!(ids, vec![2, 3]);
825    }
826
827    #[test]
828    fn dependents_of_empty_when_none_depend() {
829        let mut idx = TensorRuleIndex::new();
830        idx.insert(make_rule(
831            1,
832            "a",
833            RuleArity::Nullary,
834            0.9,
835            vec![],
836            vec![],
837            true,
838        ));
839        idx.insert(make_rule(
840            2,
841            "b",
842            RuleArity::Nullary,
843            0.8,
844            vec![],
845            vec![],
846            true,
847        ));
848
849        let deps = idx.dependents_of(1);
850        assert!(deps.is_empty());
851    }
852
853    // ---- rules_for_predicate -----------------------------------------------
854
855    #[test]
856    fn rules_for_predicate_uses_index() {
857        let mut idx = TensorRuleIndex::new();
858        idx.insert(make_rule(
859            1,
860            "parent",
861            RuleArity::Binary,
862            0.9,
863            vec![],
864            vec![],
865            true,
866        ));
867        idx.insert(make_rule(
868            2,
869            "child",
870            RuleArity::Unary,
871            0.8,
872            vec![],
873            vec![],
874            true,
875        ));
876
877        let results = idx.rules_for_predicate("parent");
878        assert_eq!(results.len(), 1);
879        assert_eq!(results[0].rule_id, 1);
880    }
881
882    #[test]
883    fn rules_for_predicate_sorted_by_confidence() {
884        let mut idx = TensorRuleIndex::new();
885        idx.insert(make_rule(
886            1,
887            "parent",
888            RuleArity::Binary,
889            0.4,
890            vec![],
891            vec![],
892            true,
893        ));
894        idx.insert(make_rule(
895            2,
896            "parent",
897            RuleArity::Binary,
898            0.9,
899            vec![],
900            vec![],
901            true,
902        ));
903        idx.insert(make_rule(
904            3,
905            "parent",
906            RuleArity::Binary,
907            0.6,
908            vec![],
909            vec![],
910            true,
911        ));
912
913        let results = idx.rules_for_predicate("parent");
914        assert_eq!(results[0].confidence, 0.9);
915        assert_eq!(results[1].confidence, 0.6);
916        assert_eq!(results[2].confidence, 0.4);
917    }
918
919    #[test]
920    fn rules_for_predicate_returns_empty_for_unknown() {
921        let idx = TensorRuleIndex::new();
922        assert!(idx.rules_for_predicate("nonexistent").is_empty());
923    }
924
925    // ---- stats -------------------------------------------------------------
926
927    #[test]
928    fn stats_total_and_active_rules() {
929        let mut idx = TensorRuleIndex::new();
930        idx.insert(make_rule(
931            1,
932            "a",
933            RuleArity::Nullary,
934            0.9,
935            vec![],
936            vec![],
937            true,
938        ));
939        idx.insert(make_rule(
940            2,
941            "b",
942            RuleArity::Nullary,
943            0.8,
944            vec![],
945            vec![],
946            false,
947        ));
948        idx.insert(make_rule(
949            3,
950            "c",
951            RuleArity::Nullary,
952            0.7,
953            vec![],
954            vec![],
955            true,
956        ));
957
958        let s = idx.stats();
959        assert_eq!(s.total_rules, 3);
960        assert_eq!(s.active_rules, 2);
961    }
962
963    #[test]
964    fn stats_unique_predicates() {
965        let mut idx = TensorRuleIndex::new();
966        idx.insert(make_rule(
967            1,
968            "parent",
969            RuleArity::Binary,
970            0.9,
971            vec![],
972            vec![],
973            true,
974        ));
975        idx.insert(make_rule(
976            2,
977            "parent",
978            RuleArity::Binary,
979            0.7,
980            vec![],
981            vec![],
982            true,
983        ));
984        idx.insert(make_rule(
985            3,
986            "child",
987            RuleArity::Unary,
988            0.8,
989            vec![],
990            vec![],
991            true,
992        ));
993
994        let s = idx.stats();
995        assert_eq!(s.unique_predicates, 2);
996    }
997
998    #[test]
999    fn stats_avg_confidence() {
1000        let mut idx = TensorRuleIndex::new();
1001        idx.insert(make_rule(
1002            1,
1003            "a",
1004            RuleArity::Nullary,
1005            0.8,
1006            vec![],
1007            vec![],
1008            true,
1009        ));
1010        idx.insert(make_rule(
1011            2,
1012            "b",
1013            RuleArity::Nullary,
1014            0.6,
1015            vec![],
1016            vec![],
1017            true,
1018        ));
1019
1020        let s = idx.stats();
1021        let expected = (0.8 + 0.6) / 2.0;
1022        assert!((s.avg_confidence - expected).abs() < 1e-10);
1023    }
1024
1025    #[test]
1026    fn stats_avg_confidence_zero_when_empty() {
1027        let idx = TensorRuleIndex::new();
1028        assert_eq!(idx.stats().avg_confidence, 0.0);
1029    }
1030
1031    // ---- RuleArity ordering -----------------------------------------------
1032
1033    #[test]
1034    fn rule_arity_ordering() {
1035        assert!(RuleArity::Nullary < RuleArity::Unary);
1036        assert!(RuleArity::Unary < RuleArity::Binary);
1037        assert!(RuleArity::Binary < RuleArity::Ternary);
1038        assert!(RuleArity::Ternary < RuleArity::NAry(4));
1039        assert!(RuleArity::NAry(4) < RuleArity::NAry(10));
1040        assert_eq!(RuleArity::Nullary, RuleArity::Nullary);
1041    }
1042}