Skip to main content

zeppelin_embed/fts/
snippet.rs

1//! Snippets and match offsets: how a hit explains itself.
2//!
3//! "Search that can't explain itself" is a documented failure class the
4//! research pins to missing per-field match offsets (`research/03:481-484`).
5//! Task 12 carried a byte range on every token precisely so this module
6//! could exist without a second pass over the text.
7//!
8//! # Determinism (task 15 D5)
9//!
10//! The returned window is the best-scoring one under a fixed rule with a
11//! pinned tie-break, so identical inputs give identical snippets across
12//! runs, machines, and platforms. The rule:
13//!
14//! 1. Score each candidate window by the number of distinct matched terms
15//!    it covers, then by total matches.
16//! 2. Break ties by the earliest start offset.
17//!
18//! A snippet that moves between runs makes a caching layer useless and a
19//! screenshot in a bug report meaningless, so the tie-break is part of the
20//! contract rather than an implementation detail.
21//!
22//! # Boundaries
23//!
24//! Every returned range is a character boundary of the original text, so
25//! slicing always yields valid UTF-8. Windows are widened outward to the
26//! nearest boundary rather than truncated inward, because cutting a
27//! multibyte character in half is the classic snippet crash.
28
29use std::num::NonZeroUsize;
30
31use super::query::OwnedLexicalSnippet;
32use super::tokenizer::{Analyzer, TokenOffset};
33
34/// One highlighted range within a field.
35#[derive(Clone, Copy, Debug, Eq, PartialEq)]
36pub struct Highlight {
37    /// Inclusive start byte offset into the field text.
38    pub start: u32,
39    /// Exclusive end byte offset into the field text.
40    pub end: u32,
41}
42
43/// A selected snippet window and the highlights inside it.
44#[derive(Clone, Debug, Eq, PartialEq)]
45pub struct Snippet {
46    /// Byte range of the window itself.
47    pub window: Highlight,
48    /// Matched ranges within the window, ascending, non-overlapping.
49    pub highlights: Vec<Highlight>,
50}
51
52impl Snippet {
53    /// Slices the window text out of the field.
54    #[must_use]
55    pub fn text<'text>(&self, field: &'text str) -> Option<&'text str> {
56        let start = usize::try_from(self.window.start).ok()?;
57        let end = usize::try_from(self.window.end).ok()?;
58        field.get(start..end)
59    }
60}
61
62/// Why a snippet could not be produced.
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub enum SnippetError {
65    /// The field was not configured `stored`, so no offsets exist.
66    ///
67    /// This is typed rather than guessed: returning a re-analyzed
68    /// approximation would silently disagree with the index.
69    FieldNotStored,
70    /// The requested window length was zero.
71    ZeroWindow,
72}
73
74impl std::fmt::Display for SnippetError {
75    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        match self {
77            Self::FieldNotStored => {
78                formatter.write_str("field is not stored, so it has no match offsets")
79            }
80            Self::ZeroWindow => formatter.write_str("snippet window length must be positive"),
81        }
82    }
83}
84
85impl std::error::Error for SnippetError {}
86
87/// Rounds an offset down to the nearest character boundary.
88fn floor_boundary(text: &str, mut offset: usize) -> usize {
89    offset = offset.min(text.len());
90    while offset > 0 && !text.is_char_boundary(offset) {
91        offset -= 1;
92    }
93    offset
94}
95
96/// Rounds an offset up to the nearest character boundary.
97fn ceil_boundary(text: &str, mut offset: usize) -> usize {
98    offset = offset.min(text.len());
99    while offset < text.len() && !text.is_char_boundary(offset) {
100        offset += 1;
101    }
102    offset
103}
104
105/// Finds the byte ranges in `text` where any of `terms` was analyzed.
106///
107/// Runs the same analyzer the index used, so a match here is a match there.
108#[must_use]
109pub fn match_offsets(analyzer: &Analyzer, text: &str, terms: &[Vec<u8>]) -> Vec<Highlight> {
110    let mut ranges: Vec<Highlight> = analyzer
111        .analyze(text)
112        .into_iter()
113        .filter(|token| {
114            terms
115                .iter()
116                .any(|term| term.as_slice() == token.term.as_bytes())
117        })
118        .map(|token| {
119            let TokenOffset { start, end } = token.offset;
120            Highlight { start, end }
121        })
122        .collect();
123    ranges.sort_by_key(|range| (range.start, range.end));
124    // Stacked variants share a byte range; report it once.
125    ranges.dedup();
126    // Overlapping ranges (a compound word and one of its parts) collapse to
127    // the widest, so highlights never nest.
128    let mut merged: Vec<Highlight> = Vec::with_capacity(ranges.len());
129    for range in ranges {
130        match merged.last_mut() {
131            Some(last) if range.start < last.end => {
132                last.end = last.end.max(range.end);
133            }
134            _ => merged.push(range),
135        }
136    }
137    merged
138}
139
140/// Chooses the best window of at most `window_bytes` and its highlights.
141///
142/// # Errors
143///
144/// Returns [`SnippetError::ZeroWindow`] when `window_bytes` is zero, and
145/// [`SnippetError::FieldNotStored`] when `stored` is false.
146pub fn best_window(
147    analyzer: &Analyzer,
148    text: &str,
149    terms: &[Vec<u8>],
150    window_bytes: usize,
151    stored: bool,
152) -> Result<Option<Snippet>, SnippetError> {
153    if !stored {
154        return Err(SnippetError::FieldNotStored);
155    }
156    let Some(window_bytes) = NonZeroUsize::new(window_bytes) else {
157        return Err(SnippetError::ZeroWindow);
158    };
159    Ok(select_window(analyzer, text, terms, window_bytes))
160}
161
162/// Chooses the best window as owned text with absolute source ranges.
163///
164/// The same selection as [`best_window`], for a caller that already holds
165/// stored text and a nonzero window, so neither typed error can arise.
166/// Returns `None` when no term occurs in `text`.
167#[must_use]
168pub fn owned_window(
169    analyzer: &Analyzer,
170    text: &str,
171    terms: &[Vec<u8>],
172    window_bytes: NonZeroUsize,
173) -> Option<OwnedLexicalSnippet> {
174    let snippet = select_window(analyzer, text, terms, window_bytes)?;
175    Some(OwnedLexicalSnippet {
176        text: snippet.text(text)?.to_owned(),
177        source: snippet.window,
178        highlights: snippet.highlights,
179        source_len: text.len(),
180    })
181}
182
183fn select_window(
184    analyzer: &Analyzer,
185    text: &str,
186    terms: &[Vec<u8>],
187    window_bytes: NonZeroUsize,
188) -> Option<Snippet> {
189    let window_bytes = window_bytes.get();
190    let matches = match_offsets(analyzer, text, terms);
191    if matches.is_empty() {
192        return None;
193    }
194
195    // Each match is a candidate anchor; the window starts at it.
196    let mut best: Option<(usize, usize, Highlight)> = None;
197    for anchor in &matches {
198        let start = floor_boundary(text, usize::try_from(anchor.start).unwrap_or(0));
199        let end = ceil_boundary(text, start.saturating_add(window_bytes));
200        let covered: Vec<&Highlight> = matches
201            .iter()
202            .filter(|candidate| {
203                usize::try_from(candidate.start).unwrap_or(usize::MAX) >= start
204                    && usize::try_from(candidate.end).unwrap_or(usize::MAX) <= end
205            })
206            .collect();
207        let distinct = {
208            let mut spans: Vec<(u32, u32)> = covered
209                .iter()
210                .map(|range| (range.start, range.end))
211                .collect();
212            spans.sort_unstable();
213            spans.dedup();
214            spans.len()
215        };
216        let score = (distinct, covered.len());
217        let window = Highlight {
218            start: u32::try_from(start).unwrap_or(u32::MAX),
219            end: u32::try_from(end).unwrap_or(u32::MAX),
220        };
221        let replace = match &best {
222            None => true,
223            // Pinned tie-break: more distinct terms, then more matches,
224            // then the earliest start. Never anything machine-dependent.
225            Some((distinct_best, count_best, window_best)) => {
226                score.0 > *distinct_best
227                    || (score.0 == *distinct_best && score.1 > *count_best)
228                    || (score.0 == *distinct_best
229                        && score.1 == *count_best
230                        && window.start < window_best.start)
231            }
232        };
233        if replace {
234            best = Some((score.0, score.1, window));
235        }
236    }
237
238    let (_, _, window) = best?;
239    let highlights = matches
240        .into_iter()
241        .filter(|range| range.start >= window.start && range.end <= window.end)
242        .collect();
243    Some(Snippet { window, highlights })
244}
245
246#[cfg(test)]
247#[allow(
248    clippy::expect_used,
249    clippy::indexing_slicing,
250    clippy::panic,
251    clippy::unwrap_used
252)]
253mod tests {
254    use super::*;
255    use crate::fts::tokenizer::{Profile, TokenizerConfig};
256
257    fn analyzer() -> Analyzer {
258        Analyzer::new(TokenizerConfig::text_default()).expect("valid config")
259    }
260
261    fn code_analyzer() -> Analyzer {
262        Analyzer::new(Profile::Code.config()).expect("valid config")
263    }
264
265    fn terms(words: &[&str]) -> Vec<Vec<u8>> {
266        words.iter().map(|word| word.as_bytes().to_vec()).collect()
267    }
268
269    #[test]
270    fn highlights_point_at_the_matched_surface_form() {
271        let text = "the quick brown fox jumps";
272        let highlights = match_offsets(&analyzer(), text, &terms(&["brown"]));
273        assert_eq!(highlights.len(), 1);
274        let range = highlights[0];
275        assert_eq!(&text[range.start as usize..range.end as usize], "brown");
276    }
277
278    #[test]
279    fn snippet_ranges_are_in_bounds_utf8_valid_and_contain_the_matched_surface_form() {
280        // Multibyte, combining marks, and emoji all in one field.
281        let text = "Caf\u{00E9} \u{1F680} na\u{00EF}ve cafe\u{0301} resume \u{4E2D}\u{6587}";
282        let snippet = best_window(&analyzer(), text, &terms(&["cafe"]), 24, true)
283            .expect("stored")
284            .expect("a match exists");
285        let window = snippet.text(text).expect("window is a char boundary");
286        assert!(!window.is_empty());
287        for highlight in &snippet.highlights {
288            let slice = &text[highlight.start as usize..highlight.end as usize];
289            assert!(
290                std::str::from_utf8(slice.as_bytes()).is_ok(),
291                "highlight sliced a character in half"
292            );
293        }
294        assert!(
295            snippet.window.end as usize <= text.len(),
296            "window escaped the field"
297        );
298    }
299
300    #[test]
301    fn snippets_are_deterministic_across_runs() {
302        let text = "alpha beta alpha gamma alpha delta alpha";
303        let analyzer = code_analyzer();
304        let first = best_window(&analyzer, text, &terms(&["alpha"]), 20, true).expect("stored");
305        for _ in 0..8 {
306            let again = best_window(&analyzer, text, &terms(&["alpha"]), 20, true).expect("stored");
307            assert_eq!(first, again, "the snippet moved between runs");
308        }
309    }
310
311    #[test]
312    fn the_window_prefers_more_distinct_terms() {
313        // "alpha" alone early, then "alpha beta" together later. The window
314        // covering both terms must win.
315        let text = "alpha ................................ alpha beta";
316        let snippet = best_window(&code_analyzer(), text, &terms(&["alpha", "beta"]), 16, true)
317            .expect("stored")
318            .expect("a match exists");
319        let window = snippet.text(text).expect("valid window");
320        assert!(
321            window.contains("beta"),
322            "the window missed the denser region: {window:?}"
323        );
324    }
325
326    #[test]
327    fn an_unstored_field_is_a_typed_error_not_a_guess() {
328        assert_eq!(
329            best_window(&analyzer(), "text", &terms(&["text"]), 10, false),
330            Err(SnippetError::FieldNotStored)
331        );
332    }
333
334    #[test]
335    fn a_zero_length_window_is_a_typed_error() {
336        assert_eq!(
337            best_window(&analyzer(), "text", &terms(&["text"]), 0, true),
338            Err(SnippetError::ZeroWindow)
339        );
340    }
341
342    #[test]
343    fn a_field_with_no_match_yields_no_snippet_rather_than_the_whole_field() {
344        assert_eq!(
345            best_window(&analyzer(), "alpha beta", &terms(&["omega"]), 20, true).expect("stored"),
346            None
347        );
348    }
349
350    #[test]
351    fn highlights_never_overlap_or_nest() {
352        // A compound word emits the whole span AND its parts; the reported
353        // highlights must collapse to the widest rather than nesting.
354        let text = "call put_if_match now";
355        let highlights = match_offsets(
356            &code_analyzer(),
357            text,
358            &terms(&["put_if_match", "put", "match"]),
359        );
360        for pair in highlights.windows(2) {
361            let (Some(left), Some(right)) = (pair.first(), pair.get(1)) else {
362                continue;
363            };
364            assert!(
365                left.end <= right.start,
366                "highlights {left:?} and {right:?} overlap"
367            );
368        }
369    }
370
371    #[test]
372    fn an_empty_field_produces_no_snippet() {
373        assert_eq!(
374            best_window(&analyzer(), "", &terms(&["alpha"]), 10, true).expect("stored"),
375            None
376        );
377    }
378
379    #[test]
380    fn boundary_helpers_never_split_a_character() {
381        let text = "a\u{00E9}\u{4E2D}\u{1F680}b";
382        for offset in 0..=text.len() + 4 {
383            let floored = floor_boundary(text, offset);
384            let ceiled = ceil_boundary(text, offset);
385            assert!(text.is_char_boundary(floored), "floor at {offset}");
386            assert!(text.is_char_boundary(ceiled), "ceil at {offset}");
387            assert!(floored <= text.len() && ceiled <= text.len());
388        }
389    }
390}