1use 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#[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#[derive(Clone, Copy, Debug)]
39pub struct QueryOpts {
40 pub limit: usize,
42 pub depth: u8,
44 pub max_seeds: usize,
46 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#[derive(Clone, Copy, Debug)]
59pub struct Anchor<'s> {
60 pub label: &'s str,
62 pub byte_range: Option<(u32, u32)>,
64 pub importance: u16,
66}
67
68#[derive(Clone, Copy, Debug)]
70pub struct Hit<'s> {
71 pub key: UrlKey,
73 pub url: &'s str,
75 pub title: Option<&'s str>,
77 pub snippet: Option<&'s str>,
79 pub anchor: Option<Anchor<'s>>,
81 pub score: f32,
83 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#[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 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 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; }
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 #[must_use]
219 #[allow(clippy::too_many_lines)] 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 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 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 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 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 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; }
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 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 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 #[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; 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}