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(Debug, 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> = fkeys
83            .iter()
84            .map(|(field, k)| kevy_text::Facet { field: *field, key: k.as_ref() })
85            .collect();
86        let grouped = self.value_field("DISTINCT", name, opts.distinct)?;
87        let dkey = grouped.map(|(_, ty)| move |raw: &[u8]| kevy_index::order_key(ty, raw));
88        let distinct =
89            grouped.zip(dkey.as_ref()).map(|((field, _), k)| kevy_text::Distinct { field, key: k });
90        let key = sorted.map(|(_, _, ty)| move |raw: &[u8]| kevy_index::order_key(ty, raw));
91        let sort = sorted.zip(key.as_ref()).map(|((field, desc, _), k)| kevy_text::Sort {
92            field,
93            desc,
94            key: k,
95        });
96        let boxed = box_tests(tests);
97        let filter: Vec<kevy_text::Filter> =
98            boxed.iter().map(|(f, t)| kevy_text::Filter { field: *f, test: t.as_ref() }).collect();
99        let stats = self.text_corpus_stats_in(name, query, opts.typo, &scope)?;
100        let q = kevy_text::QueryOpts {
101            stats: Some(&stats),
102            typo: opts.typo,
103            fields: &scope,
104            filter: &filter,
105            sort,
106            distinct,
107        };
108        let (mut all, facets, cold_vals) =
109            self.gather_hits(name, query, fetch, q, &stats, opts.highlight, &fac);
110        self.order_page(name, &mut all, sorted, &cold_vals);
111        self.collapse_union(name, &mut all, grouped, &cold_vals);
112        if offset > 0 {
113            all.drain(..offset.min(all.len()));
114        }
115        all.truncate(limit);
116        Ok(MatchPage { hits: all, facets })
117    }
118
119    /// Resolve the clauses that need the index spec: `IN` names onto
120    /// field positions, `FILTER` predicates onto stored-value positions
121    /// and typed tests.
122    ///
123    /// One catalog read for both, and both fail loudly on a name the
124    /// index does not offer — an unknown field could just as easily match
125    /// nothing, but then a typo would return a result indistinguishable
126    /// from a working query with no hits.
127    fn resolve_clauses(
128        &self,
129        name: &[u8],
130        scope: &[Vec<u8>],
131        filters: &[ValueFilter<'_>],
132    ) -> KevyResult<ResolvedClauses> {
133        if scope.is_empty() && filters.is_empty() {
134            return Ok((Vec::new(), Vec::new()));
135        }
136        let guard = self.indexes.catalog.read().unwrap_or_else(|e| e.into_inner());
137        let Some((spec, _)) = guard.1.get(name) else {
138            return Err(KevyError::NotFound("no such text index".into()));
139        };
140        let mut positions = Vec::with_capacity(scope.len());
141        for want in scope {
142            let names = || spec.fields.iter().map(|f| f.name.as_slice()).collect::<Vec<_>>();
143            let i = spec
144                .fields
145                .iter()
146                .position(|f| f.name == *want)
147                .ok_or_else(|| unknown_field("IN", want, "index", &names()))?;
148            positions.push(i);
149        }
150        let tests = 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
174impl Store {
175    /// Every shard's page for this query, unmerged, with the facet
176    /// buckets summed by identity as the shards report them.
177    #[allow(clippy::too_many_arguments)]
178    fn gather_hits(
179        &self,
180        name: &[u8],
181        query: &[u8],
182        fetch: usize,
183        q: kevy_text::QueryOpts<'_>,
184        stats: &kevy_text::CorpusStats,
185        highlight: Option<&[Vec<u8>]>,
186        facets: &[kevy_text::Facet],
187    ) -> (Vec<HighlightedHit>, Vec<FacetCounts>, super::text_cold::ColdVals) {
188        let mut all = Vec::new();
189        let mut buckets: Vec<Vec<RawBucket>> = vec![Vec::new(); facets.len()];
190        #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
191        let mut cold_vals = super::text_cold::ColdVals::new();
192        for shard in self.shards.iter() {
193            let mut g = lock_write(shard);
194            let inner = &mut *g;
195            sync_segs(&self.indexes, &mut inner.idx_segs, &mut inner.store);
196            if let Some((spec, ts)) = inner.idx_segs.text.iter().find(|(s, _)| s.name == name) {
197                // `matches_query_with` parses quoted phrases out of the
198                // raw query text; with none it is the ordinary term query.
199                let r = ts.matches_query_faceted(query, fetch, q, facets);
200                absorb_live(r, ts, spec, query, highlight, &mut all, &mut buckets);
201            }
202            // The shard's frozen buckets join the union like one more
203            // ranked contributor — the origin merge below re-orders,
204            // re-collapses and truncates the lot.
205            #[cfg(not(target_arch = "wasm32"))]
206            gather_cold(
207                inner,
208                name,
209                query,
210                fetch,
211                stats,
212                &q,
213                facets,
214                highlight,
215                &mut all,
216                &mut buckets,
217                &mut cold_vals,
218            );
219            #[cfg(target_arch = "wasm32")]
220            let _ = stats;
221        }
222        (all, finish_buckets(buckets), cold_vals)
223    }
224
225    /// Put the merged hits in the page's order: by the sort key when the
226    /// query gave one — the same definition each shard selected by — else
227    /// by score.
228    fn order_page(
229        &self,
230        name: &[u8],
231        all: &mut Vec<HighlightedHit>,
232        sorted: Option<(usize, bool, kevy_index::ValType)>,
233        cold_vals: &super::text_cold::ColdVals,
234    ) {
235        let Some((field, desc, ty)) = sorted else {
236            all.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
237            return;
238        };
239        let mut keyed: Vec<(Option<Vec<u8>>, HighlightedHit)> = std::mem::take(all)
240            .into_iter()
241            .map(|h| (self.stored_order_key(name, &h.0, field, ty, cold_vals), h))
242            .collect();
243        keyed.sort_by(|a, b| {
244            kevy_text::sorted_order((a.0.as_deref(), &a.1.0), (b.0.as_deref(), &b.1.0), desc)
245        });
246        *all = keyed.into_iter().map(|(_, h)| h).collect();
247    }
248
249    /// Collapse the union of the shards' pages: two shards can each hold
250    /// a document with the same value, and only the better survives.
251    ///
252    /// `all` is already in the page's order, so the first occurrence of a
253    /// value is its best and a stable retain keeps exactly that.
254    /// Documents with no value are their own group and all survive — the
255    /// same rule each shard collapsed by.
256    fn collapse_union(
257        &self,
258        name: &[u8],
259        all: &mut Vec<HighlightedHit>,
260        grouped: Option<(usize, kevy_index::ValType)>,
261        cold_vals: &super::text_cold::ColdVals,
262    ) {
263        let Some((field, ty)) = grouped else { return };
264        let mut seen: std::collections::HashSet<Vec<u8>> = std::collections::HashSet::new();
265        all.retain(|h| match self.stored_order_key(name, &h.0, field, ty, cold_vals) {
266            Some(k) => seen.insert(k),
267            None => true,
268        });
269    }
270
271    /// Resolve a `SORT <field> ASC|DESC` clause to the stored-value
272    /// position, the direction, and that field's declared type.
273    fn sort_field(
274        &self,
275        name: &[u8],
276        sort: Option<(&[u8], bool)>,
277    ) -> KevyResult<Option<(usize, bool, kevy_index::ValType)>> {
278        let Some((field, desc)) = sort else { return Ok(None) };
279        let Some((pos, ty)) = self.value_field("SORT", name, Some(field))? else {
280            return Ok(None);
281        };
282        Ok(Some((pos, desc, ty)))
283    }
284
285    /// Each `FACET` field's position paired with the order-preserving
286    /// encoding of its declared type — the identity buckets are grouped
287    /// by. Returned rather than built inline because the borrowed
288    /// `Facet` list points at these closures.
289    fn facet_keys(&self, name: &[u8], facets: &[Vec<u8>]) -> KevyResult<Vec<FacetKey>> {
290        Ok(self
291            .facet_fields(name, facets)?
292            .into_iter()
293            .map(|(field, ty)| -> FacetKey {
294                (field, Box::new(move |raw: &[u8]| kevy_index::order_key(ty, raw)))
295            })
296            .collect())
297    }
298
299    /// Each `FACET` field's stored-value position and declared type.
300    fn facet_fields(
301        &self,
302        name: &[u8],
303        facets: &[Vec<u8>],
304    ) -> KevyResult<Vec<(usize, kevy_index::ValType)>> {
305        facets
306            .iter()
307            .map(|f| {
308                Ok(self
309                    .value_field("FACET", name, Some(f))?
310                    .expect("a named field always resolves or errors"))
311            })
312            .collect()
313    }
314
315    /// A clause's named stored-value field, as a position and its
316    /// declared type. Errors naming what the index does store.
317    fn value_field(
318        &self,
319        clause: &str,
320        name: &[u8],
321        field: Option<&[u8]>,
322    ) -> KevyResult<Option<(usize, kevy_index::ValType)>> {
323        let Some(field) = field else { return Ok(None) };
324        let guard = self.indexes.catalog.read().unwrap_or_else(|e| e.into_inner());
325        let Some((spec, _)) = guard.1.get(name) else {
326            return Err(KevyError::NotFound("no such text index".into()));
327        };
328        let stored: Vec<&[u8]> = spec.values.iter().map(|v| v.name.as_slice()).collect();
329        let pos = spec
330            .values
331            .iter()
332            .position(|v| v.name == field)
333            .ok_or_else(|| unknown_field(clause, field, "store", &stored))?;
334        Ok(Some((pos, spec.values[pos].ty)))
335    }
336
337    /// One row's sort key: its stored value, in the order-preserving
338    /// encoding of that field's declared type.
339    /// One row's sort key: its stored value from a hot segment, or —
340    /// for a cold hit — from its frozen doc record.
341    fn stored_order_key(
342        &self,
343        name: &[u8],
344        key: &[u8],
345        field: usize,
346        ty: kevy_index::ValType,
347        cold_vals: &super::text_cold::ColdVals,
348    ) -> Option<Vec<u8>> {
349        for shard in self.shards.iter() {
350            let g = lock_write(shard);
351            if let Some((_, ts)) = g.idx_segs.text.iter().find(|(s, _)| s.name == name)
352                && let Some(raw) = ts.stored_value(key, field)
353            {
354                return kevy_index::order_key(ty, raw);
355            }
356        }
357        let raw = cold_vals.get(key)?.get(field)?.as_deref()?;
358        kevy_index::order_key(ty, raw)
359    }
360}
361
362/// One shard's cold contribution to the union: page hits (each with
363/// its row-read highlight when asked), facet buckets folded by
364/// identity, and the frozen values the union's sort/distinct keys
365/// will need.
366#[cfg(not(target_arch = "wasm32"))]
367#[allow(clippy::too_many_arguments)]
368fn gather_cold(
369    inner: &mut crate::store_inner::Inner,
370    name: &[u8],
371    query: &[u8],
372    fetch: usize,
373    stats: &kevy_text::CorpusStats,
374    q: &kevy_text::QueryOpts<'_>,
375    facets: &[kevy_text::Facet],
376    highlight: Option<&[Vec<u8>]>,
377    all: &mut Vec<HighlightedHit>,
378    buckets: &mut [Vec<RawBucket>],
379    cold_vals: &mut super::text_cold::ColdVals,
380) {
381    let Some(dir) = inner.idx_segs.cold_text_of(name).filter(|d| d.has_cold()) else {
382        return;
383    };
384    let (mut bare, phrases, _prefixes) = kevy_text::parse_clauses(query);
385    bare.sort();
386    bare.dedup();
387    let page = dir.cold_page(&kevy_window::ColdPageQuery {
388        bare,
389        phrases,
390        stats,
391        filter: q.filter,
392        sort: q.sort.as_ref(),
393        distinct: q.distinct.as_ref(),
394        facets,
395        fetch,
396    });
397    let spec = inner.idx_segs.text.iter().find(|(s, _)| s.name == name).map(|(s, _)| s.clone());
398    for h in page.hits {
399        let hl = highlight.map_or_else(Vec::new, |w| {
400            spec.as_ref().map_or_else(Vec::new, |sp| {
401                super::text_cold::cold_hit_highlight(&mut inner.store, sp, &h.key, query, w)
402            })
403        });
404        all.push((h.key, h.score, hl));
405    }
406    for (into, from) in buckets.iter_mut().zip(page.facets) {
407        fold_raw(into, from);
408    }
409    cold_vals.extend(page.values);
410}
411
412/// Fold one contributor's facet buckets into the running totals by
413/// identity (two spellings of one value sum; the first label wins).
414fn fold_raw(into: &mut Vec<RawBucket>, from: Vec<RawBucket>) {
415    for (key, label, n) in from {
416        match into.iter_mut().find(|(k, _, _)| *k == key) {
417            Some(e) => e.2 += n,
418            None => into.push((key, label, n)),
419        }
420    }
421}
422
423/// A facet field's position paired with the order-preserving encoding of
424/// its declared type — the identity its buckets are grouped by.
425type FacetKey = (usize, Box<dyn Fn(&[u8]) -> Option<Vec<u8>>>);
426
427/// One facet field's reported buckets: `(value, count)`, most frequent
428/// first.
429pub type FacetCounts = Vec<(Vec<u8>, u64)>;
430
431/// One facet bucket in flight, before the grouping identity is dropped.
432type RawBucket = (Vec<u8>, Vec<u8>, u64);
433
434/// A faceted query's answer: the page, and per requested `FACET` field
435/// its `(value, count)` buckets over the whole match set.
436#[derive(Debug)]
437pub struct MatchPage {
438    /// The ranked page — exactly what [`Store::idx_match_with`] returns.
439    pub hits: Vec<HighlightedHit>,
440    /// One entry per requested facet field, most frequent first.
441    pub facets: Vec<FacetCounts>,
442}
443
444/// Drop the grouping identity and order the buckets for reporting: most
445/// frequent first, the label breaking ties so the order is stable.
446fn finish_buckets(buckets: Vec<Vec<RawBucket>>) -> Vec<FacetCounts> {
447    buckets
448        .into_iter()
449        .map(|mut field| {
450            field.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.1.cmp(&b.1)));
451            field.into_iter().map(|(_, label, n)| (label, n)).collect()
452        })
453        .collect()
454}
455
456/// Fold one live segment's answer into the running union: each hit with its
457/// highlight spans, each facet's raw buckets into the matching accumulator.
458///
459/// Split from `gather_hits` for the 50-line rule.
460fn absorb_live(
461    r: kevy_text::FacetedMatches,
462    ts: &kevy_text::TextSegment,
463    spec: &IndexSpec,
464    query: &[u8],
465    highlight: Option<&[Vec<u8>]>,
466    all: &mut Vec<(Vec<u8>, f64, Vec<FieldSpans>)>,
467    buckets: &mut [Vec<RawBucket>],
468) {
469    for m in r.hits {
470        let hl = highlight.map_or_else(Vec::new, |w| hit_highlight(ts, spec, &m.key, query, w));
471        all.push((m.key, m.score, hl));
472    }
473    for (into, from) in buckets.iter_mut().zip(r.facets) {
474        fold_raw(into, from);
475    }
476}
477
478/// One hit's highlight spans as `(field name, [(start, end)])`, filtered
479/// to the requested fields (`want` empty = every field with a match).
480fn hit_highlight(
481    ts: &kevy_text::TextSegment,
482    spec: &IndexSpec,
483    key: &[u8],
484    query: &[u8],
485    want: &[Vec<u8>],
486) -> Vec<FieldSpans> {
487    ts.highlight_spans(key, query)
488        .into_iter()
489        .filter_map(|(fi, spans)| {
490            let name = spec.fields.get(fi)?.name.clone();
491            if !want.is_empty() && !want.contains(&name) {
492                return None;
493            }
494            let ranges = spans.into_iter().map(|(s, e)| (s as u32, e as u32)).collect();
495            Some((name, ranges))
496        })
497        .collect()
498}