Skip to main content

steeldb/
programs.rs

1//! Bitmap-program analytics over the infon index — the deterministic template library the agent
2//! composes beyond bare `retrieve`. Ported from the mother's `hypergraph/programs.ts` +
3//! `query-engine.ts` (`fd46262`): partitions (`breakdown`/`crosstab`), salience (`rank`), the
4//! structural s-graph (`cooccurs`/`s_path`/`s_clusters`), and stepwise answerability (`narrow`).
5//!
6//! Everything reduces to roaring set-algebra: partitions are anchor ∩ token popcounts; the s-graph is
7//! token→token overlap (shared situations) with a threshold `s`. Non-discriminative high-DF tokens and
8//! contextual facets (geo/time/qty/…) are held out of the structural graph so the analytics reason over
9//! signal, not stopword-like noise — the `registration()` fix (`71e1714`).
10
11use crate::bitmap::{Postings, RoarPostings};
12use crate::index::InfonIndex;
13use crate::tokenql::evaluate;
14use serde_json::{json, Value};
15use std::collections::{HashMap, HashSet};
16
17type Ix = InfonIndex<RoarPostings>;
18
19/// Contextual / structural-marker facets: a situation's coordinates (geo/time), quantities, and
20/// relation/polarity markers. They sit in huge numbers of situations, so including them pollutes
21/// s-overlap — kept out of MEMBERSHIP but still first-class as scope/filters. (structure.ts
22/// DEFAULT_CONTEXT_FACETS ∪ query-engine NON_CONCEPT.)
23const CONTEXT_FACETS: &[&str] = &[
24    "geo", "region", "location", "loc", "place", "country", "site", "city", "date", "time", "ts",
25    "when", "year", "month", "qty", "unit", "value", "amount", "measure", "num", "metric", "price",
26    "numeric", "dur", "rel", "pol", "doctype", "kano", "sentiment", "src",
27];
28
29/// Cap on the number of nodes admitted to the materialised s-graph (top-N by frequency), so
30/// `s_path` / `s_clusters` neighbour expansion stays bounded on large corpora.
31const MAX_STRUCT_NODES: usize = 2000;
32
33fn facet_of(t: &str) -> &str {
34    t.split('/').next().unwrap_or(t)
35}
36fn leaf_of(t: &str) -> &str {
37    match t.find('/') {
38        Some(i) => &t[i + 1..],
39        None => t,
40    }
41}
42fn is_context(t: &str) -> bool {
43    CONTEXT_FACETS.contains(&facet_of(t))
44}
45/// A token is admissible to the structural graph / analytics when it is a concept facet (not
46/// contextual), not a high-DF noise token, and actually indexed.
47fn admissible(t: &str, noise: &HashSet<String>) -> bool {
48    !is_context(t) && !noise.contains(t)
49}
50
51// ── partitions ──────────────────────────────────────────────────────────────────
52
53/// Partition an anchor set by the values of a facet: for each `facet/value`, the anchor ∩ token count.
54/// A breakdown of an explicitly requested facet shows ALL its values — the high-DF registration filter
55/// (which belongs on the structural graph) must not hide the most frequent values here.
56pub fn breakdown(ix: &Ix, anchor: &str, facet: &str, k: usize) -> Value {
57    let base = evaluate(ix, anchor);
58    let mut rows: Vec<(String, usize)> = Vec::new();
59    for t in ix.facet_members(facet) {
60        let n = base.and(&ix.post(t)).len();
61        if n > 0 {
62            rows.push((t.clone(), n));
63        }
64    }
65    rows.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
66    rows.truncate(k);
67    json!({
68        "program": "breakdown", "anchor": anchor, "facet": facet, "total": base.len(),
69        "partition": rows.iter().map(|(v, n)| json!({ "value": leaf_of(v), "token": v, "count": n })).collect::<Vec<_>>(),
70    })
71}
72
73/// Co-occurrence matrix of two facets over an anchor set (top-k values of each by anchor overlap).
74/// Like `breakdown`, the requested facets' values are shown in full (no high-DF registration filter).
75pub fn crosstab(ix: &Ix, anchor: &str, facet_a: &str, facet_b: &str, k: usize) -> Value {
76    let base = evaluate(ix, anchor);
77    let top = |facet: &str| -> Vec<String> {
78        let mut v: Vec<(String, usize)> = ix
79            .facet_members(facet)
80            .into_iter()
81            .map(|t| (t.clone(), base.and(&ix.post(t)).len()))
82            .filter(|(_, n)| *n > 0)
83            .collect();
84        v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
85        v.truncate(k);
86        v.into_iter().map(|(t, _)| t).collect()
87    };
88    let (a_toks, b_toks) = (top(facet_a), top(facet_b));
89    let matrix: Vec<Value> = a_toks
90        .iter()
91        .map(|a| {
92            let ba = base.and(&ix.post(a));
93            let cells: Vec<Value> = b_toks
94                .iter()
95                .map(|b| json!({ "col": leaf_of(b), "count": ba.and(&ix.post(b)).len() }))
96                .collect();
97            json!({ "row": leaf_of(a), "cells": cells })
98        })
99        .collect();
100    json!({
101        "program": "crosstab", "anchor": anchor, "row_facet": facet_a, "col_facet": facet_b,
102        "cols": b_toks.iter().map(|t| leaf_of(t)).collect::<Vec<_>>(), "matrix": matrix, "total": base.len(),
103    })
104}
105
106// ── salience (MDUS) ───────────────────────────────────────────────────────────────
107
108fn minmax(vals: &[f64]) -> Vec<f64> {
109    let (lo, hi) = vals.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| (lo.min(v), hi.max(v)));
110    let rng = hi - lo;
111    vals.iter().map(|&v| if rng > 0.0 { (v - lo) / rng } else { 0.0 }).collect()
112}
113
114/// Rank a facet's values by salience: a min-max-normalised blend of frequency (posting size) and
115/// cross-concept breadth (how many distinct structural nodes it co-occurs with). Recency is omitted —
116/// the Rust corpus has no reliable per-situation date column — so weights split freq/breadth 0.5/0.5.
117pub fn rank(ix: &Ix, facet: &str, k: usize, noise: &HashSet<String>) -> Value {
118    let st = Structure::build(ix, noise);
119    // rank all values of the requested facet; salience (breadth) already down-weights ubiquitous ones,
120    // so no need to hide high-DF values the way the structural graph does.
121    let ents: Vec<String> = ix.facet_members(facet).into_iter().cloned().collect();
122    if ents.is_empty() {
123        return json!({ "program": "rank", "facet": facet, "ranked": [] });
124    }
125    let freq: Vec<f64> = ents.iter().map(|t| ix.post_len(t) as f64).collect();
126    let breadth: Vec<f64> = ents.iter().map(|t| st.breadth_of(&ix.post(t)) as f64).collect();
127    let (nf, nb) = (minmax(&freq), minmax(&breadth));
128    let mut scored: Vec<(String, f64, f64, f64)> = ents
129        .iter()
130        .enumerate()
131        .map(|(i, t)| {
132            let mdus = 0.5 * nf[i] + 0.5 * nb[i];
133            (t.clone(), mdus, nf[i], nb[i])
134        })
135        .collect();
136    scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
137    scored.truncate(k);
138    json!({
139        "program": "rank", "facet": facet,
140        "ranked": scored.iter().map(|(t, m, f, b)| json!({
141            "token": leaf_of(t), "mdus": (m * 1000.0).round() / 1000.0,
142            "components": { "freq": (f * 1000.0).round() / 1000.0, "breadth": (b * 1000.0).round() / 1000.0 }
143        })).collect::<Vec<_>>(),
144    })
145}
146
147// ── structural s-graph ─────────────────────────────────────────────────────────────
148
149/// Materialised structural token graph: the concept nodes (facet not contextual, not noise) capped to
150/// the top `MAX_STRUCT_NODES` by frequency, with a forward `situation → node-idxs` map so neighbour
151/// discovery is O(situation degree) rather than O(vocab). Two nodes are s-adjacent iff they share ≥ s
152/// situations (overlap = popcount of the postings intersection).
153struct Structure {
154    names: Vec<String>,
155    posts: Vec<RoarPostings>,
156    index: HashMap<String, usize>,
157    forward: HashMap<u32, Vec<usize>>,
158}
159
160impl Structure {
161    fn build(ix: &Ix, noise: &HashSet<String>) -> Structure {
162        let mut nodes: Vec<(&String, usize)> = ix
163            .tokens()
164            .filter(|t| admissible(t, noise))
165            .map(|t| (t, ix.post_len(t)))
166            .filter(|(_, n)| *n > 0)
167            .collect();
168        nodes.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
169        nodes.truncate(MAX_STRUCT_NODES);
170
171        let names: Vec<String> = nodes.iter().map(|(t, _)| (*t).clone()).collect();
172        let posts: Vec<RoarPostings> = names.iter().map(|t| ix.post(t)).collect();
173        let index: HashMap<String, usize> = names.iter().enumerate().map(|(i, t)| (t.clone(), i)).collect();
174        let mut forward: HashMap<u32, Vec<usize>> = HashMap::new();
175        for (i, p) in posts.iter().enumerate() {
176            for sid in p.to_sorted() {
177                forward.entry(sid).or_default().push(i);
178            }
179        }
180        Structure { names, posts, index, forward }
181    }
182
183    /// Distinct structural nodes that share ≥1 situation with the given posting set.
184    fn candidates(&self, post: &RoarPostings) -> HashSet<usize> {
185        let mut out = HashSet::new();
186        for sid in post.to_sorted() {
187            if let Some(idxs) = self.forward.get(&sid) {
188                out.extend(idxs.iter().copied());
189            }
190        }
191        out
192    }
193
194    /// Cross-concept breadth: number of distinct structural nodes co-occurring with `post`.
195    fn breadth_of(&self, post: &RoarPostings) -> usize {
196        self.candidates(post).len()
197    }
198
199    /// s-neighbours of node `i`: candidates sharing ≥ `s` situations.
200    fn neighbors(&self, i: usize, s: usize) -> Vec<usize> {
201        self.candidates(&self.posts[i])
202            .into_iter()
203            .filter(|&j| j != i && self.posts[i].and(&self.posts[j]).len() >= s)
204            .collect()
205    }
206}
207
208
209/// One level of an **s-filtration**: both readings of the incidence matrix at overlap threshold `s`.
210///
211/// The index is an incidence matrix — tags by situations — and it can be read in two directions:
212///
213/// * **primal** — situations are nodes, joined when they share at least `s` tags. *Which events are related?*
214/// * **dual** — tags are nodes, joined when they co-occur in at least `s` situations. *Which concepts belong
215///   together?*
216///
217/// Nothing is rebuilt to switch between them: it is the same matrix transposed. That is the structural reason a
218/// hypergraph engine gets the dual for free where a pairwise graph needs a second index kept in step with the
219/// first.
220#[derive(Debug, Clone, serde::Serialize)]
221pub struct Level {
222    pub s: usize,
223    pub primal: Graph,
224    pub dual: Graph,
225}
226
227/// Edge and component counts for one reading at one threshold.
228#[derive(Debug, Clone, serde::Serialize)]
229pub struct Graph {
230    pub nodes: usize,
231    pub edges: usize,
232    /// connected components — structure appearing as coincidental links are removed
233    pub components: usize,
234    /// a bounded sample of edges, for drawing
235    pub sample: Vec<(usize, usize)>,
236}
237
238/// Connected components of an undirected edge list, by union-find.
239fn components(n: usize, edges: &[(usize, usize)]) -> usize {
240    let mut parent: Vec<usize> = (0..n).collect();
241    fn find(p: &mut [usize], x: usize) -> usize {
242        let mut r = x;
243        while p[r] != r {
244            r = p[r];
245        }
246        // path compression, iterative so a long chain cannot blow the stack
247        let mut c = x;
248        while p[c] != r {
249            let next = p[c];
250            p[c] = r;
251            c = next;
252        }
253        r
254    }
255    for &(a, b) in edges {
256        let (ra, rb) = (find(&mut parent, a), find(&mut parent, b));
257        if ra != rb {
258            parent[ra] = rb;
259        }
260    }
261    let mut roots = HashSet::new();
262    for i in 0..n {
263        roots.insert(find(&mut parent, i));
264    }
265    roots.len()
266}
267
268/// Sweep the overlap threshold from 1 to `max_s`, reporting both topologies at each level.
269///
270/// This is the **s-filtration**. At `s = 1` a single shared item connects almost everything, which is the
271/// regime where an unguarded walk drifts to somewhere unrelated to where it began. Raising `s` demands more
272/// agreement per step: edges fall away while component counts climb, so genuine structure separates from
273/// coincidence.
274///
275/// `sample_cap` bounds the edges returned per level; the counts are always exact.
276pub fn s_filtration(ix: &Ix, max_s: usize, noise: &HashSet<String>, sample_cap: usize) -> Vec<Level> {
277    // tags worth considering, and the situations each covers
278    let tags: Vec<(&String, &RoarPostings)> = ix
279        .postings()
280        .filter(|(t, _)| !noise.contains(t.as_str()))
281        .collect();
282    let n_sit = ix.situations() as usize;
283
284    // primal rows: which tags each situation carries, as indices into `tags`
285    let mut tags_of: Vec<Vec<usize>> = vec![Vec::new(); n_sit];
286    for (ti, (_, post)) in tags.iter().enumerate() {
287        for sid in post.to_sorted() {
288            if let Some(slot) = tags_of.get_mut(sid as usize) {
289                slot.push(ti);
290            }
291        }
292    }
293
294    let overlap = |a: &[usize], b: &[usize]| -> usize {
295        // both lists are ascending, so this is a merge rather than a nested scan
296        let (mut i, mut j, mut n) = (0, 0, 0);
297        while i < a.len() && j < b.len() {
298            match a[i].cmp(&b[j]) {
299                std::cmp::Ordering::Equal => {
300                    n += 1;
301                    i += 1;
302                    j += 1;
303                }
304                std::cmp::Ordering::Less => i += 1,
305                std::cmp::Ordering::Greater => j += 1,
306            }
307        }
308        n
309    };
310
311    (1..=max_s.clamp(1, 16))
312        .map(|s| {
313            let mut p_edges: Vec<(usize, usize)> = Vec::new();
314            for i in 0..n_sit {
315                for j in (i + 1)..n_sit {
316                    if overlap(&tags_of[i], &tags_of[j]) >= s {
317                        p_edges.push((i, j));
318                    }
319                }
320            }
321            let mut d_edges: Vec<(usize, usize)> = Vec::new();
322            for a in 0..tags.len() {
323                for b in (a + 1)..tags.len() {
324                    if tags[a].1.and(tags[b].1).len() >= s {
325                        d_edges.push((a, b));
326                    }
327                }
328            }
329            Level {
330                s,
331                primal: Graph {
332                    nodes: n_sit,
333                    edges: p_edges.len(),
334                    components: components(n_sit, &p_edges),
335                    sample: p_edges.iter().take(sample_cap).copied().collect(),
336                },
337                dual: Graph {
338                    nodes: tags.len(),
339                    edges: d_edges.len(),
340                    components: components(tags.len(), &d_edges),
341                    sample: d_edges.iter().take(sample_cap).copied().collect(),
342                },
343            }
344        })
345        .collect()
346}
347
348/// The names behind the dual node indices of [`s_filtration`], in the same order.
349pub fn dual_node_names(ix: &Ix, noise: &HashSet<String>) -> Vec<String> {
350    ix.postings().filter(|(t, _)| !noise.contains(t.as_str())).map(|(t, _)| t.clone()).collect()
351}
352
353/// Tokens that co-occur most with `token` (shared-situation overlap), over the structural node space.
354pub fn cooccurs(ix: &Ix, token: &str, k: usize, noise: &HashSet<String>) -> Value {
355    let st = Structure::build(ix, noise);
356    let focus = ix.post(token);
357    let mut scored: Vec<(usize, usize)> = st
358        .candidates(&focus)
359        .into_iter()
360        .filter(|&j| st.names[j] != token)
361        .map(|j| (j, focus.and(&st.posts[j]).len()))
362        .filter(|(_, n)| *n > 0)
363        .collect();
364    scored.sort_by(|a, b| b.1.cmp(&a.1).then(st.names[a.0].cmp(&st.names[b.0])));
365    scored.truncate(k);
366    json!({
367        "program": "structure", "op": "cooccurs", "focus": token,
368        "cooccurs": scored.iter().map(|(j, n)| json!({ "token": st.names[*j], "shared": n })).collect::<Vec<_>>(),
369    })
370}
371
372/// Shortest ≥s-overlap path (fewest hops) between two tokens over the structural graph; null if
373/// disconnected at `s`.
374pub fn s_path(ix: &Ix, a: &str, b: &str, s: usize, noise: &HashSet<String>) -> Value {
375    let st = Structure::build(ix, noise);
376    let path = match (st.index.get(a), st.index.get(b)) {
377        (Some(&src), Some(&dst)) => bfs_path(&st, src, dst, s.max(1)),
378        _ => None,
379    };
380    json!({
381        "program": "structure", "op": "s_path", "a": a, "b": b, "s": s,
382        "path": path.map(|p| p.iter().map(|&i| st.names[i].clone()).collect::<Vec<_>>()),
383    })
384}
385
386fn bfs_path(st: &Structure, src: usize, dst: usize, s: usize) -> Option<Vec<usize>> {
387    if src == dst {
388        return Some(vec![src]);
389    }
390    let mut prev: HashMap<usize, Option<usize>> = HashMap::new();
391    prev.insert(src, None);
392    let mut queue = std::collections::VecDeque::from([src]);
393    while let Some(u) = queue.pop_front() {
394        for v in st.neighbors(u, s) {
395            if let std::collections::hash_map::Entry::Vacant(e) = prev.entry(v) {
396                e.insert(Some(u));
397                if v == dst {
398                    let mut path = vec![dst];
399                    let mut n = dst;
400                    while let Some(Some(p)) = prev.get(&n) {
401                        path.push(*p);
402                        n = *p;
403                    }
404                    path.reverse();
405                    return Some(path);
406                }
407                queue.push_back(v);
408            }
409        }
410    }
411    None
412}
413
414/// Connected components of the structural graph at overlap threshold `s` — the token clusters.
415pub fn s_clusters(ix: &Ix, s: usize, k: usize, noise: &HashSet<String>) -> Value {
416    let st = Structure::build(ix, noise);
417    let s = s.max(1);
418    let mut seen = vec![false; st.names.len()];
419    let mut clusters: Vec<Vec<usize>> = Vec::new();
420    for start in 0..st.names.len() {
421        if seen[start] {
422            continue;
423        }
424        let mut comp = Vec::new();
425        let mut stack = vec![start];
426        seen[start] = true;
427        while let Some(u) = stack.pop() {
428            comp.push(u);
429            for v in st.neighbors(u, s) {
430                if !seen[v] {
431                    seen[v] = true;
432                    stack.push(v);
433                }
434            }
435        }
436        if comp.len() > 1 {
437            clusters.push(comp);
438        }
439    }
440    clusters.sort_by(|a, b| b.len().cmp(&a.len()));
441    clusters.truncate(k);
442    json!({
443        "program": "structure", "op": "s_clusters", "s": s,
444        "clusters": clusters.iter().map(|c| json!({
445            "size": c.len(),
446            "tokens": c.iter().take(12).map(|&i| st.names[i].clone()).collect::<Vec<_>>(),
447        })).collect::<Vec<_>>(),
448    })
449}
450
451// ── stepwise answerability ──────────────────────────────────────────────────────────
452
453/// Add scope/filter tokens one at a time and report how the matched set shrinks — where it hits zero
454/// tells you which constraint the corpus cannot satisfy.
455pub fn narrow(ix: &Ix, scope: &[String], filters: &[String]) -> Value {
456    let mut parts: Vec<String> = Vec::new();
457    let mut steps: Vec<Value> = Vec::new();
458    let mut empty_at: Option<String> = None;
459    let seq: Vec<(&str, &String)> = scope
460        .iter()
461        .map(|t| ("concept", t))
462        .chain(filters.iter().map(|t| ("filter", t)))
463        .collect();
464    for (kind, piece) in seq {
465        parts.push(piece.clone());
466        let cur = if parts.len() == 1 { parts[0].clone() } else { format!("(and {})", parts.join(" ")) };
467        let n = evaluate(ix, &cur).len();
468        steps.push(json!({ "add": piece, "kind": kind, "expr": cur, "remaining": n }));
469        if n == 0 && empty_at.is_none() {
470            empty_at = Some(piece.clone());
471        }
472    }
473    let answerable = steps.last().and_then(|s| s.get("remaining")).and_then(|n| n.as_u64()).unwrap_or(0) > 0;
474    json!({
475        "program": "narrow", "steps": steps,
476        "verdict": if answerable { "ANSWERABLE" } else { "NOT ANSWERABLE" }, "empty_at": empty_at,
477    })
478}