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