Skip to main content

kevy_embedded/
ops_index_highlight.rs

1//! Clause-carrying MATCH for the embedded API, split from
2//! `ops_index.rs` for the 500-LOC house rule. A `#[path]` child module,
3//! so it reaches `Store`'s crate-private index methods and fields.
4
5use kevy_index::IndexSpec;
6
7use super::claused::{ValueFilter, unknown_field, value_test};
8use super::{FieldSpans, HighlightedHit, sync_segs};
9use crate::store::{Store, lock_write};
10use crate::{KevyError, KevyResult};
11
12/// Everything a text MATCH carries beyond its index, query text and
13/// result limit — the embedded twin of the wire's optional clauses.
14///
15/// Grouping them keeps one entry point instead of one per clause, and
16/// [`MatchOpts::default`] is the plain query, so a caller opts into
17/// exactly the clauses it names.
18#[derive(Clone, Copy, Default)]
19pub struct MatchOpts<'a> {
20    /// `HIGHLIGHT`: `None` = not requested, `Some(&[])` = every indexed
21    /// field, `Some(names)` = only those.
22    pub highlight: Option<&'a [Vec<u8>]>,
23    /// `TYPO n`: edit budget for each bare term; 0 = exact.
24    pub typo: u32,
25    /// `OFFSET n`: hits to skip before `limit` takes effect.
26    pub offset: usize,
27    /// `IN <field…>`: the declared field names to score within; empty =
28    /// the whole document.
29    pub scope: &'a [Vec<u8>],
30    /// `FILTER …`: non-scoring predicates over stored values, ANDed.
31    /// They decide which documents are eligible, not what a term is
32    /// worth, so the corpus statistics stay whole-corpus.
33    pub filters: &'a [ValueFilter<'a>],
34    /// `SORT <field> ASC|DESC`: select by a stored value instead of by
35    /// score. Selecting, not re-ordering — a document that wins on the
36    /// key is chosen even when its score would never have reached the
37    /// page.
38    pub sort: Option<(&'a [u8], bool)>,
39    /// `DISTINCT <field>`: at most one hit per value of a stored field,
40    /// applied during selection so the page holds `limit` distinct
41    /// documents rather than `limit` that then collapse.
42    pub distinct: Option<&'a [u8]>,
43    /// `FACET <field…>`: count each field's values over the whole match
44    /// set. Reported alongside the page rather than shaping it.
45    pub facets: &'a [Vec<u8>],
46}
47
48impl Store {
49    /// [`Self::idx_match`] with every optional clause: highlight spans,
50    /// a typo budget, an offset, and a field scope.
51    ///
52    /// A scoped query is a field-scoped BM25 — frequency, length and
53    /// document frequency all come from the named fields alone — so
54    /// naming a field the index does not declare is an error rather than
55    /// an empty result that would look like a working query.
56    pub fn idx_match_with(
57        &self,
58        name: &[u8],
59        query: &[u8],
60        limit: usize,
61        opts: MatchOpts<'_>,
62    ) -> KevyResult<Vec<HighlightedHit>> {
63        self.idx_match_faceted(name, query, limit, opts).map(|p| p.hits)
64    }
65
66    // The public entry is [`Store::idx_match_faceted`] in the advise
67    // module, which wraps this run with the refusal/usage observation.
68    pub(crate) fn match_faceted_run(
69        &self,
70        name: &[u8],
71        query: &[u8],
72        limit: usize,
73        opts: MatchOpts<'_>,
74    ) -> KevyResult<MatchPage> {
75        let (limit, offset) = (limit.clamp(1, 1000), opts.offset.min(10_000));
76        // Deep enough to skip OFFSET and still fill LIMIT post-merge.
77        let fetch = limit + offset;
78        super::text_cold::cold_refusal(self.text_has_cold(name), query, opts.typo, opts.scope)?;
79        let (scope, tests) = self.resolve_clauses(name, opts.scope, opts.filters)?;
80        let sorted = self.sort_field(name, opts.sort)?;
81        let fkeys = self.facet_keys(name, opts.facets)?;
82        let fac: Vec<kevy_text::Facet> =
83            fkeys.iter().map(|(field, k)| kevy_text::Facet { field: *field, key: k.as_ref() }).collect();
84        let grouped = self.value_field("DISTINCT", name, opts.distinct)?;
85        let dkey = grouped.map(|(_, ty)| move |raw: &[u8]| kevy_index::order_key(ty, raw));
86        let distinct = grouped
87            .zip(dkey.as_ref())
88            .map(|((field, _), k)| kevy_text::Distinct { field, key: k });
89        let key = sorted.map(|(_, _, ty)| move |raw: &[u8]| kevy_index::order_key(ty, raw));
90        let sort = sorted
91            .zip(key.as_ref())
92            .map(|((field, desc, _), k)| kevy_text::Sort { field, desc, key: k });
93        let boxed = box_tests(tests);
94        let filter: Vec<kevy_text::Filter> = boxed
95            .iter()
96            .map(|(f, t)| kevy_text::Filter { field: *f, test: t.as_ref() })
97            .collect();
98        let stats = self.text_corpus_stats_in(name, query, opts.typo, &scope)?;
99        let q = kevy_text::QueryOpts {
100            stats: Some(&stats),
101            typo: opts.typo,
102            fields: &scope,
103            filter: &filter,
104            sort,
105            distinct,
106        };
107        let (mut all, facets, cold_vals) =
108            self.gather_hits(name, query, fetch, q, &stats, opts.highlight, &fac);
109        self.order_page(name, &mut all, sorted, &cold_vals);
110        self.collapse_union(name, &mut all, grouped, &cold_vals);
111        if offset > 0 {
112            all.drain(..offset.min(all.len()));
113        }
114        all.truncate(limit);
115        Ok(MatchPage { hits: all, facets })
116    }
117
118    /// Resolve the clauses that need the index spec: `IN` names onto
119    /// field positions, `FILTER` predicates onto stored-value positions
120    /// and typed tests.
121    ///
122    /// One catalog read for both, and both fail loudly on a name the
123    /// index does not offer — an unknown field could just as easily match
124    /// nothing, but then a typo would return a result indistinguishable
125    /// from a working query with no hits.
126    fn resolve_clauses(
127        &self,
128        name: &[u8],
129        scope: &[Vec<u8>],
130        filters: &[ValueFilter<'_>],
131    ) -> KevyResult<ResolvedClauses> {
132        if scope.is_empty() && filters.is_empty() {
133            return Ok((Vec::new(), Vec::new()));
134        }
135        let guard = self.indexes.catalog.read().unwrap_or_else(|e| e.into_inner());
136        let Some((spec, _)) = guard.1.get(name) else {
137            return Err(KevyError::NotFound("no such text index".into()));
138        };
139        let mut positions = Vec::with_capacity(scope.len());
140        for want in scope {
141            let names = || spec.fields.iter().map(|f| f.name.as_slice()).collect::<Vec<_>>();
142            let i = spec
143                .fields
144                .iter()
145                .position(|f| f.name == *want)
146                .ok_or_else(|| unknown_field("IN", want, "index", &names()))?;
147            positions.push(i);
148        }
149        let tests =
150            filters.iter().map(|f| value_test(spec, f)).collect::<KevyResult<Vec<_>>>()?;
151        Ok((positions, tests))
152    }
153}
154
155/// What the spec-dependent clauses resolve to: `IN`'s field positions,
156/// and `FILTER`'s (stored-value position, typed test) pairs.
157type ResolvedClauses = (Vec<usize>, Vec<(usize, kevy_index::ValueTest)>);
158
159/// Each resolved test boxed as the closure the segment takes. The boxes
160/// must outlive the borrowed `Filter` list, so they are returned rather
161/// than built inline.
162type ValuePred = Box<dyn Fn(&[u8]) -> bool>;
163
164fn box_tests(tests: Vec<(usize, kevy_index::ValueTest)>) -> Vec<(usize, ValuePred)> {
165    tests
166        .into_iter()
167        .map(|(f, t)| {
168            let b: ValuePred = Box::new(move |v: &[u8]| t.passes(v));
169            (f, b)
170        })
171        .collect()
172}
173
174
175impl Store {
176    /// Every shard's page for this query, unmerged, with the facet
177    /// buckets summed by identity as the shards report them.
178    #[allow(clippy::too_many_arguments)]
179    fn gather_hits(
180        &self,
181        name: &[u8],
182        query: &[u8],
183        fetch: usize,
184        q: kevy_text::QueryOpts<'_>,
185        stats: &kevy_text::CorpusStats,
186        highlight: Option<&[Vec<u8>]>,
187        facets: &[kevy_text::Facet],
188    ) -> (Vec<HighlightedHit>, Vec<FacetCounts>, super::text_cold::ColdVals) {
189        let mut all = Vec::new();
190        let mut buckets: Vec<Vec<RawBucket>> = vec![Vec::new(); facets.len()];
191        #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
192        let mut cold_vals = super::text_cold::ColdVals::new();
193        for shard in self.shards.iter() {
194            let mut g = lock_write(shard);
195            let inner = &mut *g;
196            sync_segs(&self.indexes, &mut inner.idx_segs, &mut inner.store);
197            if let Some((spec, ts)) = inner.idx_segs.text.iter().find(|(s, _)| s.name == name) {
198                // `matches_query_with` parses quoted phrases out of the
199                // raw query text; with none it is the ordinary term query.
200                let r = ts.matches_query_faceted(query, fetch, q, facets);
201                for m in r.hits {
202                    let hl = highlight
203                        .map_or_else(Vec::new, |w| hit_highlight(ts, spec, &m.key, query, w));
204                    all.push((m.key, m.score, hl));
205                }
206                for (into, from) in buckets.iter_mut().zip(r.facets) {
207                    fold_raw(into, from);
208                }
209            }
210            // The shard's frozen buckets join the union like one more
211            // ranked contributor — the origin merge below re-orders,
212            // re-collapses and truncates the lot.
213            #[cfg(not(target_arch = "wasm32"))]
214            gather_cold(
215                inner, name, query, fetch, stats, &q, facets, highlight, &mut all,
216                &mut buckets, &mut cold_vals,
217            );
218            #[cfg(target_arch = "wasm32")]
219            let _ = stats;
220        }
221        (all, finish_buckets(buckets), cold_vals)
222    }
223
224    /// Put the merged hits in the page's order: by the sort key when the
225    /// query gave one — the same definition each shard selected by — else
226    /// by score.
227    fn order_page(
228        &self,
229        name: &[u8],
230        all: &mut Vec<HighlightedHit>,
231        sorted: Option<(usize, bool, kevy_index::ValType)>,
232        cold_vals: &super::text_cold::ColdVals,
233    ) {
234        let Some((field, desc, ty)) = sorted else {
235            all.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
236            return;
237        };
238        let mut keyed: Vec<(Option<Vec<u8>>, HighlightedHit)> = std::mem::take(all)
239            .into_iter()
240            .map(|h| (self.stored_order_key(name, &h.0, field, ty, cold_vals), h))
241            .collect();
242        keyed.sort_by(|a, b| {
243            kevy_text::sorted_order((a.0.as_deref(), &a.1.0), (b.0.as_deref(), &b.1.0), desc)
244        });
245        *all = keyed.into_iter().map(|(_, h)| h).collect();
246    }
247
248    /// Collapse the union of the shards' pages: two shards can each hold
249    /// a document with the same value, and only the better survives.
250    ///
251    /// `all` is already in the page's order, so the first occurrence of a
252    /// value is its best and a stable retain keeps exactly that.
253    /// Documents with no value are their own group and all survive — the
254    /// same rule each shard collapsed by.
255    fn collapse_union(
256        &self,
257        name: &[u8],
258        all: &mut Vec<HighlightedHit>,
259        grouped: Option<(usize, kevy_index::ValType)>,
260        cold_vals: &super::text_cold::ColdVals,
261    ) {
262        let Some((field, ty)) = grouped else { return };
263        let mut seen: std::collections::HashSet<Vec<u8>> = std::collections::HashSet::new();
264        all.retain(|h| match self.stored_order_key(name, &h.0, field, ty, cold_vals) {
265            Some(k) => seen.insert(k),
266            None => true,
267        });
268    }
269
270    /// Resolve a `SORT <field> ASC|DESC` clause to the stored-value
271    /// position, the direction, and that field's declared type.
272    fn sort_field(
273        &self,
274        name: &[u8],
275        sort: Option<(&[u8], bool)>,
276    ) -> KevyResult<Option<(usize, bool, kevy_index::ValType)>> {
277        let Some((field, desc)) = sort else { return Ok(None) };
278        let Some((pos, ty)) = self.value_field("SORT", name, Some(field))? else {
279            return Ok(None);
280        };
281        Ok(Some((pos, desc, ty)))
282    }
283
284    /// Each `FACET` field's position paired with the order-preserving
285    /// encoding of its declared type — the identity buckets are grouped
286    /// by. Returned rather than built inline because the borrowed
287    /// `Facet` list points at these closures.
288    fn facet_keys(
289        &self,
290        name: &[u8],
291        facets: &[Vec<u8>],
292    ) -> KevyResult<Vec<FacetKey>> {
293        Ok(self
294            .facet_fields(name, facets)?
295            .into_iter()
296            .map(|(field, ty)| -> FacetKey {
297                (field, Box::new(move |raw: &[u8]| kevy_index::order_key(ty, raw)))
298            })
299            .collect())
300    }
301
302    /// Each `FACET` field's stored-value position and declared type.
303    fn facet_fields(
304        &self,
305        name: &[u8],
306        facets: &[Vec<u8>],
307    ) -> KevyResult<Vec<(usize, kevy_index::ValType)>> {
308        facets
309            .iter()
310            .map(|f| {
311                Ok(self
312                    .value_field("FACET", name, Some(f))?
313                    .expect("a named field always resolves or errors"))
314            })
315            .collect()
316    }
317
318    /// A clause's named stored-value field, as a position and its
319    /// declared type. Errors naming what the index does store.
320    fn value_field(
321        &self,
322        clause: &str,
323        name: &[u8],
324        field: Option<&[u8]>,
325    ) -> KevyResult<Option<(usize, kevy_index::ValType)>> {
326        let Some(field) = field else { return Ok(None) };
327        let guard = self.indexes.catalog.read().unwrap_or_else(|e| e.into_inner());
328        let Some((spec, _)) = guard.1.get(name) else {
329            return Err(KevyError::NotFound("no such text index".into()));
330        };
331        let stored: Vec<&[u8]> = spec.values.iter().map(|v| v.name.as_slice()).collect();
332        let pos = spec
333            .values
334            .iter()
335            .position(|v| v.name == field)
336            .ok_or_else(|| unknown_field(clause, field, "store", &stored))?;
337        Ok(Some((pos, spec.values[pos].ty)))
338    }
339
340    /// One row's sort key: its stored value, in the order-preserving
341    /// encoding of that field's declared type.
342    /// One row's sort key: its stored value from a hot segment, or —
343    /// for a cold hit — from its frozen doc record.
344    fn stored_order_key(
345        &self,
346        name: &[u8],
347        key: &[u8],
348        field: usize,
349        ty: kevy_index::ValType,
350        cold_vals: &super::text_cold::ColdVals,
351    ) -> Option<Vec<u8>> {
352        for shard in self.shards.iter() {
353            let g = lock_write(shard);
354            if let Some((_, ts)) = g.idx_segs.text.iter().find(|(s, _)| s.name == name)
355                && let Some(raw) = ts.stored_value(key, field)
356            {
357                return kevy_index::order_key(ty, raw);
358            }
359        }
360        let raw = cold_vals.get(key)?.get(field)?.as_deref()?;
361        kevy_index::order_key(ty, raw)
362    }
363}
364
365/// One shard's cold contribution to the union: page hits (each with
366/// its row-read highlight when asked), facet buckets folded by
367/// identity, and the frozen values the union's sort/distinct keys
368/// will need.
369#[cfg(not(target_arch = "wasm32"))]
370#[allow(clippy::too_many_arguments)]
371fn gather_cold(
372    inner: &mut crate::store_inner::Inner,
373    name: &[u8],
374    query: &[u8],
375    fetch: usize,
376    stats: &kevy_text::CorpusStats,
377    q: &kevy_text::QueryOpts<'_>,
378    facets: &[kevy_text::Facet],
379    highlight: Option<&[Vec<u8>]>,
380    all: &mut Vec<HighlightedHit>,
381    buckets: &mut [Vec<RawBucket>],
382    cold_vals: &mut super::text_cold::ColdVals,
383) {
384    let Some(dir) = inner.idx_segs.cold_text_of(name).filter(|d| d.has_cold()) else {
385        return;
386    };
387    let (mut bare, phrases, _prefixes) = kevy_text::parse_clauses(query);
388    bare.sort();
389    bare.dedup();
390    let page = dir.cold_page(&kevy_window::ColdPageQuery {
391        bare,
392        phrases,
393        stats,
394        filter: q.filter,
395        sort: q.sort.as_ref(),
396        distinct: q.distinct.as_ref(),
397        facets,
398        fetch,
399    });
400    let spec = inner
401        .idx_segs
402        .text
403        .iter()
404        .find(|(s, _)| s.name == name)
405        .map(|(s, _)| s.clone());
406    for h in page.hits {
407        let hl = highlight.map_or_else(Vec::new, |w| {
408            spec.as_ref().map_or_else(Vec::new, |sp| {
409                super::text_cold::cold_hit_highlight(&mut inner.store, sp, &h.key, query, w)
410            })
411        });
412        all.push((h.key, h.score, hl));
413    }
414    for (into, from) in buckets.iter_mut().zip(page.facets) {
415        fold_raw(into, from);
416    }
417    cold_vals.extend(page.values);
418}
419
420/// Fold one contributor's facet buckets into the running totals by
421/// identity (two spellings of one value sum; the first label wins).
422fn fold_raw(into: &mut Vec<RawBucket>, from: Vec<RawBucket>) {
423    for (key, label, n) in from {
424        match into.iter_mut().find(|(k, _, _)| *k == key) {
425            Some(e) => e.2 += n,
426            None => into.push((key, label, n)),
427        }
428    }
429}
430
431/// A facet field's position paired with the order-preserving encoding of
432/// its declared type — the identity its buckets are grouped by.
433type FacetKey = (usize, Box<dyn Fn(&[u8]) -> Option<Vec<u8>>>);
434
435/// One facet field's reported buckets: `(value, count)`, most frequent
436/// first.
437pub type FacetCounts = Vec<(Vec<u8>, u64)>;
438
439/// One facet bucket in flight, before the grouping identity is dropped.
440type RawBucket = (Vec<u8>, Vec<u8>, u64);
441
442/// A faceted query's answer: the page, and per requested `FACET` field
443/// its `(value, count)` buckets over the whole match set.
444#[derive(Debug)]
445pub struct MatchPage {
446    /// The ranked page — exactly what [`Store::idx_match_with`] returns.
447    pub hits: Vec<HighlightedHit>,
448    /// One entry per requested facet field, most frequent first.
449    pub facets: Vec<FacetCounts>,
450}
451
452/// Drop the grouping identity and order the buckets for reporting: most
453/// frequent first, the label breaking ties so the order is stable.
454fn finish_buckets(buckets: Vec<Vec<RawBucket>>) -> Vec<FacetCounts> {
455    buckets
456        .into_iter()
457        .map(|mut field| {
458            field.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.1.cmp(&b.1)));
459            field.into_iter().map(|(_, label, n)| (label, n)).collect()
460        })
461        .collect()
462}
463
464
465/// One hit's highlight spans as `(field name, [(start, end)])`, filtered
466/// to the requested fields (`want` empty = every field with a match).
467fn hit_highlight(
468    ts: &kevy_text::TextSegment,
469    spec: &IndexSpec,
470    key: &[u8],
471    query: &[u8],
472    want: &[Vec<u8>],
473) -> Vec<FieldSpans> {
474    ts.highlight_spans(key, query)
475        .into_iter()
476        .filter_map(|(fi, spans)| {
477            let name = spec.fields.get(fi)?.name.clone();
478            if !want.is_empty() && !want.contains(&name) {
479                return None;
480            }
481            let ranges = spans.into_iter().map(|(s, e)| (s as u32, e as u32)).collect();
482            Some((name, ranges))
483        })
484        .collect()
485}