Skip to main content

kevy_text/
cold.rs

1//! The frozen half of a text index: the codec a cold bucket segment's
2//! posting payloads use, the freeze that produces them, and the
3//! scorer that reads them back — all pure (no I/O; the segment file
4//! itself is the engine's concern).
5//!
6//! A frozen posting is keyed by row KEY, never by the hot segment's
7//! recycled doc id, and scores against the same injected
8//! [`crate::CorpusStats`] the hot two-pass query uses — which is what
9//! makes a cold hit's score comparable to a hot hit's by construction.
10
11use std::collections::{BTreeMap, HashMap};
12
13use crate::bm25::bm25_score;
14use crate::positions::walk;
15use crate::docblobs::put_varint;
16use crate::segment::TextSegment;
17
18/// One slide batch's worth of frozen text entries: term → encoded
19/// posting payload, in term order (the segment builder's key order),
20/// plus the bucket's contribution to the corpus statistics.
21pub struct FrozenBucket {
22    /// term → [`encode_posting`] payload, ascending by term.
23    pub terms: BTreeMap<Vec<u8>, Vec<u8>>,
24    /// row key → [`encode_fwd`] payload, ascending by key — the
25    /// forward records a later tombstone reads back to withdraw this
26    /// document's statistics contribution exactly.
27    pub fwd: BTreeMap<Vec<u8>, Vec<u8>>,
28    /// Documents frozen.
29    pub n_docs: u64,
30    /// Their summed (unweighted) token length.
31    pub total_len: u64,
32}
33
34/// One decoded cold posting entry.
35pub struct ColdEntry {
36    /// The document's row key.
37    pub key: Vec<u8>,
38    /// Weighted term frequency.
39    pub tf: u32,
40    /// Document length (unweighted tokens).
41    pub dl: u32,
42    /// The positions blob, verbatim from the hot channel; empty when
43    /// the index was not declared `WITH POSITIONS`.
44    pub positions: Vec<u8>,
45}
46
47/// Encode one term's cold posting list:
48/// `[n varint]` then per doc `[klen][key][tf][dl][plen][pos]`.
49pub fn encode_posting(docs: &[ColdEntry]) -> Vec<u8> {
50    let mut out = Vec::new();
51    put_varint(&mut out, docs.len() as u32);
52    for d in docs {
53        put_varint(&mut out, d.key.len() as u32);
54        out.extend_from_slice(&d.key);
55        put_varint(&mut out, d.tf);
56        put_varint(&mut out, d.dl);
57        put_varint(&mut out, d.positions.len() as u32);
58        out.extend_from_slice(&d.positions);
59    }
60    out
61}
62
63/// The document frequency a payload carries — its header, no walk.
64pub fn posting_df(payload: &[u8]) -> Option<u32> {
65    read_varint(payload, &mut 0)
66}
67
68/// One decoded forward record: the document's length, its terms, and
69/// its stored values (aligned with the declared VALUES order).
70pub struct FwdRecord {
71    /// Document length (unweighted tokens).
72    pub dl: u32,
73    /// Every term the document held, ascending.
74    pub terms: Vec<Vec<u8>>,
75    /// Stored values; `None` = the document has no value for the field
76    /// (absent is not a value — a predicate never passes on it).
77    pub values: Vec<Option<Vec<u8>>>,
78}
79
80/// Encode one document's forward record:
81/// `[dl][n terms][klen‖term…][n values][per value: 0 | 1‖len‖bytes]`.
82/// A tombstone reads it back to subtract the document from the
83/// segment's corpus statistics — same numbers, exact withdrawal — and
84/// the value-reading clauses (FILTER / SORT / DISTINCT / FACET) read
85/// it to serve a cold hit without touching the row.
86pub fn encode_fwd(dl: u32, terms: &[&[u8]], values: &[Option<&[u8]>]) -> Vec<u8> {
87    let mut out = Vec::new();
88    put_varint(&mut out, dl);
89    put_varint(&mut out, terms.len() as u32);
90    for t in terms {
91        put_varint(&mut out, t.len() as u32);
92        out.extend_from_slice(t);
93    }
94    put_varint(&mut out, values.len() as u32);
95    for v in values {
96        match v {
97            None => put_varint(&mut out, 0),
98            Some(b) => {
99                put_varint(&mut out, 1);
100                put_varint(&mut out, b.len() as u32);
101                out.extend_from_slice(b);
102            }
103        }
104    }
105    out
106}
107
108/// Decode a forward record. `None` on any malformed frame.
109pub fn decode_fwd(payload: &[u8]) -> Option<FwdRecord> {
110    let mut at = 0usize;
111    let dl = read_varint(payload, &mut at)?;
112    let n = read_varint(payload, &mut at)? as usize;
113    let mut terms = Vec::with_capacity(n);
114    for _ in 0..n {
115        let klen = read_varint(payload, &mut at)? as usize;
116        terms.push(payload.get(at..at + klen)?.to_vec());
117        at += klen;
118    }
119    let nv = read_varint(payload, &mut at)? as usize;
120    let mut values = Vec::with_capacity(nv);
121    for _ in 0..nv {
122        values.push(match read_varint(payload, &mut at)? {
123            0 => None,
124            1 => {
125                let vlen = read_varint(payload, &mut at)? as usize;
126                let v = payload.get(at..at + vlen)?.to_vec();
127                at += vlen;
128                Some(v)
129            }
130            _ => return None,
131        });
132    }
133    (at == payload.len()).then_some(FwdRecord { dl, terms, values })
134}
135
136/// Decode a payload back to its entries. `None` on any malformed
137/// frame — a corrupt payload is a refusal upstream, never a guess.
138pub fn decode_posting(payload: &[u8]) -> Option<Vec<ColdEntry>> {
139    let mut at = 0usize;
140    let n = read_varint(payload, &mut at)? as usize;
141    let mut out = Vec::with_capacity(n);
142    for _ in 0..n {
143        let klen = read_varint(payload, &mut at)? as usize;
144        let key = payload.get(at..at + klen)?.to_vec();
145        at += klen;
146        let tf = read_varint(payload, &mut at)?;
147        let dl = read_varint(payload, &mut at)?;
148        let plen = read_varint(payload, &mut at)? as usize;
149        let positions = payload.get(at..at + plen)?.to_vec();
150        at += plen;
151        out.push(ColdEntry { key, tf, dl, positions });
152    }
153    (at == payload.len()).then_some(out)
154}
155
156/// Accumulate one term's cold contributions into `acc` under the
157/// injected corpus statistics — the same formula, the same globals,
158/// the same scale as the hot path. `dead` shadows revived/deleted
159/// rows; no MaxScore pruning (a hot-only threshold would LOSE cold
160/// documents, not merely misrank them).
161pub fn score_cold(
162    payload: &[u8],
163    term: &[u8],
164    stats: &crate::CorpusStats,
165    dead: &dyn Fn(&[u8]) -> bool,
166    acc: &mut HashMap<Vec<u8>, f64>,
167) -> Option<()> {
168    let entries = decode_posting(payload)?;
169    let df = f64::from(*stats.df.get(term).unwrap_or(&(entries.len() as u32)));
170    for e in entries {
171        if dead(&e.key) {
172            continue;
173        }
174        let s = bm25_score(f64::from(e.tf), df, stats.n_docs, f64::from(e.dl), stats.avgdl);
175        *acc.entry(e.key).or_insert(0.0) += s;
176    }
177    Some(())
178}
179
180/// Accumulate one phrase clause's cold contributions into `acc` —
181/// the mirror of the hot `add_phrase`: a document scores the BM25 sum
182/// of the phrase's DISTINCT tokens (what an AND query would give it),
183/// once, iff the tokens occur consecutively and in order. `payloads`
184/// aligns with `toks` (one term posting payload each; any token
185/// absent from this segment = the phrase matches nothing here, the
186/// `rarest_anchor` `None` mirror). Positions blobs travel verbatim
187/// from the hot channel, so a segment frozen without `WITH POSITIONS`
188/// has empty blobs and verifies nothing — exactly the hot refusal.
189pub fn score_cold_phrase(
190    payloads: &[Vec<u8>],
191    toks: &[Vec<u8>],
192    stats: &crate::CorpusStats,
193    dead: &dyn Fn(&[u8]) -> bool,
194    acc: &mut HashMap<Vec<u8>, f64>,
195) -> Option<()> {
196    if payloads.len() != toks.len() || toks.is_empty() {
197        return None;
198    }
199    let per_tok: Vec<HashMap<Vec<u8>, ColdEntry>> = payloads
200        .iter()
201        .map(|p| {
202            decode_posting(p).map(|es| es.into_iter().map(|e| (e.key.clone(), e)).collect())
203        })
204        .collect::<Option<_>>()?;
205    let distinct = crate::segment::distinct_tokens(toks);
206    for (key, first) in &per_tok[0] {
207        if dead(key) || !per_tok[1..].iter().all(|m| m.contains_key(key)) {
208            continue;
209        }
210        let adjacent = walk(&first.positions).any(|start| {
211            toks.iter().enumerate().skip(1).all(|(i, _)| {
212                walk(&per_tok[i][key].positions).any(|p| p == start + i as u32)
213            })
214        });
215        if !adjacent {
216            continue;
217        }
218        let dl = f64::from(first.dl);
219        let mut score = 0.0;
220        for t in &distinct {
221            let Some(pos) = toks.iter().position(|tt| tt == t) else { continue };
222            let e = &per_tok[pos][key];
223            let df = f64::from(
224                *stats.df.get(t).unwrap_or(&(per_tok[pos].len() as u32)),
225            );
226            score += bm25_score(f64::from(e.tf), df, stats.n_docs, dl, stats.avgdl);
227        }
228        *acc.entry(key.clone()).or_insert(0.0) += score;
229    }
230    Some(())
231}
232
233/// Highlight spans over a document's raw field texts — the cold twin
234/// of the hot `highlight_spans`, for a hit whose source text lives in
235/// the ROW rather than the segment (the freeze consumed the stored
236/// copy). Same re-analysis, same span rules, byte-identical output
237/// for the same texts.
238pub fn highlight_fields(
239    fields: &[Vec<u8>],
240    query: &[u8],
241) -> Vec<(usize, Vec<(usize, usize)>)> {
242    let (bare, phrases, prefixes) = crate::parse_clauses(query);
243    let terms: std::collections::HashSet<&[u8]> = bare.iter().map(Vec::as_slice).collect();
244    let mut out = Vec::new();
245    for (fi, text) in fields.iter().enumerate() {
246        let mut spans = crate::segment::field_spans(
247            &crate::tokenize_spans(text),
248            &terms,
249            &phrases,
250            &prefixes,
251        );
252        if !spans.is_empty() {
253            spans.sort_unstable();
254            spans.dedup();
255            out.push((fi, spans));
256        }
257    }
258    out
259}
260
261fn read_varint(b: &[u8], at: &mut usize) -> Option<u32> {
262    let mut cur = 0u32;
263    let mut shift = 0u32;
264    loop {
265        let byte = *b.get(*at)?;
266        *at += 1;
267        cur |= u32::from(byte & 0x7f) << shift;
268        if byte & 0x80 == 0 {
269            return Some(cur);
270        }
271        shift += 7;
272        if shift > 28 {
273            return None;
274        }
275    }
276}
277
278impl TextSegment {
279    /// Freeze `keys` out of the hot index: read each document's terms,
280    /// term frequencies and positions blobs FIRST (withdraw consumes
281    /// the stored source text they are derived from), then withdraw —
282    /// reclaiming the doc record, its postings slots and its positions
283    /// in one motion. Keys not indexed are skipped. `None` when
284    /// nothing froze.
285    pub fn freeze_docs(&mut self, keys: &[Vec<u8>]) -> Option<FrozenBucket> {
286        let mut terms: BTreeMap<Vec<u8>, Vec<ColdEntry>> = BTreeMap::new();
287        let mut fwd: BTreeMap<Vec<u8>, Vec<u8>> = BTreeMap::new();
288        let mut n_docs = 0u64;
289        let mut total_len = 0u64;
290        for key in keys {
291            let Some((id, dl, tf_map)) = self.doc_terms(key) else { continue };
292            n_docs += 1;
293            total_len += u64::from(dl);
294            let mut doc_terms: Vec<&[u8]> = tf_map.keys().map(Vec::as_slice).collect();
295            doc_terms.sort_unstable();
296            let values = self.doc_values_of(id);
297            fwd.insert(key.clone(), encode_fwd(dl, &doc_terms, &values));
298            for (t, tf) in &tf_map {
299                let positions = self.positions_blob(t, id).map(<[u8]>::to_vec).unwrap_or_default();
300                terms.entry(t.clone()).or_default().push(ColdEntry {
301                    key: key.clone(),
302                    tf: *tf,
303                    dl,
304                    positions,
305                });
306            }
307        }
308        if n_docs == 0 {
309            return None;
310        }
311        // Withdraw is a safe no-op for keys that were never indexed.
312        for key in keys {
313            self.apply_doc(key, None, &[]);
314        }
315        let terms = terms
316            .into_iter()
317            .map(|(t, entries)| {
318                let payload = encode_posting(&entries);
319                (t, payload)
320            })
321            .collect();
322        Some(FrozenBucket { terms, fwd, n_docs, total_len })
323    }
324}
325
326#[cfg(test)]
327#[path = "cold_tests.rs"]
328mod tests;