Skip to main content

kevy_window/
text_query.rs

1//! The cold directory's query face — what the two MATCH passes read
2//! out of the frozen buckets. Pass 1 takes the live corpus counters;
3//! pass 2 takes a whole clause-faithful page: bare terms and phrases
4//! accumulate (the hot engine's exact clause semantics, over the
5//! frozen postings), FILTER prunes on the frozen stored values,
6//! FACET counts the filtered match set, and selection runs in the
7//! page's own order — score order, or `sorted_order` under SORT, the
8//! same rule the hot top-K and the cross-shard merge use.
9//!
10//! A child module of [`super`] (`#[path]`), so it reaches the cold
11//! directory's private shape.
12
13use std::collections::HashMap;
14
15use kevy_text::cold::{decode_fwd, posting_df, score_cold, score_cold_phrase};
16use kevy_text::{CorpusStats, sorted_order};
17
18use super::TextColdDir;
19
20/// Everything pass 2 asks of the cold directory.
21#[derive(Debug)]
22pub struct ColdPageQuery<'a> {
23    /// Bare terms, sorted and deduplicated (the hot engine's rule).
24    pub bare: Vec<Vec<u8>>,
25    /// Each phrase's token sequence.
26    pub phrases: Vec<Vec<Vec<u8>>>,
27    /// The injected global statistics both passes score with.
28    pub stats: &'a CorpusStats,
29    /// `FILTER` predicates, ANDed, over the frozen stored values.
30    pub filter: &'a [kevy_text::Filter<'a>],
31    /// `SORT`: the page order is the sort key's, not the score's.
32    pub sort: Option<&'a kevy_text::Sort<'a>>,
33    /// `DISTINCT`: collapse to the best hit per value identity.
34    pub distinct: Option<&'a kevy_text::Distinct<'a>>,
35    /// `FACET` fields to count over the (filtered) match set.
36    pub facets: &'a [kevy_text::Facet<'a>],
37    /// How deep a page the merge needs (LIMIT + OFFSET).
38    pub fetch: usize,
39}
40
41/// One cold hit: its page-order ingredients, ready to merge.
42#[derive(Debug)]
43pub struct ColdHit {
44    /// The row key this hit points at.
45    pub key: Vec<u8>,
46    /// Its BM25 relevance. Comparable across segments because the
47    /// document-frequency corrections are applied before the merge, not
48    /// after — a per-segment score would not be.
49    pub score: f64,
50    /// The sort key, when the query sorts by a stored value.
51    pub okey: Option<Vec<u8>>,
52}
53
54/// The cold half of one shard's pass-2 answer.
55#[derive(Debug)]
56pub struct ColdPage {
57    /// Best `fetch` cold hits in the page's order.
58    pub hits: Vec<ColdHit>,
59    /// The returned hits' frozen stored values — what the merge reads
60    /// for sort/distinct identities and the origin's okeys/dkeys.
61    pub values: HashMap<Vec<u8>, Vec<Option<Vec<u8>>>>,
62    /// Per requested facet field, (identity, label, count) over the
63    /// filtered cold match set.
64    pub facets: Vec<Vec<kevy_text::Bucket>>,
65}
66
67impl TextColdDir {
68    /// Pass-1 contribution: summed LIVE docs/length plus per-token
69    /// live df across every cold segment (one fence descent per token
70    /// per segment; the doc/length halves are in-memory numbers, no
71    /// I/O at all).
72    pub fn cold_stats(&self, tokens: &[Vec<u8>]) -> (u64, u64, Vec<(Vec<u8>, u32)>) {
73        let n_docs: u64 = self.segs.iter().map(|c| c.n_docs).sum();
74        let total_len: u64 = self.segs.iter().map(|c| c.total_len).sum();
75        let df = tokens
76            .iter()
77            .map(|t| {
78                let frozen: u32 = self
79                    .segs
80                    .iter()
81                    .filter_map(|c| c.seg.get(t).ok().flatten())
82                    .filter_map(|p| posting_df(&p))
83                    .sum();
84                let dead = self.df_dead.get(t).copied().unwrap_or(0);
85                (t.clone(), frozen.saturating_sub(dead))
86            })
87            .collect();
88        (n_docs, total_len, df)
89    }
90
91    /// Pass-2 contribution: the clause-faithful cold page (see the
92    /// module doc for what each clause does here).
93    pub fn cold_page(&self, q: &ColdPageQuery) -> ColdPage {
94        let acc = self.accumulate(q);
95        let need_values = !q.filter.is_empty()
96            || q.sort.is_some()
97            || q.distinct.is_some()
98            || !q.facets.is_empty();
99        let mut values: HashMap<Vec<u8>, Vec<Option<Vec<u8>>>> = HashMap::new();
100        let mut cands: Vec<ColdHit> = Vec::new();
101        for (key, score) in acc {
102            let vals = if need_values {
103                let Some(v) = self.frozen_values(&key) else { continue };
104                if !passes(&v, q.filter) {
105                    continue;
106                }
107                Some(v)
108            } else {
109                None
110            };
111            let okey = q.sort.and_then(|s| vals.as_ref()?.get(s.field)?.as_deref().and_then(s.key));
112            if let Some(v) = vals {
113                values.insert(key.clone(), v);
114            }
115            cands.push(ColdHit { key, score, okey });
116        }
117        let facets = self.count_facets(q, &cands, &values);
118        order_page(&mut cands, q.sort.is_some(), q.sort.is_some_and(|s| s.desc));
119        if let Some(d) = q.distinct {
120            collapse(&mut cands, d, &values);
121        }
122        cands.truncate(q.fetch);
123        values.retain(|k, _| cands.iter().any(|c| &c.key == k));
124        ColdPage { hits: cands, values, facets }
125    }
126
127    /// Every clause's accumulated cold score, keyed by row key — the
128    /// mirror of the hot `accumulate_clauses` over the frozen postings.
129    fn accumulate(&self, q: &ColdPageQuery) -> HashMap<Vec<u8>, f64> {
130        let mut acc = HashMap::new();
131        for cs in &self.segs {
132            let dead = |k: &[u8]| self.tombs.get(k).is_some_and(|s| s.contains(&cs.seq));
133            for t in &q.bare {
134                if let Ok(Some(payload)) = cs.seg.get(t) {
135                    let _ = score_cold(&payload, t, q.stats, &dead, &mut acc);
136                }
137            }
138            for phrase in &q.phrases {
139                let payloads: Option<Vec<Vec<u8>>> =
140                    phrase.iter().map(|t| cs.seg.get(t).ok().flatten()).collect();
141                // A phrase token absent from this segment = the phrase
142                // matches nothing here (the rarest-anchor None mirror).
143                if let Some(payloads) = payloads {
144                    let _ = score_cold_phrase(&payloads, phrase, q.stats, &dead, &mut acc);
145                }
146            }
147        }
148        acc
149    }
150
151    /// One live cold document's frozen stored values, from whichever
152    /// segment holds its un-shadowed copy.
153    fn frozen_values(&self, key: &[u8]) -> Option<Vec<Option<Vec<u8>>>> {
154        let mut fwd_key = vec![0u8];
155        fwd_key.extend_from_slice(key);
156        for cs in &self.segs {
157            if self.tombs.get(key).is_some_and(|s| s.contains(&cs.seq)) {
158                continue;
159            }
160            if let Ok(Some(payload)) = cs.seg.get(&fwd_key) {
161                return decode_fwd(&payload).map(|r| r.values);
162            }
163        }
164        None
165    }
166
167    /// The cold half of each facet's count — the hot `count_facet`'s
168    /// rules (filter applies, top-K and DISTINCT do not), ordered the
169    /// same way; the shard merge sums it with the hot half.
170    fn count_facets(
171        &self,
172        q: &ColdPageQuery,
173        cands: &[ColdHit],
174        values: &HashMap<Vec<u8>, Vec<Option<Vec<u8>>>>,
175    ) -> Vec<Vec<kevy_text::Bucket>> {
176        q.facets
177            .iter()
178            .map(|f| {
179                let mut counts: HashMap<Vec<u8>, (Vec<u8>, u64)> = HashMap::new();
180                for c in cands {
181                    let Some(raw) =
182                        values.get(&c.key).and_then(|v| v.get(f.field)).and_then(Option::as_deref)
183                    else {
184                        continue;
185                    };
186                    let Some(k) = (f.key)(raw) else { continue };
187                    counts.entry(k).or_insert_with(|| (raw.to_vec(), 0)).1 += 1;
188                }
189                let mut out: Vec<kevy_text::Bucket> =
190                    counts.into_iter().map(|(k, (label, n))| (k, label, n)).collect();
191                out.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.1.cmp(&b.1)));
192                out
193            })
194            .collect()
195    }
196}
197
198/// Order candidates by the page's rule: `sorted_order` under SORT
199/// (a document WITH a value outranks one without, in both
200/// directions), else score-descending with the row key as tiebreak.
201fn order_page(cands: &mut [ColdHit], sorted: bool, desc: bool) {
202    if sorted {
203        cands.sort_by(|a, b| {
204            sorted_order((a.okey.as_deref(), &a.key), (b.okey.as_deref(), &b.key), desc)
205        });
206    } else {
207        cands.sort_by(|a, b| {
208            b.score
209                .partial_cmp(&a.score)
210                .unwrap_or(std::cmp::Ordering::Equal)
211                .then_with(|| a.key.cmp(&b.key))
212        });
213    }
214}
215
216/// Collapse an ordered candidate list to the best hit per distinct
217/// identity. A document with no value for the field is its own group
218/// (the hot rule: it has not been shown to share anything).
219fn collapse(
220    cands: &mut Vec<ColdHit>,
221    d: &kevy_text::Distinct,
222    values: &HashMap<Vec<u8>, Vec<Option<Vec<u8>>>>,
223) {
224    let mut seen: std::collections::HashSet<Vec<u8>> = std::collections::HashSet::new();
225    cands.retain(|c| {
226        let identity = values
227            .get(&c.key)
228            .and_then(|v| v.get(d.field))
229            .and_then(Option::as_deref)
230            .and_then(d.key);
231        match identity {
232            None => true,
233            Some(id) => seen.insert(id),
234        }
235    });
236}
237
238/// The hot `passes` mirror over frozen values: every predicate must
239/// pass, and an absent value never does.
240fn passes(values: &[Option<Vec<u8>>], filter: &[kevy_text::Filter]) -> bool {
241    filter
242        .iter()
243        .all(|f| values.get(f.field).and_then(Option::as_deref).is_some_and(|v| (f.test)(v)))
244}