Skip to main content

steeldb/
tokenql.rs

1//! Token-only IKL — the single retrieval language, an s-expression over the postings store.
2//! Ported 1:1 from the TS `tokenql.ts` evaluator:
3//!   `<atom>`        a tag or glob pattern → UNION of matching tokens' postings, ∩ scope
4//!   (and A B …)   → ∩   (also the default for a bare list)
5//!   (or  A B …)   → ∪
6//!   (not A)       → scope − A   (closed-world inversion)
7//! (The `num` typed range predicate is stubbed here — it belongs to the columnar numeric layer,
8//! which is a separate benchmark; set-algebra is what we're measuring.)
9
10use crate::bitmap::Postings;
11
12pub trait TokenStore<B: Postings> {
13    fn atom(&self, pattern: &str) -> B;
14    fn universe(&self) -> B;
15    /// Situations whose numeric `field` satisfies `field op value` (op ∈ ge|gt|le|lt|eq|ne). Stores
16    /// without a numeric layer return empty.
17    fn numeric(&self, _field: &str, _op: &str, _value: f64) -> B {
18        B::empty()
19    }
20    /// Situations where `token` holds with per-situation belief ≥ `min_bel` (paper §6, the `evidence` atom).
21    /// Stores without a polarity layer fall back to plain membership.
22    fn evidence(&self, token: &str, _min_bel: f64) -> B {
23        self.atom(token)
24    }
25    /// Situations lying on an s-path between two tokens: a chain where consecutive steps share at least `s`
26    /// situations (paper §3.3). Stores without a topological layer return nothing.
27    fn s_path(&self, _a: &str, _b: &str, _s: usize) -> Option<B> {
28        None
29    }
30}
31
32#[derive(Debug, Clone, PartialEq)]
33pub enum Node {
34    Atom(String),
35    List(Vec<Node>),
36}
37
38/// Parse an s-expression. Atoms stay strings; `"quoted atoms"` keep spaces.
39pub fn parse(expr: &str) -> Node {
40    let mut stack: Vec<Vec<Node>> = Vec::new();
41    let mut out: Vec<Node> = Vec::new();
42    let mut buf = String::new();
43    let mut inq = false;
44
45    macro_rules! flush {
46        () => {
47            if !buf.is_empty() {
48                out.push(Node::Atom(std::mem::take(&mut buf)));
49            }
50        };
51    }
52
53    for ch in expr.chars() {
54        if inq {
55            if ch == '"' {
56                out.push(Node::Atom(std::mem::take(&mut buf)));
57                inq = false;
58            } else {
59                buf.push(ch);
60            }
61        } else if ch == '"' {
62            flush!();
63            inq = true;
64        } else if ch == '(' {
65            flush!();
66            stack.push(std::mem::take(&mut out));
67        } else if ch == ')' {
68            flush!();
69            // A stray `)` is ignored rather than panicking. This is a `pub fn` reachable with any string, and
70            // it used to abort on `query(")")` — in the wasm build a panic takes the whole module down. Callers
71            // that care about well-formedness get that from the linter, which reports the imbalance; parsing
72            // recovers so the failure is a refusal rather than a crash.
73            if let Some(prev) = stack.pop() {
74                let node = Node::List(std::mem::take(&mut out));
75                out = prev;
76                out.push(node);
77            }
78        } else if ch.is_whitespace() {
79            flush!();
80        } else {
81            buf.push(ch);
82        }
83    }
84    flush!();
85    if out.len() == 1 {
86        out.pop().unwrap()
87    } else {
88        Node::List(out)
89    }
90}
91
92/// Why a query could not be evaluated. Separate from "matched nothing", which is an answer.
93#[derive(Debug, Clone, PartialEq)]
94pub enum QueryError {
95    /// the sources in a `combine-ds` block disagree beyond the block's own threshold
96    Conflict { conflict: f64, threshold: f64 },
97    /// a `combine-ds` or `stream` block was not shaped as §6.1 requires
98    Malformed(String),
99}
100
101impl std::fmt::Display for QueryError {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        match self {
104            QueryError::Conflict { conflict, threshold } => write!(
105                f,
106                "evidential conflict K={conflict:.4} exceeds :max-conflict {threshold:.4}; \
107                 the evidence streams disagree too much to fuse"
108            ),
109            QueryError::Malformed(what) => write!(f, "malformed query: {what}"),
110        }
111    }
112}
113
114impl std::error::Error for QueryError {}
115
116/// Evaluate a query, returning empty on refusal. Kept for callers that cannot act on an error.
117pub fn evaluate<B: Postings, S: TokenStore<B>>(store: &S, expr: &str) -> B {
118    try_evaluate(store, expr).unwrap_or_else(|_| B::empty())
119}
120
121/// Evaluate a query, surfacing refusals.
122///
123/// A conflict refusal is not the same as an empty result and must not be collapsed into one: "the sources
124/// contradict each other" is information the caller can act on, and "no rows matched" is an answer.
125pub fn try_evaluate<B: Postings, S: TokenStore<B>>(store: &S, expr: &str) -> Result<B, QueryError> {
126    let scope = store.universe();
127    try_eval_node(store, &parse(expr), &scope)
128}
129
130/// Read a `:keyword value` pair out of an argument list.
131fn keyword_arg(args: &[Node], name: &str) -> Option<String> {
132    args.iter().position(|n| matches!(n, Node::Atom(a) if a == name)).and_then(|i| match args.get(i + 1) {
133        Some(Node::Atom(v)) => Some(v.clone()),
134        _ => None,
135    })
136}
137
138/// `(mass (<atom>+) <float>)` — a focal set and its mass. The atoms are a DISJUNCTION: mass on
139/// "one of these, cannot say which", so the focal set is their union.
140fn focal_of<B: Postings, S: TokenStore<B>>(store: &S, node: &Node, scope: &B) -> Option<(B, f64)> {
141    let Node::List(items) = node else { return None };
142    if items.len() != 3 || !matches!(&items[0], Node::Atom(a) if a == "mass") {
143        return None;
144    }
145    let mass = match &items[2] {
146        Node::Atom(v) => v.parse::<f64>().ok()?,
147        _ => return None,
148    };
149    let set = match &items[1] {
150        Node::List(atoms) => {
151            let mut u = B::empty();
152            for a in atoms {
153                if let Node::Atom(t) = a {
154                    u.or_inplace(&store.atom(t));
155                }
156            }
157            u.and(scope)
158        }
159        Node::Atom(t) => store.atom(t).and(scope),
160    };
161    Some((set, mass))
162}
163
164fn try_eval_node<B: Postings, S: TokenStore<B>>(
165    store: &S,
166    node: &Node,
167    scope: &B,
168) -> Result<B, QueryError> {
169    Ok(match node {
170        Node::Atom(a) => store.atom(a).and(scope),
171        Node::List(items) => {
172            if items.is_empty() {
173                return Ok(scope.clone());
174            }
175            let op = if let Node::Atom(s) = &items[0] { s.as_str() } else { "" };
176            let args = &items[1..];
177            match op {
178                "and" => {
179                    let mut r = scope.clone();
180                    for a in args {
181                        r = r.and(&try_eval_node(store, a, scope)?);
182                    }
183                    r
184                }
185                "or" => {
186                    let mut r = B::empty();
187                    for a in args {
188                        let e = try_eval_node(store, a, scope)?;
189                        r.or_inplace(&e);
190                    }
191                    r.and(scope)
192                }
193                // `(not)` with no argument used to index an empty slice and PANIC — which in WebAssembly
194                // aborts the whole module, taking the page with it. A model emitting a truncated expression is
195                // an expected input, not a bug to crash on.
196                "not" => match args.first() {
197                    Some(inner) => scope.and_not(&try_eval_node(store, inner, scope)?),
198                    None => return Err(QueryError::Malformed("not needs one argument".into())),
199                },
200                // (evidence <atom> :min-bel <f> :max-pl <f>) — §6.1. This was lint-accepted but never
201                // evaluated: it fell through to implicit AND, where ":min-bel" and "0.8" are atoms matching
202                // nothing, so a belief-constrained query silently returned empty instead of filtering.
203                "evidence" if !args.is_empty() => {
204                    let token = match &args[0] {
205                        Node::Atom(t) => t.clone(),
206                        other => return try_eval_node(store, other, scope),
207                    };
208                    let min_bel = keyword_arg(args, ":min-bel")
209                        .and_then(|v| v.parse::<f64>().ok())
210                        .unwrap_or(0.0);
211                    store.evidence(&token, min_bel).and(scope)
212                }
213                // (s-path :s <n> (source <atom>) (target <atom>)) — §3.3/§6.1. Returns the situations the
214                // path passes through, so it composes with the rest of the algebra like any other set.
215                "s-path" => {
216                    let s_thr = keyword_arg(args, ":s").and_then(|v| v.parse::<usize>().ok()).unwrap_or(1);
217                    let endpoint = |name: &str| -> Option<String> {
218                        args.iter().find_map(|n| match n {
219                            Node::List(it)
220                                if matches!(it.first(), Some(Node::Atom(h)) if h == name) =>
221                            {
222                                match it.get(1) {
223                                    Some(Node::Atom(t)) => Some(t.clone()),
224                                    _ => None,
225                                }
226                            }
227                            _ => None,
228                        })
229                    };
230                    let (Some(src), Some(dst)) = (endpoint("source"), endpoint("target")) else {
231                        return Err(QueryError::Malformed(
232                            "s-path needs (source <atom>) and (target <atom>)".into(),
233                        ));
234                    };
235                    // an optional (constraint <expr>) narrows the scope BEFORE the walk — the predicate
236                    // pushdown of §3.3, which is what bounds the traversal
237                    let mut walk_scope = scope.clone();
238                    for a in args {
239                        if let Node::List(it) = a {
240                            if matches!(it.first(), Some(Node::Atom(h)) if h == "constraint") {
241                                if let Some(inner) = it.get(1) {
242                                    walk_scope = walk_scope.and(&try_eval_node(store, inner, scope)?);
243                                }
244                            }
245                        }
246                    }
247                    match store.s_path(&src, &dst, s_thr) {
248                        Some(found) => found.and(&walk_scope),
249                        None => {
250                            return Err(QueryError::Malformed(
251                                "this store has no topological layer, so s-path cannot be evaluated".into(),
252                            ))
253                        }
254                    }
255                }
256                // (combine-ds :max-conflict <f> (stream …)+) — §6.1. Fuses independent streams by
257                // Dempster's rule and REFUSES when they disagree beyond the stated threshold.
258                "combine-ds" => {
259                    let threshold = keyword_arg(args, ":max-conflict")
260                        .and_then(|v| v.parse::<f64>().ok())
261                        .unwrap_or(1.0);
262                    let mut masses: Vec<crate::evidence::Mass<B>> = Vec::new();
263                    for a in args {
264                        let Node::List(items) = a else { continue };
265                        if !matches!(items.first(), Some(Node::Atom(h)) if h == "stream") {
266                            continue;
267                        }
268                        // :mass-assignments ((mass (…) f) …)
269                        let pos = items
270                            .iter()
271                            .position(|n| matches!(n, Node::Atom(k) if k == ":mass-assignments"));
272                        let Some(Node::List(focals)) = pos.and_then(|i| items.get(i + 1)) else {
273                            return Err(QueryError::Malformed(
274                                "stream needs :mass-assignments ((mass (<atom>+) <float>) …)".into(),
275                            ));
276                        };
277                        let mut built: Vec<(B, f64)> = Vec::new();
278                        for f in focals {
279                            match focal_of(store, f, scope) {
280                                // a focal set that matches nothing in this corpus carries no evidence;
281                                // dropping it keeps the mass function valid instead of failing the query
282                                Some((set, m)) if !set.is_empty() => built.push((set, m)),
283                                Some(_) => {}
284                                None => {
285                                    return Err(QueryError::Malformed(
286                                        "expected (mass (<atom>+) <float>)".into(),
287                                    ))
288                                }
289                            }
290                        }
291                        // renormalise what survived, so dropped focal sets cannot silently unbalance it
292                        let total: f64 = built.iter().map(|(_, m)| *m).sum();
293                        if total <= 0.0 {
294                            continue;
295                        }
296                        for (_, m) in built.iter_mut() {
297                            *m /= total;
298                        }
299                        match crate::evidence::Mass::new(built) {
300                            Ok(m) => masses.push(m),
301                            Err(e) => return Err(QueryError::Malformed(e.to_string())),
302                        }
303                    }
304                    if masses.is_empty() {
305                        return Err(QueryError::Malformed("combine-ds needs at least one stream".into()));
306                    }
307                    match crate::evidence::combine_all(&masses, threshold) {
308                        Ok(fused) => {
309                            // everything still possible after fusion
310                            let mut out = B::empty();
311                            for (set, _) in fused.focals() {
312                                out.or_inplace(set);
313                            }
314                            out.and(scope)
315                        }
316                        Err((_, crate::evidence::EvidenceError::ConflictExceeded { conflict, threshold })) => {
317                            return Err(QueryError::Conflict { conflict, threshold })
318                        }
319                        Err((_, crate::evidence::EvidenceError::TotalConflict)) => {
320                            return Err(QueryError::Conflict { conflict: 1.0, threshold })
321                        }
322                        Err((i, e)) => {
323                            return Err(QueryError::Malformed(format!("stream {i}: {e}")))
324                        }
325                    }
326                }
327                // (num <field> <op> <value>) — numeric range predicate over the columnar numeric layer
328                "num" if args.len() == 3 => {
329                    if let (Node::Atom(field), Node::Atom(op), Node::Atom(val)) = (&args[0], &args[1], &args[2]) {
330                        match val.parse::<f64>().ok().or_else(|| crate::units::parse_number(val)) {
331                            Some(v) => store.numeric(field, op, v).and(scope),
332                            None => B::empty(),
333                        }
334                    } else {
335                        B::empty()
336                    }
337                }
338                // bare list with no leading operator = implicit AND
339                // These heads ARE implemented; reaching here means the shape was wrong, which is a different
340                // failure from "not implemented" and must not be reported as one.
341                "num" => {
342                    return Err(QueryError::Malformed(
343                        "num needs (num <field> <op> <value>), op one of ge gt le lt eq ne".into(),
344                    ))
345                }
346                "evidence" => {
347                    return Err(QueryError::Malformed(
348                        "evidence needs (evidence <atom> :min-bel <float>)".into(),
349                    ))
350                }
351                // A head the linter accepts but this evaluator does not implement must FAIL LOUDLY. Falling
352                // through to implicit AND turns it into an empty result, which reads as "no rows matched" — a
353                // wrong answer to a query that passed type-checking. This is how `evidence`, `combine-ds` and
354                // `s-path` each shipped broken.
355                other
356                    if crate::linter::STRUCTURAL_HEADS.contains(&other)
357                        && !crate::linter::SUB_FORMS.contains(&other) =>
358                {
359                    return Err(QueryError::Malformed(format!(
360                        "'{other}' is accepted by the linter but not implemented by the evaluator"
361                    )))
362                }
363                _ => {
364                    let mut r = scope.clone();
365                    for a in items {
366                        r = r.and(&try_eval_node(store, a, scope)?);
367                    }
368                    r
369                }
370            }
371        }
372    })
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    #[test]
380    fn parse_nested() {
381        let n = parse("(and a (or b c) (not d))");
382        match n {
383            Node::List(v) => assert_eq!(v.len(), 4),
384            _ => panic!("expected list"),
385        }
386    }
387
388    // ── a tiny store, so the query semantics are tested without an index ──
389    use crate::bitmap::RoarPostings as P;
390    use std::collections::HashMap;
391
392    struct Toy {
393        sets: HashMap<String, P>,
394        universe: P,
395        /// tokens whose situations are refuted, to exercise the evidence atom
396        refuted: HashMap<String, P>,
397    }
398    impl Toy {
399        fn new(pairs: &[(&str, &[u32])]) -> Toy {
400            let mut sets = HashMap::new();
401            let mut all: Vec<u32> = Vec::new();
402            for (k, ids) in pairs {
403                sets.insert((*k).to_string(), P::from_sorted(ids));
404                all.extend_from_slice(ids);
405            }
406            all.sort_unstable();
407            all.dedup();
408            Toy { sets, universe: P::from_sorted(&all), refuted: HashMap::new() }
409        }
410        fn refute(mut self, token: &str, ids: &[u32]) -> Toy {
411            self.refuted.insert(token.to_string(), P::from_sorted(ids));
412            self
413        }
414    }
415    impl TokenStore<P> for Toy {
416        fn atom(&self, p: &str) -> P {
417            self.sets.get(p).cloned().unwrap_or_else(P::empty)
418        }
419        fn universe(&self) -> P {
420            self.universe.clone()
421        }
422        fn evidence(&self, token: &str, min_bel: f64) -> P {
423            let base = self.atom(token);
424            if min_bel <= 0.0 {
425                return base;
426            }
427            match self.refuted.get(token) {
428                Some(bad) => base.and_not(bad),
429                None => base,
430            }
431        }
432    }
433
434    #[test]
435    fn the_evidence_atom_filters_by_belief_instead_of_returning_empty() {
436        // situations 1..4 mention it; 3 and 4 refute it
437        let store = Toy::new(&[("artifact/cell", &[1, 2, 3, 4])]).refute("artifact/cell", &[3, 4]);
438
439        // the bug this covers: `evidence` used to fall through to implicit AND, where ":min-bel" and "0.8"
440        // are atoms matching nothing, so the whole query collapsed to empty
441        let got = try_evaluate(&store, "(evidence artifact/cell :min-bel 0.8)").unwrap();
442        assert_eq!(got.to_sorted(), vec![1, 2], "must drop the refuted situations, not everything");
443
444        // with no threshold it is plain membership
445        let all = try_evaluate(&store, "(evidence artifact/cell :min-bel 0.0)").unwrap();
446        assert_eq!(all.to_sorted(), vec![1, 2, 3, 4]);
447    }
448
449    #[test]
450    fn combine_ds_fuses_agreeing_streams() {
451        let store = Toy::new(&[("a", &[1, 2, 3]), ("b", &[3, 4, 5])]);
452        let q = "(combine-ds :max-conflict 0.5 \
453                   (stream :id s1 :mass-assignments ((mass (a) 0.7) (mass (a b) 0.3))) \
454                   (stream :id s2 :mass-assignments ((mass (a) 0.6) (mass (a b) 0.4))))";
455        let got = try_evaluate(&store, q).expect("compatible streams should fuse");
456        // both streams favour a; the fused possibilities stay inside a ∪ b
457        assert!(!got.is_empty());
458        assert!(got.to_sorted().iter().all(|s| (1..=5).contains(s)), "{:?}", got.to_sorted());
459    }
460
461    #[test]
462    fn combine_ds_refuses_when_streams_contradict() {
463        // disjoint evidence: one stream says {1,2}, the other {8,9}
464        let store = Toy::new(&[("x", &[1, 2]), ("y", &[8, 9])]);
465        let q = "(combine-ds :max-conflict 0.2 \
466                   (stream :id s1 :mass-assignments ((mass (x) 1.0))) \
467                   (stream :id s2 :mass-assignments ((mass (y) 1.0))))";
468        match try_evaluate(&store, q) {
469            Err(QueryError::Conflict { conflict, threshold }) => {
470                assert!(conflict > threshold, "K={conflict} should exceed {threshold}");
471            }
472            other => panic!("expected a conflict refusal, got {other:?}"),
473        }
474        // and the infallible wrapper must not present that refusal as an ordinary empty answer
475        assert!(evaluate(&store, q).is_empty());
476    }
477
478    #[test]
479    fn a_permissive_threshold_still_fuses_the_same_streams() {
480        let store = Toy::new(&[("x", &[1, 2]), ("y", &[8, 9]), ("either", &[1, 2, 8, 9])]);
481        let q = "(combine-ds :max-conflict 1.0 \
482                   (stream :id s1 :mass-assignments ((mass (x either) 1.0))) \
483                   (stream :id s2 :mass-assignments ((mass (y either) 1.0))))";
484        assert!(try_evaluate(&store, q).is_ok(), "overlapping focal sets are compatible");
485    }
486
487    #[test]
488    fn a_malformed_stream_is_reported_not_silently_ignored() {
489        let store = Toy::new(&[("a", &[1])]);
490        let bad = "(combine-ds :max-conflict 0.5 (stream :id s1))";
491        assert!(matches!(try_evaluate(&store, bad), Err(QueryError::Malformed(_))));
492    }
493
494    #[test]
495    fn plain_queries_are_unaffected_by_the_new_error_channel() {
496        let store = Toy::new(&[("a", &[1, 2, 3]), ("b", &[2, 3, 4])]);
497        assert_eq!(try_evaluate(&store, "(and a b)").unwrap().to_sorted(), vec![2, 3]);
498        assert_eq!(try_evaluate(&store, "(or a b)").unwrap().to_sorted(), vec![1, 2, 3, 4]);
499        assert_eq!(try_evaluate(&store, "(and a (not b))").unwrap().to_sorted(), vec![1]);
500    }
501
502    #[test]
503    fn every_head_the_linter_accepts_is_implemented() {
504        // The contract that used to drift silently. A head whitelisted by the linter with no evaluator arm
505        // passed type-checking and then returned an empty set — indistinguishable from "no rows matched".
506        // s-path, source, target and constraint all sat in that state.
507        let store = Toy::new(&[("a", &[1])]);
508        for head in crate::linter::STRUCTURAL_HEADS {
509            if crate::linter::SUB_FORMS.contains(head) {
510                continue; // only valid inside a parent form
511            }
512            let q = format!("({head})");
513            match try_evaluate(&store, &q) {
514                // implemented: may succeed, or fail for a reason specific to its own shape
515                Ok(_) => {}
516                Err(QueryError::Malformed(m)) => assert!(
517                    !m.contains("not implemented by the evaluator"),
518                    "{head} is accepted by the linter but has no evaluator arm"
519                ),
520                Err(_) => {}
521            }
522        }
523    }
524
525    #[test]
526    fn s_path_walks_the_token_graph_at_the_given_threshold() {
527        use crate::index::InfonIndex;
528        let mut raw: HashMap<String, Vec<u32>> = HashMap::new();
529        raw.insert("a/x".into(), vec![0, 1]);
530        raw.insert("b/y".into(), vec![0, 2]);
531        raw.insert("c/z".into(), vec![1, 2]);
532        let ix: InfonIndex<P> = InfonIndex::from_postings(raw, 3);
533
534        // s=1: one shared situation is enough, so a chain exists
535        let hit = try_evaluate(&ix, "(s-path :s 1 (source a/x) (target c/z))").unwrap();
536        assert!(!hit.is_empty(), "a chain should exist at s=1");
537
538        // s=2: no pair shares two situations, so nothing connects them. An ANSWER, not an error.
539        let none = try_evaluate(&ix, "(s-path :s 2 (source a/x) (target c/z))").unwrap();
540        assert!(none.is_empty(), "raising s must break the weak link");
541
542        // (constraint …) narrows the scope before the walk — the predicate pushdown of §3.3
543        let constrained =
544            try_evaluate(&ix, "(s-path :s 1 (source a/x) (target c/z) (constraint b/y))").unwrap();
545        assert_eq!(constrained.to_sorted(), vec![0, 2], "the constraint must bound the result");
546        assert!(constrained.len() < hit.len(), "constrained must be narrower than unconstrained");
547    }
548
549    #[test]
550    fn a_malformed_s_path_is_refused_rather_than_answered() {
551        use crate::index::InfonIndex;
552        let mut raw: HashMap<String, Vec<u32>> = HashMap::new();
553        raw.insert("a/x".into(), vec![0]);
554        let ix: InfonIndex<P> = InfonIndex::from_postings(raw, 1);
555        assert!(matches!(
556            try_evaluate(&ix, "(s-path :s 1 (source a/x))"),
557            Err(QueryError::Malformed(_))
558        ));
559    }
560
561    #[test]
562    fn an_unknown_endpoint_yields_nothing_without_erroring() {
563        use crate::index::InfonIndex;
564        let mut raw: HashMap<String, Vec<u32>> = HashMap::new();
565        raw.insert("a/x".into(), vec![0]);
566        let ix: InfonIndex<P> = InfonIndex::from_postings(raw, 1);
567        let r = try_evaluate(&ix, "(s-path :s 1 (source a/x) (target nope/tok))").unwrap();
568        assert!(r.is_empty(), "an absent endpoint has no path; that is an answer");
569    }
570}