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::docblobs::put_varint;
15use crate::positions::walk;
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/// Upper bound on the initial reservation for a count read out of a cold
109/// payload — not a limit on the decode, which returns `None` the moment the
110/// payload cannot supply an entry.
111///
112/// Every entry here costs at least one byte (a varint length, or a varint
113/// tag), so a payload of `len` bytes cannot honour a claim past `len`.
114/// `read_varint` returns u32, so an unbounded claim reserves up to 4.29e9
115/// elements — about 103 GB for a `Vec<Vec<u8>>`. Fifth and sixth of the same
116/// shape this release; the earlier ones came from fuzzers, these from
117/// listing every allocation whose size comes out of the bytes.
118pub(crate) fn entries_fit(n: usize, payload_len: usize) -> usize {
119    n.min(payload_len)
120}
121
122/// Decode a forward record. `None` on any malformed frame.
123pub fn decode_fwd(payload: &[u8]) -> Option<FwdRecord> {
124    let mut at = 0usize;
125    let dl = read_varint(payload, &mut at)?;
126    let n = read_varint(payload, &mut at)? as usize;
127    let mut terms = Vec::with_capacity(entries_fit(n, payload.len()));
128    for _ in 0..n {
129        let klen = read_varint(payload, &mut at)? as usize;
130        terms.push(payload.get(at..at + klen)?.to_vec());
131        at += klen;
132    }
133    let nv = read_varint(payload, &mut at)? as usize;
134    let mut values = Vec::with_capacity(entries_fit(nv, payload.len()));
135    for _ in 0..nv {
136        values.push(match read_varint(payload, &mut at)? {
137            0 => None,
138            1 => {
139                let vlen = read_varint(payload, &mut at)? as usize;
140                let v = payload.get(at..at + vlen)?.to_vec();
141                at += vlen;
142                Some(v)
143            }
144            _ => return None,
145        });
146    }
147    (at == payload.len()).then_some(FwdRecord { dl, terms, values })
148}
149
150/// Decode a payload back to its entries. `None` on any malformed
151/// frame — a corrupt payload is a refusal upstream, never a guess.
152pub fn decode_posting(payload: &[u8]) -> Option<Vec<ColdEntry>> {
153    let mut at = 0usize;
154    let n = read_varint(payload, &mut at)? as usize;
155    let mut out = Vec::with_capacity(entries_fit(n, payload.len()));
156    for _ in 0..n {
157        let klen = read_varint(payload, &mut at)? as usize;
158        let key = payload.get(at..at + klen)?.to_vec();
159        at += klen;
160        let tf = read_varint(payload, &mut at)?;
161        let dl = read_varint(payload, &mut at)?;
162        let plen = read_varint(payload, &mut at)? as usize;
163        let positions = payload.get(at..at + plen)?.to_vec();
164        at += plen;
165        out.push(ColdEntry { key, tf, dl, positions });
166    }
167    (at == payload.len()).then_some(out)
168}
169
170/// Accumulate one term's cold contributions into `acc` under the
171/// injected corpus statistics — the same formula, the same globals,
172/// the same scale as the hot path. `dead` shadows revived/deleted
173/// rows; no MaxScore pruning (a hot-only threshold would LOSE cold
174/// documents, not merely misrank them).
175pub fn score_cold(
176    payload: &[u8],
177    term: &[u8],
178    stats: &crate::CorpusStats,
179    dead: &dyn Fn(&[u8]) -> bool,
180    acc: &mut HashMap<Vec<u8>, f64>,
181) -> Option<()> {
182    let entries = decode_posting(payload)?;
183    let df = f64::from(*stats.df.get(term).unwrap_or(&(entries.len() as u32)));
184    for e in entries {
185        if dead(&e.key) {
186            continue;
187        }
188        let s = bm25_score(f64::from(e.tf), df, stats.n_docs, f64::from(e.dl), stats.avgdl);
189        *acc.entry(e.key).or_insert(0.0) += s;
190    }
191    Some(())
192}
193
194/// Accumulate one phrase clause's cold contributions into `acc` —
195/// the mirror of the hot `add_phrase`: a document scores the BM25 sum
196/// of the phrase's DISTINCT tokens (what an AND query would give it),
197/// once, iff the tokens occur consecutively and in order. `payloads`
198/// aligns with `toks` (one term posting payload each; any token
199/// absent from this segment = the phrase matches nothing here, the
200/// `rarest_anchor` `None` mirror). Positions blobs travel verbatim
201/// from the hot channel, so a segment frozen without `WITH POSITIONS`
202/// has empty blobs and verifies nothing — exactly the hot refusal.
203pub fn score_cold_phrase(
204    payloads: &[Vec<u8>],
205    toks: &[Vec<u8>],
206    stats: &crate::CorpusStats,
207    dead: &dyn Fn(&[u8]) -> bool,
208    acc: &mut HashMap<Vec<u8>, f64>,
209) -> Option<()> {
210    if payloads.len() != toks.len() || toks.is_empty() {
211        return None;
212    }
213    let per_tok: Vec<HashMap<Vec<u8>, ColdEntry>> = payloads
214        .iter()
215        .map(|p| decode_posting(p).map(|es| es.into_iter().map(|e| (e.key.clone(), e)).collect()))
216        .collect::<Option<_>>()?;
217    let distinct = crate::segment::distinct_tokens(toks);
218    for (key, first) in &per_tok[0] {
219        if dead(key) || !per_tok[1..].iter().all(|m| m.contains_key(key)) {
220            continue;
221        }
222        let adjacent = walk(&first.positions).any(|start| {
223            toks.iter()
224                .enumerate()
225                .skip(1)
226                .all(|(i, _)| walk(&per_tok[i][key].positions).any(|p| p == start + i as u32))
227        });
228        if !adjacent {
229            continue;
230        }
231        let dl = f64::from(first.dl);
232        let mut score = 0.0;
233        for t in &distinct {
234            let Some(pos) = toks.iter().position(|tt| tt == t) else { continue };
235            let e = &per_tok[pos][key];
236            let df = f64::from(*stats.df.get(t).unwrap_or(&(per_tok[pos].len() as u32)));
237            score += bm25_score(f64::from(e.tf), df, stats.n_docs, dl, stats.avgdl);
238        }
239        *acc.entry(key.clone()).or_insert(0.0) += score;
240    }
241    Some(())
242}
243
244/// Highlight spans over a document's raw field texts — the cold twin
245/// of the hot `highlight_spans`, for a hit whose source text lives in
246/// the ROW rather than the segment (the freeze consumed the stored
247/// copy). Same re-analysis, same span rules, byte-identical output
248/// for the same texts.
249pub fn highlight_fields(fields: &[Vec<u8>], query: &[u8]) -> Vec<(usize, Vec<(usize, usize)>)> {
250    let (bare, phrases, prefixes) = crate::parse_clauses(query);
251    let terms: std::collections::HashSet<&[u8]> = bare.iter().map(Vec::as_slice).collect();
252    let mut out = Vec::new();
253    for (fi, text) in fields.iter().enumerate() {
254        let mut spans =
255            crate::segment::field_spans(&crate::tokenize_spans(text), &terms, &phrases, &prefixes);
256        if !spans.is_empty() {
257            spans.sort_unstable();
258            spans.dedup();
259            out.push((fi, spans));
260        }
261    }
262    out
263}
264
265fn read_varint(b: &[u8], at: &mut usize) -> Option<u32> {
266    let mut cur = 0u32;
267    let mut shift = 0u32;
268    loop {
269        let byte = *b.get(*at)?;
270        *at += 1;
271        cur |= u32::from(byte & 0x7f) << shift;
272        if byte & 0x80 == 0 {
273            return Some(cur);
274        }
275        shift += 7;
276        if shift > 28 {
277            return None;
278        }
279    }
280}
281
282impl TextSegment {
283    /// Freeze `keys` out of the hot index: read each document's terms,
284    /// term frequencies and positions blobs FIRST (withdraw consumes
285    /// the stored source text they are derived from), then withdraw —
286    /// reclaiming the doc record, its postings slots and its positions
287    /// in one motion. Keys not indexed are skipped. `None` when
288    /// nothing froze.
289    pub fn freeze_docs(&mut self, keys: &[Vec<u8>]) -> Option<FrozenBucket> {
290        let mut terms: BTreeMap<Vec<u8>, Vec<ColdEntry>> = BTreeMap::new();
291        let mut fwd: BTreeMap<Vec<u8>, Vec<u8>> = BTreeMap::new();
292        let mut n_docs = 0u64;
293        let mut total_len = 0u64;
294        for key in keys {
295            let Some((id, dl, tf_map)) = self.doc_terms(key) else { continue };
296            n_docs += 1;
297            total_len += u64::from(dl);
298            let mut doc_terms: Vec<&[u8]> = tf_map.keys().map(Vec::as_slice).collect();
299            doc_terms.sort_unstable();
300            let values = self.doc_values_of(id);
301            fwd.insert(key.clone(), encode_fwd(dl, &doc_terms, &values));
302            for (t, tf) in &tf_map {
303                let positions = self.positions_blob(t, id).map(<[u8]>::to_vec).unwrap_or_default();
304                terms.entry(t.clone()).or_default().push(ColdEntry {
305                    key: key.clone(),
306                    tf: *tf,
307                    dl,
308                    positions,
309                });
310            }
311        }
312        if n_docs == 0 {
313            return None;
314        }
315        // Withdraw is a safe no-op for keys that were never indexed.
316        for key in keys {
317            self.apply_doc(key, None, &[]);
318        }
319        let terms = terms
320            .into_iter()
321            .map(|(t, entries)| {
322                let payload = encode_posting(&entries);
323                (t, payload)
324            })
325            .collect();
326        Some(FrozenBucket { terms, fwd, n_docs, total_len })
327    }
328}
329
330#[cfg(test)]
331#[path = "cold_tests.rs"]
332mod tests;
333
334#[cfg(test)]
335mod bound_tests {
336    /// A count out of a cold payload cannot size an allocation.
337    ///
338    /// Sixth site of this shape in one release. The first three were found
339    /// by fuzzers pointing at them, which is why the last three were found
340    /// by listing every allocation whose size comes out of the bytes instead
341    /// of waiting for the next crash.
342    #[test]
343    fn a_count_from_a_payload_cannot_size_an_allocation() {
344        use super::entries_fit;
345        assert_eq!(entries_fit(3, 1024), 3, "an honest count is used as-is");
346        assert_eq!(
347            entries_fit(u32::MAX as usize, 40),
348            40,
349            "4.29e9 entries over forty bytes reserves the ceiling, not 103 GB"
350        );
351        // One byte per entry is the floor, so a payload can always honour
352        // `len` of them — an honest payload is never short-reserved.
353        for len in [0usize, 1, 64, 4096] {
354            assert_eq!(entries_fit(len, len), len, "len at {len} still fits exactly");
355        }
356
357        // The decode refuses the lie either way, which is why the assertion
358        // that sees this defect is the one above and not this one.
359        let mut payload = vec![0xffu8, 0xff, 0xff, 0xff, 0x0f]; // varint u32::MAX
360        payload.push(0x00);
361        assert!(super::decode_posting(&payload).is_none());
362    }
363}