Skip to main content

snomed_ecl_engine/store/
search.rs

1//! Word index over description terms, for finding concepts by name.
2//!
3//! ECL's `{{ term = "..." }}` filter scans the descriptions of everything in
4//! scope, which costs over a second on a large hierarchy and exceeds the work
5//! limit on the whole edition. A browser needs an answer while the user is
6//! still typing, so words are extracted once at build time and searched here
7//! by binary search and list intersection.
8//!
9//! Normalisation happens at build time, so querying needs no collation library
10//! and works in the build without ICU.
11use super::*;
12use std::sync::OnceLock;
13
14/// Postings as plain u32s.
15/// Postings as varint deltas, a third of the size; decoded to u32s on load.
16const MAGIC: &[u8; 8] = b"SNECLSR2";
17
18#[derive(Debug, Serialize, Deserialize, Clone)]
19#[serde(deny_unknown_fields)]
20pub struct SearchManifest {
21    pub bytes: u64,
22    pub sha256: String,
23    pub words: usize,
24    pub postings: usize,
25}
26
27/// Folds one character towards lowercase ASCII, so a search typed without
28/// accents still finds a term that carries them. Anything still not
29/// alphanumeric after folding ends a word.
30fn fold(ch: char) -> char {
31    match ch {
32        '\u{00e0}'..='\u{00e5}' | '\u{00c0}'..='\u{00c5}' | '\u{00e6}' | '\u{00c6}' => 'a',
33        '\u{00e7}' | '\u{00c7}' => 'c',
34        '\u{00e8}'..='\u{00eb}' | '\u{00c8}'..='\u{00cb}' => 'e',
35        '\u{00ec}'..='\u{00ef}' | '\u{00cc}'..='\u{00cf}' => 'i',
36        '\u{00f1}' | '\u{00d1}' => 'n',
37        '\u{00f2}'..='\u{00f6}' | '\u{00d2}'..='\u{00d6}' | '\u{00f8}' | '\u{00d8}' => 'o',
38        '\u{00f9}'..='\u{00fc}' | '\u{00d9}'..='\u{00dc}' => 'u',
39        '\u{00fd}' | '\u{00ff}' | '\u{00dd}' => 'y',
40        '\u{00df}' => 's',
41        _ => ch.to_ascii_lowercase(),
42    }
43}
44
45/// Splits text into normalised words. "Type 2 diabetes mellitus" gives four.
46pub fn words(text: &str, out: &mut Vec<String>) {
47    let mut word = String::new();
48    for ch in text.chars() {
49        let ch = fold(ch);
50        if ch.is_ascii_alphanumeric() {
51            word.push(ch);
52        } else if !word.is_empty() {
53            out.push(std::mem::take(&mut word));
54        }
55    }
56    if !word.is_empty() {
57        out.push(word);
58    }
59}
60
61/// Every (word, concept) pair in an edition's active descriptions.
62///
63/// Inactive descriptions are skipped: they are retired wordings, and matching
64/// them surfaces concepts under names the release no longer publishes.
65pub fn search_pairs(index: &DescriptionIndex, concepts: usize) -> Result<Vec<(String, u32)>> {
66    let mut pairs = Vec::new();
67    let mut buffer = Vec::new();
68    for concept in 0..concepts as u32 {
69        for row in index.for_concept(concept) {
70            if !index.active(row) {
71                continue;
72            }
73            buffer.clear();
74            index.with_term(row, |term| words(term, &mut buffer))?;
75            for word in buffer.drain(..) {
76                pairs.push((word, concept));
77            }
78        }
79    }
80    Ok(pairs)
81}
82
83#[derive(Debug, Default)]
84pub struct SearchIndex {
85    /// Sorted unique words, concatenated.
86    text: Vec<u8>,
87    /// Where each word starts in `text`. One longer than the word count.
88    text_offsets: Vec<u32>,
89    /// Where each word's concept list starts in `postings`.
90    posting_offsets: Vec<u32>,
91    /// Concept ordinals, sorted and deduplicated within each word.
92    postings: Vec<u32>,
93}
94
95impl SearchIndex {
96    pub fn word_count(&self) -> usize {
97        self.text_offsets.len().saturating_sub(1)
98    }
99    pub fn posting_count(&self) -> usize {
100        self.postings.len()
101    }
102    fn word(&self, index: usize) -> &[u8] {
103        &self.text[self.text_offsets[index] as usize..self.text_offsets[index + 1] as usize]
104    }
105    fn concepts(&self, index: usize) -> &[u32] {
106        &self.postings
107            [self.posting_offsets[index] as usize..self.posting_offsets[index + 1] as usize]
108    }
109
110    /// Builds from (word, concept ordinal) pairs. Duplicates are fine.
111    pub fn build(mut pairs: Vec<(String, u32)>) -> Result<Self> {
112        pairs.sort_unstable();
113        pairs.dedup();
114        let mut index = Self {
115            text_offsets: vec![0],
116            posting_offsets: vec![0],
117            ..Self::default()
118        };
119        let mut current: Option<String> = None;
120        for (word, ordinal) in pairs {
121            if current.as_deref() != Some(word.as_str()) {
122                index.text.extend_from_slice(word.as_bytes());
123                index
124                    .text_offsets
125                    .push(u32::try_from(index.text.len()).context("Search text exceeds u32")?);
126                index.posting_offsets.push(
127                    u32::try_from(index.postings.len()).context("Search postings exceed u32")?,
128                );
129                current = Some(word);
130            }
131            index.postings.push(ordinal);
132            *index.posting_offsets.last_mut().expect("seeded") =
133                u32::try_from(index.postings.len()).context("Search postings exceed u32")?;
134        }
135        index.validate()?;
136        Ok(index)
137    }
138
139    fn validate(&self) -> Result<()> {
140        let n = self.word_count();
141        ensure!(
142            self.posting_offsets.len() == n + 1
143                && self.text_offsets.first() == Some(&0)
144                && self.posting_offsets.first() == Some(&0),
145            "Invalid search index offsets"
146        );
147        ensure!(
148            self.text_offsets.last().copied() == u32::try_from(self.text.len()).ok()
149                && self.posting_offsets.last().copied() == u32::try_from(self.postings.len()).ok(),
150            "Search index offsets do not cover their arrays"
151        );
152        ensure!(
153            self.text_offsets.windows(2).all(|w| w[0] <= w[1])
154                && self.posting_offsets.windows(2).all(|w| w[0] <= w[1]),
155            "Non-monotonic search index offsets"
156        );
157        Ok(())
158    }
159
160    /// Checks that words are sorted, unique and valid text, and that every
161    /// posting list is sorted. Only `verify` pays for this.
162    pub fn validate_order(&self) -> Result<()> {
163        for i in 0..self.word_count() {
164            ensure!(
165                std::str::from_utf8(self.word(i)).is_ok(),
166                "Search word is not UTF-8"
167            );
168            ensure!(!self.word(i).is_empty(), "Empty search word");
169            if i > 0 {
170                ensure!(self.word(i - 1) < self.word(i), "Unsorted search words");
171            }
172            ensure!(
173                self.concepts(i).windows(2).all(|w| w[0] < w[1]),
174                "Unsorted search postings"
175            );
176        }
177        Ok(())
178    }
179
180    /// Concepts matching every word in `query`. The last word matches as a
181    /// prefix, so results narrow while the user is still typing it.
182    pub fn matches(&self, query: &str) -> Vec<u32> {
183        let mut terms = Vec::new();
184        words(query, &mut terms);
185        let Some((last, rest)) = terms.split_last() else {
186            return Vec::new();
187        };
188        let mut result: Option<Vec<u32>> = None;
189        for word in rest {
190            let exact = self.exact(word.as_bytes());
191            result = Some(match result {
192                None => exact,
193                Some(current) => intersect(&current, &exact),
194            });
195            if result.as_ref().is_some_and(|r| r.is_empty()) {
196                return Vec::new();
197            }
198        }
199        let prefixed = self.prefixed(last.as_bytes());
200        match result {
201            None => prefixed,
202            Some(current) => intersect(&current, &prefixed),
203        }
204    }
205
206    fn exact(&self, word: &[u8]) -> Vec<u32> {
207        let n = self.word_count();
208        let at = partition(n, |i| self.word(i) < word);
209        if at < n && self.word(at) == word {
210            self.concepts(at).to_vec()
211        } else {
212            Vec::new()
213        }
214    }
215
216    /// Every concept under any word starting with `prefix`, sorted and unique.
217    fn prefixed(&self, prefix: &[u8]) -> Vec<u32> {
218        let n = self.word_count();
219        let start = partition(n, |i| self.word(i) < prefix);
220        let mut out = Vec::new();
221        for i in start..n {
222            if !self.word(i).starts_with(prefix) {
223                break;
224            }
225            out.extend_from_slice(self.concepts(i));
226        }
227        out.sort_unstable();
228        out.dedup();
229        out
230    }
231
232    pub fn write(&self, path: &Path) -> Result<SearchManifest> {
233        let mut out = BufWriter::new(File::create_new(path)?);
234        out.write_all(MAGIC)?;
235        put_u32s(&mut out, &self.text_offsets)?;
236        put_u64(&mut out, self.text.len() as u64)?;
237        out.write_all(&self.text)?;
238        put_u32s(&mut out, &self.posting_offsets)?;
239        let postings = super::varint::encode(&self.posting_offsets, &self.postings)?;
240        put_u64(&mut out, postings.len() as u64)?;
241        out.write_all(&postings)?;
242        out.flush()?;
243        out.get_ref().sync_all()?;
244        Ok(SearchManifest {
245            bytes: path.metadata()?.len(),
246            sha256: sha256(path)?,
247            words: self.word_count(),
248            postings: self.posting_count(),
249        })
250    }
251
252    /// Opens the section, refusing postings outside `concepts` ordinals.
253    pub(super) fn open(
254        section: &Section,
255        manifest: &SearchManifest,
256        concepts: usize,
257    ) -> Result<Self> {
258        let mut input = Input::open(section, MAGIC)?;
259        let text_offsets = input.u32s()?;
260        let text = input.bytes()?;
261        let posting_offsets = input.u32s()?;
262        let postings = super::varint::decode(&posting_offsets, &input.bytes()?)?;
263        ensure!(input.remaining == 0, "Trailing search bytes");
264        let index = Self {
265            text,
266            text_offsets,
267            posting_offsets,
268            postings,
269        };
270        index.validate()?;
271        ensure!(
272            index.word_count() == manifest.words && index.posting_count() == manifest.postings,
273            "Search index differs from manifest"
274        );
275        // Order is only checked by `verify`, so every posting is bounded here.
276        ensure!(
277            index.postings.iter().all(|&p| (p as usize) < concepts),
278            "Search posting outside the concept table"
279        );
280        Ok(index)
281    }
282}
283
284/// The first index in `0..n` where `predicate` stops holding. `predicate` must
285/// hold for a prefix of the range and not after it.
286fn partition(n: usize, predicate: impl Fn(usize) -> bool) -> usize {
287    let (mut low, mut high) = (0, n);
288    while low < high {
289        let mid = low + (high - low) / 2;
290        if predicate(mid) {
291            low = mid + 1;
292        } else {
293            high = mid;
294        }
295    }
296    low
297}
298
299/// Both inputs are sorted and unique, so this walks them once.
300fn intersect(left: &[u32], right: &[u32]) -> Vec<u32> {
301    let mut out = Vec::new();
302    let (mut i, mut j) = (0, 0);
303    while i < left.len() && j < right.len() {
304        match left[i].cmp(&right[j]) {
305            std::cmp::Ordering::Less => i += 1,
306            std::cmp::Ordering::Greater => j += 1,
307            std::cmp::Ordering::Equal => {
308                out.push(left[i]);
309                i += 1;
310                j += 1;
311            }
312        }
313    }
314    out
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    fn split(text: &str) -> Vec<String> {
322        let mut out = Vec::new();
323        words(text, &mut out);
324        out
325    }
326
327    #[test]
328    fn splits_and_folds_terms_into_searchable_words() {
329        assert_eq!(
330            split("Type 2 diabetes mellitus"),
331            ["type", "2", "diabetes", "mellitus"]
332        );
333        // Punctuation separates; it never becomes part of a word.
334        assert_eq!(
335            split("COPD - chronic/obstructive"),
336            ["copd", "chronic", "obstructive"]
337        );
338        // Accents fold, so a query typed without them still matches.
339        assert_eq!(
340            split("\u{00c5}str\u{00f6}m's na\u{00ef}ve"),
341            ["astrom", "s", "naive"]
342        );
343        assert!(split("   -- ").is_empty());
344    }
345
346    fn index() -> SearchIndex {
347        let terms = [
348            (0, "Asthma"),
349            (1, "Asthma clinic"),
350            (2, "Chronic asthmatic bronchitis"),
351            (3, "Diabetes mellitus"),
352            (4, "Type 2 diabetes mellitus"),
353        ];
354        let mut pairs = Vec::new();
355        for (ordinal, term) in terms {
356            for word in split(term) {
357                pairs.push((word, ordinal));
358            }
359        }
360        SearchIndex::build(pairs).unwrap()
361    }
362
363    #[test]
364    fn matches_every_word_with_the_last_one_as_a_prefix() {
365        let index = index();
366        index.validate_order().unwrap();
367        // A whole word matches only what carries it.
368        assert_eq!(index.matches("clinic"), [1]);
369        // The last word is a prefix, so "asthma" also reaches "asthmatic".
370        assert_eq!(index.matches("asthma"), [0, 1, 2]);
371        assert_eq!(index.matches("asthmatic"), [2]);
372        // Several words must all appear, in any order.
373        assert_eq!(index.matches("mellitus diabetes"), [3, 4]);
374        assert_eq!(index.matches("2 diabetes"), [4]);
375        // A word that is not a prefix of anything matches nothing.
376        assert!(index.matches("asthmatics").is_empty());
377        assert!(index.matches("clinic diabetes").is_empty());
378        assert!(index.matches("").is_empty());
379    }
380
381    #[test]
382    fn survives_a_write_and_read_round_trip() {
383        let directory = tempfile::TempDir::new().unwrap();
384        let path = directory.path().join("search.bin");
385        let built = index();
386        let manifest = built.write(&path).unwrap();
387        assert_eq!(manifest.words, built.word_count());
388
389        let source = Section::for_test(&path, manifest.bytes, manifest.sha256.clone());
390        let reopened = SearchIndex::open(&source, &manifest, 100).unwrap();
391        assert_eq!(reopened.word_count(), built.word_count());
392        assert_eq!(reopened.matches("asthma"), built.matches("asthma"));
393        reopened.validate_order().unwrap();
394
395        // A posting past the last concept is refused.
396        assert!(SearchIndex::open(&source, &manifest, 4).is_err());
397        // So is a manifest that disagrees with the bytes.
398        let wrong = SearchManifest {
399            words: manifest.words + 1,
400            ..manifest
401        };
402        assert!(SearchIndex::open(&source, &wrong, 100).is_err());
403    }
404}
405
406/// Opens the section on first use, like descriptions and member tables.
407#[derive(Debug, Default)]
408pub struct SearchStore {
409    source: Option<(Section, SearchManifest, usize)>,
410    loaded: OnceLock<std::result::Result<SearchIndex, String>>,
411}
412
413impl SearchStore {
414    pub(super) fn lazy(
415        source: &IndexSource,
416        metadata: SearchManifest,
417        concepts: usize,
418    ) -> Result<Self> {
419        Ok(Self {
420            source: Some((source.section("search.bin")?, metadata, concepts)),
421            loaded: OnceLock::new(),
422        })
423    }
424    pub fn get(&self) -> Result<Option<&SearchIndex>> {
425        if self.source.is_none() {
426            return Ok(None);
427        }
428        match self.loaded.get_or_init(|| {
429            let (section, manifest, concepts) = self.source.as_ref().unwrap();
430            SearchIndex::open(section, manifest, *concepts).map_err(|e| e.to_string())
431        }) {
432            Ok(index) => Ok(Some(index)),
433            Err(message) => bail!("Search index: {message}"),
434        }
435    }
436}