Skip to main content

steeldb/
index.rs

1//! InfonIndex — the sparse incidence store: one posting set per infon token = the situations where
2//! it holds. Generic over the `Postings` backend so the same query path runs on either the HashSet
3//! baseline or roaring. `atom` resolves a token or a glob pattern to the UNION of matching postings,
4//! matching the TS `atom` / `fnmatch` semantics.
5
6use crate::bitmap::Postings;
7use crate::tokenql::TokenStore;
8use std::collections::HashMap;
9
10pub struct InfonIndex<B: Postings> {
11    symbol_table: HashMap<String, B>,
12    universe: B,
13    n: u32,
14    /// numeric columnar layer: field → (sid, value), sid-ascending — powers `(num field op value)`.
15    numbers: HashMap<String, Vec<(u32, f64)>>,
16    /// **Infon polarity** layer (paper §1.2, §4): the paper's infon `⟨⟨R, a₁…aₙ; i⟩⟩` carries a polarity
17    /// `i`; we widen `i` from `{0,1}` to four discrete belief levels `{-1, -0.5, +0.5, +1}`
18    /// (strong-against / weak-against / weak-for / strong-for) so Dempster-Shafer `Bel`/`Pl` can be
19    /// aggregated as a weighted POPCNT over bitmaps rather than a power-set walk:
20    ///   sign(sid): in `polarity_neg` ⇒ `i < 0`; magnitude(sid): in `polarity_weak` ⇒ `|i| = 0.5` else `1.0`.
21    ///   default (present in the symbol table, in neither set) = `i = +1`.
22    polarity_neg: HashMap<String, B>,
23    polarity_weak: HashMap<String, B>,
24}
25
26impl<B: Postings> InfonIndex<B> {
27    /// Build from `token -> ascending sids`. Callers accumulate sids in id order during a scan.
28    pub fn from_postings(raw: HashMap<String, Vec<u32>>, n: u32) -> Self {
29        let mut symbol_table = HashMap::with_capacity(raw.len());
30        for (tok, ids) in raw {
31            symbol_table.insert(tok, B::from_sorted(&ids));
32        }
33        let universe = B::from_sorted(&(0..n).collect::<Vec<_>>());
34        InfonIndex { symbol_table, universe, n, numbers: HashMap::new(), polarity_neg: HashMap::new(), polarity_weak: HashMap::new() }
35    }
36
37    /// Record a token membership at `sid` with a Dempster-Shafer polarity level ∈ {-1, -0.5, +0.5, +1}
38    /// (paper §4). `+1` strong-for, `+0.5` weak-for, `-0.5` weak-against, `-1` strong-against. Presence is
39    /// always asserted; sign/magnitude are stored in the `neg`/`weak` auxiliary sets.
40    pub fn add_infon_polar(&mut self, sid: u32, token: &str, level: f32) {
41        self.symbol_table.entry(token.to_string()).or_insert_with(B::empty).insert(sid);
42        if level < 0.0 {
43            self.polarity_neg.entry(token.to_string()).or_insert_with(B::empty).insert(sid);
44        }
45        if level.abs() < 0.75 {
46            self.polarity_weak.entry(token.to_string()).or_insert_with(B::empty).insert(sid);
47        }
48    }
49
50    /// SIMD belief aggregation (paper §4.1): net signed mass of `token` over `scope`, computed as a
51    /// weighted POPCNT over roaring sets — `+1·|strong-for| +0.5·|weak-for| −0.5·|weak-against|
52    /// −1·|strong-against|`, all within `scope ∩ post(token)`.
53    pub fn signed_mass(&self, token: &str, scope: &B) -> f64 {
54        let Some(post) = self.symbol_table.get(token) else { return 0.0 };
55        let base = post.and(scope);
56        let neg = self.polarity_neg.get(token).map(|n| base.and(n)).unwrap_or_else(B::empty);
57        let weak = self.polarity_weak.get(token).map(|w| base.and(w)).unwrap_or_else(B::empty);
58        let pos = base.and_not(&neg); // positive-sign members
59        let pos_weak = pos.and(&weak);
60        let neg_weak = neg.and(&weak);
61        let pos_strong = pos.len() - pos_weak.len();
62        let neg_strong = neg.len() - neg_weak.len();
63        pos_strong as f64 + 0.5 * pos_weak.len() as f64 - 0.5 * neg_weak.len() as f64 - neg_strong as f64
64    }
65
66    /// Situations where `token` holds with per-situation belief ≥ `min_bel`, using the discrete level
67    /// map (+1→1.0 strong-for, +0.5→0.5 weak-for, present→>0). Powers IKL `(evidence … :min-bel b)`.
68    pub fn evidence_set(&self, token: &str, min_bel: f64) -> B {
69        let Some(post) = self.symbol_table.get(token) else { return B::empty() };
70        let neg = self.polarity_neg.get(token);
71        let pos = match neg {
72            Some(n) => post.and_not(n), // positive-sign members
73            None => post.clone(),
74        };
75        if min_bel >= 0.75 {
76            // strong-for only: positive AND not weak
77            match self.polarity_weak.get(token) {
78                Some(w) => pos.and_not(w),
79                None => pos,
80            }
81        } else if min_bel >= 0.25 {
82            pos // strong or weak for
83        } else {
84            post.clone() // any presence
85        }
86    }
87
88    /// Dempster-Shafer belief interval `[Bel, Pl]` for `token` over `scope` (paper §4.1): `Bel` = fraction
89    /// of scope with strong-for evidence (lower certainty bound); `Pl` = fraction not strongly refuted
90    /// (upper bound `1 − Bel(¬A)`). Returns `(bel, pl)` in `[0,1]`.
91    pub fn belief_interval(&self, token: &str, scope: &B) -> (f64, f64) {
92        let n = scope.len();
93        if n == 0 {
94            return (0.0, 0.0);
95        }
96        let Some(post) = self.symbol_table.get(token) else { return (0.0, 1.0) };
97        let base = post.and(scope);
98        let neg = self.polarity_neg.get(token).map(|x| base.and(x)).unwrap_or_else(B::empty);
99        let weak = self.polarity_weak.get(token).map(|x| base.and(x)).unwrap_or_else(B::empty);
100        let pos = base.and_not(&neg);
101        let strong_for = pos.len() - pos.and(&weak).len();
102        let strong_against = neg.len() - neg.and(&weak).len();
103        let bel = strong_for as f64 / n as f64;
104        let pl = 1.0 - (strong_against as f64 / n as f64);
105        (bel, pl)
106    }
107
108    /// Record a numeric field value for a situation (columnar numeric layer).
109    pub fn add_number(&mut self, sid: u32, field: &str, value: f64) {
110        self.numbers.entry(field.to_string()).or_default().push((sid, value));
111    }
112
113    /// Numeric fields present (for schema/agent discovery).
114    pub fn numeric_fields(&self) -> impl Iterator<Item = &String> {
115        self.numbers.keys()
116    }
117
118    /// Append one situation's tokens as a new sid (incremental / realtime ingest). Tokens should be
119    /// de-duplicated by the caller. Returns the new sid.
120    pub fn add(&mut self, tokens: &[String]) -> u32 {
121        let sid = self.n;
122        for t in tokens {
123            self.symbol_table.entry(t.clone()).or_insert_with(B::empty).insert(sid);
124        }
125        self.universe.insert(sid);
126        self.n += 1;
127        sid
128    }
129
130    pub fn vocab_size(&self) -> usize {
131        self.symbol_table.len()
132    }
133    pub fn tokens(&self) -> impl Iterator<Item = &String> {
134        self.symbol_table.keys()
135    }
136    /// The posting set for an exact token (clone; empty if absent). Unlike `atom`, no glob handling —
137    /// the analytics programs address concrete tokens they discovered from the vocabulary.
138    pub fn post(&self, token: &str) -> B {
139        self.symbol_table.get(token).cloned().unwrap_or_else(B::empty)
140    }
141    pub fn post_len(&self, token: &str) -> usize {
142        self.symbol_table.get(token).map(|b| b.len()).unwrap_or(0)
143    }
144    /// Every token under a facet (first path segment), unsorted and untruncated — the analytics
145    /// programs partition/rank over the full facet, not just the top-N `tokens_in_facet` returns.
146    pub fn facet_members(&self, facet: &str) -> Vec<&String> {
147        self.symbol_table.keys().filter(|t| t.split('/').next() == Some(facet)).collect()
148    }
149    /// The most frequent tokens under a facet, with posting sizes — so an agent can learn the
150    /// queryable vocabulary of a facet before composing IKL.
151    pub fn tokens_in_facet(&self, facet: &str, limit: usize) -> Vec<(String, usize)> {
152        let mut v: Vec<(String, usize)> = self
153            .symbol_table
154            .iter()
155            .filter(|(t, _)| t.split('/').next() == Some(facet))
156            .map(|(t, b)| (t.clone(), b.len()))
157            .collect();
158        v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
159        v.truncate(limit);
160        v
161    }
162    /// Every `(tag, postings)` pair in the symbol table — the incidence matrix, row by row.
163    pub fn postings(&self) -> impl Iterator<Item = (&String, &B)> {
164        self.symbol_table.iter()
165    }
166    pub fn situations(&self) -> u32 {
167        self.n
168    }
169
170    /// Total native bytes across all postings (memory proxy for the chosen backend).
171    pub fn postings_native_bytes(&self) -> usize {
172        self.symbol_table.values().map(|b| b.native_bytes()).sum()
173    }
174    /// Total portable delta-gap bytes (same for every backend — the on-disk posting size).
175    pub fn postings_deltagap_bytes(&self) -> usize {
176        self.symbol_table.values().map(|b| b.serialize_deltagap().len()).sum()
177    }
178}
179
180/// glob match with `*` (any run) and `?` (one char) — enough for the token wildcard atoms.
181fn glob_match(pat: &str, s: &str) -> bool {
182    // classic two-pointer wildcard matcher with backtracking on `*`
183    let (p, t) = (pat.as_bytes(), s.as_bytes());
184    let (mut pi, mut ti) = (0usize, 0usize);
185    let (mut star, mut mark) = (usize::MAX, 0usize);
186    while ti < t.len() {
187        if pi < p.len() && (p[pi] == b'?' || p[pi] == t[ti]) {
188            pi += 1;
189            ti += 1;
190        } else if pi < p.len() && p[pi] == b'*' {
191            star = pi;
192            mark = ti;
193            pi += 1;
194        } else if star != usize::MAX {
195            pi = star + 1;
196            mark += 1;
197            ti = mark;
198        } else {
199            return false;
200        }
201    }
202    while pi < p.len() && p[pi] == b'*' {
203        pi += 1;
204    }
205    pi == p.len()
206}
207
208fn is_glob(pat: &str) -> bool {
209    pat.contains('*') || pat.contains('?')
210}
211
212impl<B: Postings> InfonIndex<B> {
213    /// Tokens on an s-path from `a` to `b`: a chain where consecutive tokens share at least `s` situations
214    /// (paper §3.3). Returns `None` when either endpoint is unknown or no such chain exists.
215    ///
216    /// Raising `s` is what suppresses semantic drift — at `s = 1` a single shared situation links two tokens,
217    /// which lets a walk wander somewhere unrelated to where it began.
218    ///
219    /// Breadth-first, so the chain found is the shortest. Neighbour discovery scans the symbol table, which is
220    /// linear per expansion; the paper's answer to that cost is to filter the scope first, and the
221    /// `(constraint …)` clause in the query form is how a caller does it.
222    pub fn s_path_tokens(&self, a: &str, b: &str, s: usize) -> Option<Vec<String>> {
223        let s = s.max(1);
224        if !self.symbol_table.contains_key(a) || !self.symbol_table.contains_key(b) {
225            return None;
226        }
227        if a == b {
228            return Some(vec![a.to_string()]);
229        }
230        let mut prev: std::collections::HashMap<&str, Option<&str>> = std::collections::HashMap::new();
231        prev.insert(a, None);
232        let mut queue = std::collections::VecDeque::from([a]);
233        while let Some(cur) = queue.pop_front() {
234            let cur_post = match self.symbol_table.get(cur) {
235                Some(p) => p,
236                None => continue,
237            };
238            for (tok, post) in &self.symbol_table {
239                if tok.as_str() == cur || prev.contains_key(tok.as_str()) {
240                    continue;
241                }
242                if cur_post.and(post).len() < s {
243                    continue;
244                }
245                prev.insert(tok.as_str(), Some(cur));
246                if tok.as_str() == b {
247                    // walk the parent chain back to the source
248                    let mut chain = vec![b.to_string()];
249                    let mut node = b;
250                    while let Some(Some(p)) = prev.get(node) {
251                        chain.push((*p).to_string());
252                        node = p;
253                    }
254                    chain.reverse();
255                    return Some(chain);
256                }
257                queue.push_back(tok.as_str());
258            }
259        }
260        None
261    }
262}
263
264impl<B: Postings> TokenStore<B> for InfonIndex<B> {
265    fn atom(&self, pattern: &str) -> B {
266        if is_glob(pattern) {
267            let mut acc = B::empty();
268            for (tok, post) in &self.symbol_table {
269                if glob_match(pattern, tok) {
270                    acc.or_inplace(post);
271                }
272            }
273            acc
274        } else {
275            self.symbol_table.get(pattern).cloned().unwrap_or_else(B::empty)
276        }
277    }
278    fn universe(&self) -> B {
279        self.universe.clone()
280    }
281    /// The `evidence` atom of §6.1, backed by the polarity layer: situations where this token holds with
282    /// per-situation belief at or above the threshold. The trait default is plain membership, which would
283    /// ignore polarity entirely and quietly include refuted situations.
284    fn evidence(&self, token: &str, min_bel: f64) -> B {
285        self.evidence_set(token, min_bel)
286    }
287    /// The situations an s-path passes through: the union of the postings of the tokens on the chain, so the
288    /// result composes with the rest of the set algebra.
289    fn s_path(&self, a: &str, b: &str, s: usize) -> Option<B> {
290        // Some(empty) and None mean different things and must not be collapsed: this index HAS a topological
291        // layer, so "no chain connects these at this threshold" is an answer (empty), whereas None is reserved
292        // for a store that cannot evaluate s-paths at all.
293        let Some(chain) = self.s_path_tokens(a, b, s) else { return Some(B::empty()) };
294        let mut out = B::empty();
295        for tok in &chain {
296            if let Some(post) = self.symbol_table.get(tok) {
297                out.or_inplace(post);
298            }
299        }
300        Some(out)
301    }
302    fn numeric(&self, field: &str, op: &str, value: f64) -> B {
303        match self.numbers.get(field) {
304            Some(vals) => {
305                let sids: Vec<u32> = vals.iter().filter(|(_, v)| crate::units::cmp_op(*v, op, value)).map(|(sid, _)| *sid).collect();
306                // a situation may record a field once; sids stay ascending from the ingest scan
307                B::from_sorted(&sids)
308            }
309            None => B::empty(),
310        }
311    }
312}