Skip to main content

ipfrs_tensorlogic/
term_index.rs

1//! Inverted index over TensorLogic terms for fast predicate/constant/variable lookup.
2//!
3//! [`TermIndexBuilder`] maintains a posting-list index keyed by [`TermType`].
4//! Each posting list is a `Vec<TermRef>` that records which rule (or fact) and
5//! at which position a given term appears.  The index supports incremental
6//! construction (`index_term`), point lookups (`lookup`, `lookup_predicate`,
7//! `lookup_constant`), derived queries (`rules_for_predicate`), mutation
8//! (`remove_rule`, `clear`), and statistics (`stats`).
9
10use std::collections::HashMap;
11
12// ── TermPosition ─────────────────────────────────────────────────────────────
13
14/// Describes where in a rule (or as a standalone fact) a term appears.
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
16pub enum TermPosition {
17    /// The term appears in the rule head.
18    Head,
19    /// The term appears in the body at the given clause index.
20    Body(usize),
21    /// The term is a standalone fact (not part of a rule).
22    Fact,
23}
24
25// ── TermType ─────────────────────────────────────────────────────────────────
26
27/// The kind/value of a term used as an index key.
28#[derive(Clone, Debug, PartialEq, Eq, Hash)]
29pub enum TermType {
30    /// A predicate name (functor).
31    Predicate(String),
32    /// A string constant.
33    Constant(String),
34    /// A logic variable name.
35    Variable(String),
36    /// A numeric value stored as the bit-pattern of an `f64` so that the type
37    /// remains `Hash + Eq`.  Use [`TermType::from_f64`] and
38    /// [`TermType::as_f64`] for ergonomic construction/access.
39    Numeric(u64),
40}
41
42impl TermType {
43    /// Construct a [`TermType::Numeric`] from an `f64`.
44    #[inline]
45    pub fn from_f64(v: f64) -> Self {
46        Self::Numeric(v.to_bits())
47    }
48
49    /// Return the `f64` value stored in a [`TermType::Numeric`], or `None`
50    /// for other variants.
51    #[inline]
52    pub fn as_f64(&self) -> Option<f64> {
53        match self {
54            Self::Numeric(bits) => Some(f64::from_bits(*bits)),
55            _ => None,
56        }
57    }
58}
59
60// ── TermRef ───────────────────────────────────────────────────────────────────
61
62/// A single posting-list entry: records which rule (or fact) mentions a term
63/// and at which position.
64///
65/// Note: this `TermRef` is specific to the `term_index` module and is
66/// *distinct* from [`crate::ir::TermRef`], which is a CID-based reference used
67/// in the IR layer.
68#[derive(Clone, Debug, PartialEq, Eq)]
69pub struct TermRef {
70    /// Identifier of the rule or fact that contains this term.
71    pub rule_id: u64,
72    /// Position within the rule/fact where the term appears.
73    pub position: TermPosition,
74    /// The kind/value of the term.
75    pub term_type: TermType,
76}
77
78// ── IndexStats ────────────────────────────────────────────────────────────────
79
80/// Summary statistics for a [`TermIndexBuilder`].
81#[derive(Clone, Debug, PartialEq)]
82pub struct IndexStats {
83    /// Number of unique term keys in the index.
84    pub total_terms: usize,
85    /// Sum of all posting-list lengths.
86    pub total_refs: usize,
87}
88
89impl IndexStats {
90    /// Average number of posting-list entries per unique term key.
91    ///
92    /// Returns `0.0` when the index is empty.
93    pub fn average_posting_len(&self) -> f64 {
94        if self.total_terms == 0 {
95            0.0
96        } else {
97            self.total_refs as f64 / self.total_terms as f64
98        }
99    }
100}
101
102// ── TermIndexBuilder ──────────────────────────────────────────────────────────
103
104/// Builds and queries an inverted index over TensorLogic terms.
105///
106/// The index maps each [`TermType`] to the list of [`TermRef`]s (posting list)
107/// that identify every rule/fact position where that term occurs.
108///
109/// # Example
110///
111/// ```
112/// use ipfrs_tensorlogic::term_index::{TermIndexBuilder, TermPosition, TermType};
113///
114/// let mut builder = TermIndexBuilder::new();
115///
116/// builder.index_term(
117///     TermType::Predicate("parent".to_string()),
118///     1,
119///     TermPosition::Head,
120/// );
121///
122/// let refs = builder.lookup_predicate("parent");
123/// assert_eq!(refs.len(), 1);
124/// assert_eq!(refs[0].rule_id, 1);
125/// ```
126#[derive(Debug, Default)]
127pub struct TermIndexBuilder {
128    /// The underlying inverted index.
129    pub index: HashMap<TermType, Vec<TermRef>>,
130}
131
132impl TermIndexBuilder {
133    /// Create a new, empty [`TermIndexBuilder`].
134    pub fn new() -> Self {
135        Self {
136            index: HashMap::new(),
137        }
138    }
139
140    /// Add a term occurrence to the index.
141    ///
142    /// Appends a [`TermRef`] to the posting list for `term`.
143    pub fn index_term(&mut self, term: TermType, rule_id: u64, position: TermPosition) {
144        let entry = self.index.entry(term.clone()).or_default();
145        entry.push(TermRef {
146            rule_id,
147            position,
148            term_type: term,
149        });
150    }
151
152    /// Return the posting list for `term`, or an empty slice if absent.
153    pub fn lookup(&self, term: &TermType) -> &[TermRef] {
154        self.index.get(term).map(Vec::as_slice).unwrap_or_default()
155    }
156
157    /// Return the posting list for `Predicate(name)`, or an empty slice.
158    pub fn lookup_predicate(&self, name: &str) -> &[TermRef] {
159        self.lookup(&TermType::Predicate(name.to_string()))
160    }
161
162    /// Return the posting list for `Constant(name)`, or an empty slice.
163    pub fn lookup_constant(&self, name: &str) -> &[TermRef] {
164        self.lookup(&TermType::Constant(name.to_string()))
165    }
166
167    /// Return a deduplicated, sorted list of rule IDs that mention
168    /// `Predicate(name)`.
169    pub fn rules_for_predicate(&self, name: &str) -> Vec<u64> {
170        let mut ids: Vec<u64> = self
171            .lookup_predicate(name)
172            .iter()
173            .map(|r| r.rule_id)
174            .collect();
175        ids.sort_unstable();
176        ids.dedup();
177        ids
178    }
179
180    /// Remove all [`TermRef`]s belonging to `rule_id` from every posting list.
181    ///
182    /// Posting lists that become empty after removal are dropped from the index.
183    pub fn remove_rule(&mut self, rule_id: u64) {
184        self.index.retain(|_, refs| {
185            refs.retain(|r| r.rule_id != rule_id);
186            !refs.is_empty()
187        });
188    }
189
190    /// Return statistics about the current state of the index.
191    pub fn stats(&self) -> IndexStats {
192        let total_terms = self.index.len();
193        let total_refs = self.index.values().map(Vec::len).sum();
194        IndexStats {
195            total_terms,
196            total_refs,
197        }
198    }
199
200    /// Remove all entries from the index.
201    pub fn clear(&mut self) {
202        self.index.clear();
203    }
204}
205
206// ── Tests ─────────────────────────────────────────────────────────────────────
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    // Helper: build a small builder with two predicates and a constant.
213    fn sample_builder() -> TermIndexBuilder {
214        let mut b = TermIndexBuilder::new();
215        b.index_term(
216            TermType::Predicate("parent".to_string()),
217            1,
218            TermPosition::Head,
219        );
220        b.index_term(
221            TermType::Predicate("parent".to_string()),
222            2,
223            TermPosition::Body(0),
224        );
225        b.index_term(
226            TermType::Predicate("child".to_string()),
227            2,
228            TermPosition::Head,
229        );
230        b.index_term(
231            TermType::Constant("alice".to_string()),
232            1,
233            TermPosition::Body(0),
234        );
235        b
236    }
237
238    // ── 1. index_term builds the posting list ────────────────────────────────
239
240    #[test]
241    fn test_index_term_single_entry() {
242        let mut b = TermIndexBuilder::new();
243        b.index_term(
244            TermType::Predicate("foo".to_string()),
245            10,
246            TermPosition::Head,
247        );
248        assert_eq!(b.index.len(), 1);
249        let list = b.lookup_predicate("foo");
250        assert_eq!(list.len(), 1);
251        assert_eq!(list[0].rule_id, 10);
252        assert_eq!(list[0].position, TermPosition::Head);
253    }
254
255    #[test]
256    fn test_index_term_multiple_entries_same_key() {
257        let mut b = TermIndexBuilder::new();
258        b.index_term(TermType::Predicate("p".to_string()), 1, TermPosition::Head);
259        b.index_term(TermType::Predicate("p".to_string()), 2, TermPosition::Head);
260        b.index_term(
261            TermType::Predicate("p".to_string()),
262            3,
263            TermPosition::Body(1),
264        );
265        let list = b.lookup_predicate("p");
266        assert_eq!(list.len(), 3);
267    }
268
269    // ── 2. lookup returns refs ────────────────────────────────────────────────
270
271    #[test]
272    fn test_lookup_returns_correct_refs() {
273        let b = sample_builder();
274        let list = b.lookup(&TermType::Predicate("parent".to_string()));
275        assert_eq!(list.len(), 2);
276        assert!(list
277            .iter()
278            .any(|r| r.rule_id == 1 && r.position == TermPosition::Head));
279        assert!(list
280            .iter()
281            .any(|r| r.rule_id == 2 && r.position == TermPosition::Body(0)));
282    }
283
284    #[test]
285    fn test_lookup_missing_returns_empty() {
286        let b = sample_builder();
287        let list = b.lookup(&TermType::Predicate("nonexistent".to_string()));
288        assert!(list.is_empty());
289    }
290
291    // ── 3. lookup_predicate ───────────────────────────────────────────────────
292
293    #[test]
294    fn test_lookup_predicate_found() {
295        let b = sample_builder();
296        assert_eq!(b.lookup_predicate("child").len(), 1);
297    }
298
299    #[test]
300    fn test_lookup_predicate_not_found() {
301        let b = sample_builder();
302        assert!(b.lookup_predicate("grandparent").is_empty());
303    }
304
305    // ── 4. lookup_constant ────────────────────────────────────────────────────
306
307    #[test]
308    fn test_lookup_constant_found() {
309        let b = sample_builder();
310        let list = b.lookup_constant("alice");
311        assert_eq!(list.len(), 1);
312        assert_eq!(list[0].rule_id, 1);
313        assert_eq!(list[0].position, TermPosition::Body(0));
314    }
315
316    #[test]
317    fn test_lookup_constant_not_found() {
318        let b = sample_builder();
319        assert!(b.lookup_constant("bob").is_empty());
320    }
321
322    // ── 5. rules_for_predicate deduplicates ───────────────────────────────────
323
324    #[test]
325    fn test_rules_for_predicate_deduplicates() {
326        let mut b = TermIndexBuilder::new();
327        // Rule 5 mentions "ancestor" in both head and body — should appear once.
328        b.index_term(
329            TermType::Predicate("ancestor".to_string()),
330            5,
331            TermPosition::Head,
332        );
333        b.index_term(
334            TermType::Predicate("ancestor".to_string()),
335            5,
336            TermPosition::Body(0),
337        );
338        b.index_term(
339            TermType::Predicate("ancestor".to_string()),
340            7,
341            TermPosition::Head,
342        );
343
344        let ids = b.rules_for_predicate("ancestor");
345        assert_eq!(ids, vec![5, 7]);
346    }
347
348    #[test]
349    fn test_rules_for_predicate_sorted() {
350        let mut b = TermIndexBuilder::new();
351        b.index_term(TermType::Predicate("r".to_string()), 10, TermPosition::Head);
352        b.index_term(
353            TermType::Predicate("r".to_string()),
354            3,
355            TermPosition::Body(0),
356        );
357        b.index_term(TermType::Predicate("r".to_string()), 7, TermPosition::Head);
358
359        let ids = b.rules_for_predicate("r");
360        assert_eq!(ids, vec![3, 7, 10]);
361    }
362
363    #[test]
364    fn test_rules_for_predicate_empty() {
365        let b = TermIndexBuilder::new();
366        assert!(b.rules_for_predicate("missing").is_empty());
367    }
368
369    // ── 6. remove_rule removes from all lists and drops empty ─────────────────
370
371    #[test]
372    fn test_remove_rule_removes_refs() {
373        let mut b = sample_builder();
374        // "parent" has entries for rules 1 and 2; remove rule 1.
375        b.remove_rule(1);
376        let list = b.lookup_predicate("parent");
377        assert_eq!(list.len(), 1);
378        assert_eq!(list[0].rule_id, 2);
379    }
380
381    #[test]
382    fn test_remove_rule_drops_empty_posting_list() {
383        let mut b = sample_builder();
384        // "alice" constant is only referenced by rule 1; removing rule 1 drops it.
385        b.remove_rule(1);
386        assert!(!b
387            .index
388            .contains_key(&TermType::Constant("alice".to_string())));
389    }
390
391    #[test]
392    fn test_remove_rule_nonexistent_is_noop() {
393        let mut b = sample_builder();
394        let before = b.stats();
395        b.remove_rule(999);
396        assert_eq!(b.stats(), before);
397    }
398
399    // ── 7. stats ──────────────────────────────────────────────────────────────
400
401    #[test]
402    fn test_stats_counts() {
403        let b = sample_builder();
404        let s = b.stats();
405        // Keys: "parent", "child", "alice" → 3 unique terms.
406        assert_eq!(s.total_terms, 3);
407        // Refs: parent×2, child×1, alice×1 → 4.
408        assert_eq!(s.total_refs, 4);
409    }
410
411    #[test]
412    fn test_stats_empty() {
413        let b = TermIndexBuilder::new();
414        let s = b.stats();
415        assert_eq!(s.total_terms, 0);
416        assert_eq!(s.total_refs, 0);
417    }
418
419    // ── 8. average_posting_len ────────────────────────────────────────────────
420
421    #[test]
422    fn test_average_posting_len() {
423        let b = sample_builder();
424        let s = b.stats();
425        // 4 refs / 3 terms ≈ 1.333…
426        let avg = s.average_posting_len();
427        assert!((avg - (4.0 / 3.0)).abs() < 1e-10);
428    }
429
430    #[test]
431    fn test_average_posting_len_empty() {
432        let s = IndexStats {
433            total_terms: 0,
434            total_refs: 0,
435        };
436        assert_eq!(s.average_posting_len(), 0.0);
437    }
438
439    // ── 9. clear empties the index ────────────────────────────────────────────
440
441    #[test]
442    fn test_clear_empties_index() {
443        let mut b = sample_builder();
444        b.clear();
445        assert!(b.index.is_empty());
446        let s = b.stats();
447        assert_eq!(s.total_terms, 0);
448        assert_eq!(s.total_refs, 0);
449    }
450
451    // ── 10. multiple terms for the same rule ──────────────────────────────────
452
453    #[test]
454    fn test_multiple_terms_same_rule() {
455        let mut b = TermIndexBuilder::new();
456        let rule_id = 42;
457        b.index_term(
458            TermType::Predicate("path".to_string()),
459            rule_id,
460            TermPosition::Head,
461        );
462        b.index_term(
463            TermType::Predicate("edge".to_string()),
464            rule_id,
465            TermPosition::Body(0),
466        );
467        b.index_term(
468            TermType::Variable("X".to_string()),
469            rule_id,
470            TermPosition::Body(0),
471        );
472        b.index_term(
473            TermType::Variable("Y".to_string()),
474            rule_id,
475            TermPosition::Body(0),
476        );
477
478        assert_eq!(b.lookup_predicate("path").len(), 1);
479        assert_eq!(b.lookup_predicate("edge").len(), 1);
480        assert_eq!(b.lookup(&TermType::Variable("X".to_string())).len(), 1);
481        assert_eq!(b.lookup(&TermType::Variable("Y".to_string())).len(), 1);
482
483        // Removing the rule clears all four entries.
484        b.remove_rule(rule_id);
485        assert!(b.index.is_empty());
486    }
487
488    // ── 11. Numeric term ──────────────────────────────────────────────────────
489
490    #[test]
491    fn test_numeric_term_index_and_lookup() {
492        let mut b = TermIndexBuilder::new();
493        let num = TermType::from_f64(std::f64::consts::PI);
494        b.index_term(num.clone(), 99, TermPosition::Fact);
495
496        let list = b.lookup(&num);
497        assert_eq!(list.len(), 1);
498        assert_eq!(list[0].rule_id, 99);
499        assert_eq!(list[0].position, TermPosition::Fact);
500        assert!(
501            (list[0].term_type.as_f64().expect("test: should succeed") - std::f64::consts::PI)
502                .abs()
503                < 1e-10
504        );
505    }
506
507    #[test]
508    fn test_numeric_term_distinct_values() {
509        let mut b = TermIndexBuilder::new();
510        b.index_term(TermType::from_f64(1.0), 1, TermPosition::Fact);
511        b.index_term(TermType::from_f64(2.0), 2, TermPosition::Fact);
512        b.index_term(TermType::from_f64(1.0), 3, TermPosition::Fact);
513
514        // 1.0 and 2.0 are distinct keys.
515        assert_eq!(b.index.len(), 2);
516        assert_eq!(b.lookup(&TermType::from_f64(1.0)).len(), 2);
517        assert_eq!(b.lookup(&TermType::from_f64(2.0)).len(), 1);
518    }
519
520    // ── 12. Fact position ─────────────────────────────────────────────────────
521
522    #[test]
523    fn test_fact_position() {
524        let mut b = TermIndexBuilder::new();
525        b.index_term(
526            TermType::Predicate("likes".to_string()),
527            0,
528            TermPosition::Fact,
529        );
530        let list = b.lookup_predicate("likes");
531        assert_eq!(list[0].position, TermPosition::Fact);
532    }
533
534    // ── 13. Body position variant ─────────────────────────────────────────────
535
536    #[test]
537    fn test_body_position_index() {
538        let mut b = TermIndexBuilder::new();
539        b.index_term(
540            TermType::Predicate("q".to_string()),
541            7,
542            TermPosition::Body(3),
543        );
544        let list = b.lookup_predicate("q");
545        assert_eq!(list[0].position, TermPosition::Body(3));
546    }
547
548    // ── 14. term_type stored correctly in TermRef ─────────────────────────────
549
550    #[test]
551    fn test_term_ref_stores_term_type() {
552        let mut b = TermIndexBuilder::new();
553        b.index_term(
554            TermType::Constant("hello".to_string()),
555            5,
556            TermPosition::Head,
557        );
558        let list = b.lookup_constant("hello");
559        assert_eq!(list[0].term_type, TermType::Constant("hello".to_string()));
560    }
561
562    // ── 15. remove_rule on shared posting list ───────────────────────────────
563
564    #[test]
565    fn test_remove_rule_partial_posting_list_retained() {
566        let mut b = TermIndexBuilder::new();
567        b.index_term(TermType::Predicate("p".to_string()), 1, TermPosition::Head);
568        b.index_term(TermType::Predicate("p".to_string()), 2, TermPosition::Head);
569        b.index_term(TermType::Predicate("p".to_string()), 3, TermPosition::Head);
570
571        b.remove_rule(2);
572
573        let ids = b.rules_for_predicate("p");
574        assert_eq!(ids, vec![1, 3]);
575    }
576
577    // ── 16. stats after remove ────────────────────────────────────────────────
578
579    #[test]
580    fn test_stats_after_remove() {
581        let mut b = sample_builder();
582        // Before: 3 terms, 4 refs.
583        b.remove_rule(1);
584        // Rule 1 had: parent(head) and constant(alice). Both removed.
585        // "alice" posting list becomes empty and is dropped.
586        // "parent" posting list still has rule 2 → remains.
587        let s = b.stats();
588        assert_eq!(s.total_terms, 2); // "parent", "child"
589        assert_eq!(s.total_refs, 2); // parent/rule2 + child/rule2
590    }
591
592    // ── 17. new() constructs empty builder ────────────────────────────────────
593
594    #[test]
595    fn test_new_is_empty() {
596        let b = TermIndexBuilder::new();
597        assert!(b.index.is_empty());
598        let s = b.stats();
599        assert_eq!(s.total_terms, 0);
600        assert_eq!(s.total_refs, 0);
601        assert_eq!(s.average_posting_len(), 0.0);
602    }
603}