Skip to main content

graph_r/
query.rs

1// Author: spicyzhug@gmail.com
2// Copyright: lituus-io, all rights reserved
3//
4//! Local lookups that answer with **references** — URL + anchor + snippet —
5//! inside a token budget, never with document bodies. The recipe:
6//!
7//! 1. IDF-weighted tiered lexical scoring over node/segment labels
8//!    (exact ×1000, prefix ×100, substring ×1), scaled by squared
9//!    term-coverage so partial matches of many terms beat a lucky exact
10//!    match of one.
11//! 2. Seed selection with a per-term guarantee (every query term seats its
12//!    best node) capped at a few seeds.
13//! 3. Bounded BFS expansion that refuses to expand *through* hub nodes
14//!    (≥ p99 degree), decaying by hop, edge weight, and confidence.
15//! 4. Fusion with persisted importance, then deterministic seeds-first
16//!    rendering that stops before the token budget is exceeded.
17
18use crate::format::base::{self, LexRec};
19use crate::hash::trigram;
20use crate::key::{EdgeType, NodeId, UrlKey};
21use crate::snapshot::Snapshot;
22use crate::text::for_each_term;
23use crate::traverse::LendingIterator;
24use compact_str::CompactString;
25use std::collections::HashMap;
26
27/// Token allowance for a rendered answer (~4 bytes/token estimate).
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub struct TokenBudget(pub u32);
30
31impl Default for TokenBudget {
32    fn default() -> Self {
33        Self(2000)
34    }
35}
36
37/// Query options.
38#[derive(Clone, Copy, Debug)]
39pub struct QueryOpts {
40    /// Maximum hits returned (budget permitting).
41    pub limit: usize,
42    /// BFS expansion depth from the seeds.
43    pub depth: u8,
44    /// Maximum seed nodes.
45    pub max_seeds: usize,
46    /// Output token budget.
47    pub budget: TokenBudget,
48}
49
50impl Default for QueryOpts {
51    fn default() -> Self {
52        Self { limit: 20, depth: 3, max_seeds: 3, budget: TokenBudget::default() }
53    }
54}
55
56/// A sub-document anchor attached to a hit: fetch `url` and slice by the
57/// byte range (or resolve the heading) instead of re-reading the whole page.
58#[derive(Clone, Copy, Debug)]
59pub struct Anchor<'s> {
60    /// Heading-path label.
61    pub label: &'s str,
62    /// Byte range in the source document, when known.
63    pub byte_range: Option<(u32, u32)>,
64    /// Segment importance in 1/65535 units.
65    pub importance: u16,
66}
67
68/// One lookup answer entry. Borrowed from the snapshot; cheap to produce.
69#[derive(Clone, Copy, Debug)]
70pub struct Hit<'s> {
71    /// Durable key.
72    pub key: UrlKey,
73    /// Canonical URL.
74    pub url: &'s str,
75    /// Title, if known.
76    pub title: Option<&'s str>,
77    /// Distilled snippet, if known.
78    pub snippet: Option<&'s str>,
79    /// Best matching segment anchor, if any.
80    pub anchor: Option<Anchor<'s>>,
81    /// Fused relevance score (higher is better).
82    pub score: f32,
83    /// True when this hit seeded the traversal (direct lexical match).
84    pub seed: bool,
85}
86
87const TIER_EXACT: f32 = 1000.0;
88const TIER_PREFIX: f32 = 100.0;
89const TIER_SUBSTR: f32 = 1.0;
90const HOP_DECAY: f32 = 0.5;
91const CONF_INFERRED: f32 = 0.6;
92const FUSE_TRAVERSAL: f32 = 0.8;
93const FUSE_RANK: f32 = 0.2;
94
95fn dedup_terms(query: &str) -> Vec<CompactString> {
96    let mut terms: Vec<CompactString> = Vec::new();
97    for_each_term(query, |t| {
98        if t.len() >= 2 && !terms.iter().any(|x| x == t) {
99            terms.push(CompactString::from(t));
100        }
101    });
102    terms
103}
104
105/// Per-thread dense scoring buffers, indexed by base node id.
106#[derive(Default)]
107struct Scratch {
108    scores: Vec<NodeScore>,
109    tier_of: Vec<f32>,
110}
111
112thread_local! {
113    static SCRATCH: std::cell::RefCell<Scratch> = std::cell::RefCell::default();
114}
115
116#[derive(Default, Clone, Copy)]
117struct NodeScore {
118    tiered: f32,
119    matched: u32,
120    last_term: u32,
121    best_term_tier: f32,
122}
123
124impl<'s> Snapshot<'s> {
125    /// Lexicon tokens matching `term`, each once at its best tier: exact by
126    /// hash (binary search), prefix by string order (binary search, then the
127    /// run), substring through the term's trigrams (terms of 3+ bytes) —
128    /// O(log V + matches), never a scan of the vocabulary.
129    fn lex_matches(&self, term: &str, hash: u64, mut emit: impl FnMut(LexRec<'_>, f32)) {
130        let (lex, labels) = (self.lexicon(), self.labels());
131        let n = lex.len() / base::LEX_LEN;
132        let rec = |i: usize| LexRec(&lex[i * base::LEX_LEN..(i + 1) * base::LEX_LEN]);
133        let tok = |i: usize| base::label_at(labels, rec(i).label_off()).unwrap_or("");
134        let exact = {
135            let (mut lo, mut hi) = (0, n);
136            while lo < hi {
137                let mid = lo.midpoint(hi);
138                if rec(mid).token_hash() < hash {
139                    lo = mid + 1;
140                } else {
141                    hi = mid;
142                }
143            }
144            (lo < n && rec(lo).token_hash() == hash).then_some(lo)
145        };
146        if let Some(i) = exact {
147            emit(rec(i), TIER_EXACT);
148        }
149        let order = self.section_bytes(base::SectionKind::LexOrder);
150        let at = |k: usize| crate::bytesio::get_u32(order, k * 4) as usize;
151        let (mut lo, mut hi) = (0, order.len() / 4);
152        while lo < hi {
153            let mid = lo.midpoint(hi);
154            if tok(at(mid)) < term {
155                lo = mid + 1;
156            } else {
157                hi = mid;
158            }
159        }
160        for k in lo..order.len() / 4 {
161            let i = at(k);
162            if !tok(i).starts_with(term) {
163                break;
164            }
165            if Some(i) != exact {
166                emit(rec(i), TIER_PREFIX);
167            }
168        }
169        if term.len() < 3 {
170            return;
171        }
172        // The rarest trigram's tokens are the candidates; `contains` decides.
173        let (tri, post) = (
174            self.section_bytes(base::SectionKind::Trigrams),
175            self.section_bytes(base::SectionKind::TriPostings),
176        );
177        let lookup = |g: u32| -> &[u8] {
178            let (mut lo, mut hi) = (0, tri.len() / base::TRI_LEN);
179            while lo < hi {
180                let mid = lo.midpoint(hi);
181                if crate::bytesio::get_u32(tri, mid * base::TRI_LEN) < g {
182                    lo = mid + 1;
183                } else {
184                    hi = mid;
185                }
186            }
187            let r = lo * base::TRI_LEN;
188            if r < tri.len() && crate::bytesio::get_u32(tri, r) == g {
189                let (o, l) = (
190                    crate::bytesio::get_u32(tri, r + 4) as usize,
191                    crate::bytesio::get_u32(tri, r + 8) as usize,
192                );
193                &post[o * 4..(o + l) * 4]
194            } else {
195                &[]
196            }
197        };
198        let mut rarest: Option<&[u8]> = None;
199        for w in term.as_bytes().windows(3) {
200            let ids = lookup(trigram(w));
201            if ids.is_empty() {
202                return; // some trigram never occurs: no token contains the term
203            }
204            if rarest.is_none_or(|r| ids.len() < r.len()) {
205                rarest = Some(ids);
206            }
207        }
208        for c in rarest.unwrap_or(&[]).chunks_exact(4) {
209            let i = crate::bytesio::get_u32(c, 0) as usize;
210            let t = tok(i);
211            if t.contains(term) && !t.starts_with(term) {
212                emit(rec(i), TIER_SUBSTR);
213            }
214        }
215    }
216
217    /// Answer a plain-language `query` with ranked URL + anchor references.
218    #[must_use]
219    #[allow(clippy::too_many_lines)] // the scoring pipeline reads best as one pass
220    pub fn query(&'s self, query: &str, opts: &QueryOpts) -> Vec<Hit<'s>> {
221        let terms = dedup_terms(query);
222        if terms.is_empty() {
223            return Vec::new();
224        }
225        let n_base = self.base_len();
226        let n_docs = n_base.max(1) as f32;
227
228        // ---- lexical scoring over the base lexicon --------------------------
229        // Dense per-node buffers (reset through touched lists): a common term
230        // matching every document costs array writes, not hash-map traffic.
231        // The buffers live per thread and are left all-zero after each use,
232        // so a query never allocates or clears O(documents) memory.
233        let mut scratch = SCRATCH.with(|c| std::mem::take(&mut *c.borrow_mut()));
234        if scratch.scores.len() < n_base {
235            scratch.scores.resize(n_base, NodeScore::default());
236            scratch.tier_of.resize(n_base, 0.0);
237        }
238        let Scratch { scores, tier_of, .. } = &mut scratch;
239        let mut scored: Vec<u32> = Vec::new();
240        let mut touched: Vec<u32> = Vec::new();
241        let mut per_term_best: Vec<Option<(f32, u32)>> = vec![None; terms.len()];
242        for (ti, term) in terms.iter().enumerate() {
243            let hash = crate::hash::xxh3_64(term.as_bytes());
244            let mut df_for_idf: u32 = 0;
245            self.lex_matches(term, hash, |rec, tier| {
246                if tier >= TIER_EXACT {
247                    df_for_idf = df_for_idf.max(rec.df());
248                }
249                let range =
250                    rec.postings_off() as usize..(rec.postings_off() + rec.postings_len()) as usize;
251                if let Ok(ids) = base::decode_postings(&self.postings()[range]) {
252                    for id in ids {
253                        let Some(e) = tier_of.get_mut(id as usize) else { continue };
254                        if *e == 0.0 {
255                            touched.push(id);
256                        }
257                        if tier > *e {
258                            *e = tier;
259                        }
260                    }
261                }
262            });
263            let idf = (1.0 + n_docs / (1.0 + df_for_idf as f32)).ln().max(0.1);
264            for id in touched.drain(..) {
265                let tier = std::mem::take(&mut tier_of[id as usize]);
266                let contribution = tier * idf;
267                let s = &mut scores[id as usize];
268                if s.matched == 0 {
269                    scored.push(id);
270                }
271                if s.last_term != ti as u32 + 1 {
272                    s.matched += 1;
273                    s.last_term = ti as u32 + 1;
274                }
275                s.tiered += contribution;
276                if contribution > s.best_term_tier {
277                    s.best_term_tier = contribution;
278                }
279                let best = per_term_best[ti];
280                if best.is_none_or(|(b, bid)| contribution > b || (contribution >= b && id < bid)) {
281                    per_term_best[ti] = Some((contribution, id));
282                }
283            }
284        }
285
286        // Coverage² scaling; only the leaders are ever read, so select them
287        // rather than sort every match.
288        let coverage_score = |id: u32| {
289            let s = &scores[id as usize];
290            let coverage = s.matched as f32 / terms.len() as f32;
291            s.tiered * coverage * coverage
292        };
293        let mut base_scores: Vec<(u32, f32)> =
294            scored.iter().map(|&id| (id, coverage_score(id))).collect();
295        let by_score = |a: &(u32, f32), b: &(u32, f32)| {
296            b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal).then(a.0.cmp(&b.0))
297        };
298        let lead = opts.max_seeds.min(base_scores.len());
299        if lead < base_scores.len() {
300            base_scores.select_nth_unstable_by(lead, by_score);
301            base_scores.truncate(lead);
302        }
303        base_scores.sort_by(by_score);
304
305        // ---- overlay-fresh nodes (not yet compacted): direct matches --------
306        let mut fresh_hits: Vec<(u64, f32)> = Vec::new();
307        for k in self.overlay_keys() {
308            if self.base_node_id(UrlKey(k)).is_some() {
309                continue;
310            }
311            let Some(node) = self.node(UrlKey(k)) else { continue };
312            if node.is_tombstone() || node.url.is_empty() {
313                continue;
314            }
315            let mut hay: Vec<CompactString> = Vec::new();
316            let searchable = node.url.split_once("://").map_or(node.url, |(_, r)| r);
317            for text in [Some(searchable), node.title, node.snippet].into_iter().flatten() {
318                for_each_term(text, |t| hay.push(CompactString::from(t)));
319            }
320            let mut tiered = 0.0f32;
321            let mut matched = 0u32;
322            for term in &terms {
323                let tier = hay
324                    .iter()
325                    .map(|tok| {
326                        if tok == term {
327                            TIER_EXACT
328                        } else if tok.starts_with(term.as_str()) {
329                            TIER_PREFIX
330                        } else if tok.contains(term.as_str()) {
331                            TIER_SUBSTR
332                        } else {
333                            0.0
334                        }
335                    })
336                    .fold(0.0f32, f32::max);
337                if tier > 0.0 {
338                    matched += 1;
339                    tiered += tier * (1.0 + n_docs).ln();
340                }
341            }
342            if matched > 0 {
343                let coverage = matched as f32 / terms.len() as f32;
344                fresh_hits.push((k, tiered * coverage * coverage));
345            }
346        }
347        fresh_hits.sort_by(|a, b| {
348            b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal).then(a.0.cmp(&b.0))
349        });
350
351        // ---- seed selection --------------------------------------------------
352        let mut seeds: Vec<u32> = Vec::new();
353        for best in per_term_best.iter().flatten() {
354            if !seeds.contains(&best.1) {
355                seeds.push(best.1);
356            }
357        }
358        seeds.sort_unstable();
359        seeds.truncate(opts.max_seeds);
360        for &(id, _) in &base_scores {
361            if seeds.len() >= opts.max_seeds {
362                break;
363            }
364            if !seeds.contains(&id) {
365                seeds.push(id);
366            }
367        }
368
369        // ---- bounded, hub-refusing BFS --------------------------------------
370        let hub = self.hub_threshold();
371
372        let mut arrival: HashMap<u32, f32> = HashMap::new();
373        let mut frontier: Vec<(u32, f32)> = Vec::new();
374        for &s in &seeds {
375            let sc = if scores.get(s as usize).is_some_and(|x| x.matched > 0) {
376                coverage_score(s)
377            } else {
378                0.0
379            };
380            arrival.insert(s, sc);
381            frontier.push((s, sc));
382        }
383        for &id in &scored {
384            scores[id as usize] = NodeScore::default();
385        }
386        SCRATCH.with(|c| *c.borrow_mut() = scratch);
387        for _hop in 0..opts.depth {
388            let mut next: Vec<(u32, f32)> = Vec::new();
389            for &(u, u_score) in &frontier {
390                let u_key = self.rec(NodeId(u)).url_key();
391                let is_seed = seeds.contains(&u);
392                let degree = {
393                    let idx = self.edge_index();
394                    let out = if idx.is_empty() {
395                        0
396                    } else {
397                        crate::bytesio::get_u32(idx, (u as usize + 1) * 4)
398                            - crate::bytesio::get_u32(idx, u as usize * 4)
399                    };
400                    out + self.rec(NodeId(u)).in_degree()
401                };
402                if !is_seed && degree >= hub {
403                    continue; // hubs may be reported, never traversed through
404                }
405                let mut it = self.neighbors(u_key);
406                while let Some(e) = it.next() {
407                    let Some(dst) = e.dst else { continue };
408                    let conf = if e.is_inferred() { CONF_INFERRED } else { 1.0 };
409                    let w = f32::from(e.weight.max(1)) / 65_535.0;
410                    let sc = u_score * HOP_DECAY * w * conf;
411                    if sc <= 0.0 {
412                        continue;
413                    }
414                    let slot = arrival.entry(dst.0).or_insert(0.0);
415                    if sc > *slot {
416                        *slot = sc;
417                        next.push((dst.0, sc));
418                    }
419                }
420            }
421            if next.is_empty() {
422                break;
423            }
424            frontier = next;
425        }
426
427        // ---- fusion + hit assembly ------------------------------------------
428        let max_arrival = arrival.values().copied().fold(0.0f32, f32::max).max(f32::MIN_POSITIVE);
429        let max_fresh =
430            fresh_hits.iter().map(|&(_, s)| s).fold(0.0f32, f32::max).max(f32::MIN_POSITIVE);
431        let mut ranked: Vec<(bool, f32, u64)> = Vec::new();
432        for (&id, &a) in &arrival {
433            let node_id = NodeId(id);
434            let rec = self.rec(node_id);
435            if rec.flags() & base::nflags::TOMBSTONE != 0 || rec.flags() & base::nflags::STUB != 0 {
436                continue;
437            }
438            let key = rec.url_key();
439            if self.overlay().map.get(&key.0).is_some_and(|p| p.removed) {
440                continue;
441            }
442            let fused = FUSE_TRAVERSAL * (a / max_arrival)
443                + FUSE_RANK * f32::from(self.rank_permille_of(node_id)) / 1000.0;
444            ranked.push((seeds.contains(&id), fused, key.0));
445        }
446        for &(k, s) in &fresh_hits {
447            ranked.push((true, FUSE_TRAVERSAL * (s / max_fresh), k));
448        }
449        ranked.sort_by(|a, b| {
450            b.0.cmp(&a.0)
451                .then(b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal))
452                .then(a.2.cmp(&b.2))
453        });
454
455        // ---- budgeted render -------------------------------------------------
456        let mut out: Vec<Hit<'s>> = Vec::new();
457        let mut spent: u32 = 0;
458        for (seed, score, key) in ranked {
459            if out.len() >= opts.limit {
460                break;
461            }
462            let Some(node) = self.node(UrlKey(key)) else { continue };
463            let anchor = self.best_anchor(UrlKey(key), &terms);
464            let cost = estimate_tokens(node.url, node.title, node.snippet, anchor.as_ref());
465            if spent + cost > opts.budget.0 {
466                break;
467            }
468            spent += cost;
469            out.push(Hit {
470                key: UrlKey(key),
471                url: node.url,
472                title: node.title,
473                snippet: node.snippet,
474                anchor,
475                score,
476                seed,
477            });
478        }
479        out
480    }
481
482    fn best_anchor(&'s self, key: UrlKey, terms: &[CompactString]) -> Option<Anchor<'s>> {
483        let segs = self.segments(key);
484        let mut best: Option<(u32, u16, Anchor<'s>)> = None;
485        for s in segs {
486            let mut matches = 0u32;
487            for_each_term(s.label, |tok| {
488                if terms.iter().any(|t| tok == t.as_str() || tok.starts_with(t.as_str())) {
489                    matches += 1;
490                }
491            });
492            if matches == 0 {
493                continue;
494            }
495            let cand =
496                Anchor { label: s.label, byte_range: s.byte_range, importance: s.importance };
497            let better = match &best {
498                None => true,
499                Some((m, imp, _)) => matches > *m || (matches == *m && s.importance > *imp),
500            };
501            if better {
502                best = Some((matches, s.importance, cand));
503            }
504        }
505        best.map(|(_, _, a)| a)
506    }
507
508    /// Documents most related to `key`: outbound links and similarity edges,
509    /// strongest first.
510    #[must_use]
511    pub fn related(&'s self, key: UrlKey, k: usize) -> Vec<Hit<'s>> {
512        let mut ranked: Vec<(f32, u64)> = Vec::new();
513        let mut it = self.neighbors(key);
514        while let Some(e) = it.next() {
515            let conf = if e.is_inferred() { CONF_INFERRED } else { 1.0 };
516            let bonus = if matches!(e.etype, EdgeType::Link) { 0.25 } else { 0.0 };
517            ranked.push((f32::from(e.weight) / 65_535.0 * conf + bonus, e.dst_key.0));
518        }
519        ranked.sort_by(|a, b| {
520            b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal).then(a.1.cmp(&b.1))
521        });
522        let mut out = Vec::new();
523        for (score, dst) in ranked {
524            if out.len() >= k {
525                break;
526            }
527            let Some(node) = self.node(UrlKey(dst)) else { continue };
528            if node.is_stub() || node.is_tombstone() {
529                continue;
530            }
531            out.push(Hit {
532                key: UrlKey(dst),
533                url: node.url,
534                title: node.title,
535                snippet: node.snippet,
536                anchor: None,
537                score,
538                seed: false,
539            });
540        }
541        out
542    }
543}
544
545fn estimate_tokens(
546    url: &str,
547    title: Option<&str>,
548    snippet: Option<&str>,
549    anchor: Option<&Anchor<'_>>,
550) -> u32 {
551    let bytes = url.len()
552        + title.map_or(0, str::len)
553        + snippet.map_or(0, str::len)
554        + anchor.map_or(0, |a| a.label.len() + 12)
555        + 16; // structural overhead per rendered entry
556    crate::text::estimate_tokens(bytes) as u32
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    #[test]
564    fn dedup_terms_keeps_order_and_drops_singles() {
565        let t = dedup_terms("the the auth Auth a flow");
566        assert_eq!(t, ["the", "auth", "flow"]);
567    }
568
569    #[test]
570    fn token_estimate_counts_all_parts() {
571        let a = Anchor { label: "Install", byte_range: None, importance: 1 };
572        let with = estimate_tokens("https://x/y", Some("T"), Some("Snip"), Some(&a));
573        let without = estimate_tokens("https://x/y", Some("T"), Some("Snip"), None);
574        assert!(with > without);
575    }
576}