1use super::*;
12use std::sync::OnceLock;
13
14const 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
27fn 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
45pub 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
61pub 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 text: Vec<u8>,
87 text_offsets: Vec<u32>,
89 posting_offsets: Vec<u32>,
91 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 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 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 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(¤t, &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(¤t, &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 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 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 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
284fn 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
299fn 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 assert_eq!(
335 split("COPD - chronic/obstructive"),
336 ["copd", "chronic", "obstructive"]
337 );
338 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 assert_eq!(index.matches("clinic"), [1]);
369 assert_eq!(index.matches("asthma"), [0, 1, 2]);
371 assert_eq!(index.matches("asthmatic"), [2]);
372 assert_eq!(index.matches("mellitus diabetes"), [3, 4]);
374 assert_eq!(index.matches("2 diabetes"), [4]);
375 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 assert!(SearchIndex::open(&source, &manifest, 4).is_err());
397 let wrong = SearchManifest {
399 words: manifest.words + 1,
400 ..manifest
401 };
402 assert!(SearchIndex::open(&source, &wrong, 100).is_err());
403 }
404}
405
406#[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}