1use crate::bitmap::Postings;
11
12pub trait TokenStore<B: Postings> {
13 fn atom(&self, pattern: &str) -> B;
14 fn universe(&self) -> B;
15 fn numeric(&self, _field: &str, _op: &str, _value: f64) -> B {
18 B::empty()
19 }
20 fn evidence(&self, token: &str, _min_bel: f64) -> B {
23 self.atom(token)
24 }
25 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
38pub 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 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#[derive(Debug, Clone, PartialEq)]
94pub enum QueryError {
95 Conflict { conflict: f64, threshold: f64 },
97 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
116pub fn evaluate<B: Postings, S: TokenStore<B>>(store: &S, expr: &str) -> B {
118 try_evaluate(store, expr).unwrap_or_else(|_| B::empty())
119}
120
121pub 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
130fn 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
138fn 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" => 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" 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" => {
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 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" => {
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 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 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 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 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" 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 "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 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 use crate::bitmap::RoarPostings as P;
390 use std::collections::HashMap;
391
392 struct Toy {
393 sets: HashMap<String, P>,
394 universe: P,
395 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 let store = Toy::new(&[("artifact/cell", &[1, 2, 3, 4])]).refute("artifact/cell", &[3, 4]);
438
439 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 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 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 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 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 let store = Toy::new(&[("a", &[1])]);
508 for head in crate::linter::STRUCTURAL_HEADS {
509 if crate::linter::SUB_FORMS.contains(head) {
510 continue; }
512 let q = format!("({head})");
513 match try_evaluate(&store, &q) {
514 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 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 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 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}