1use std::collections::HashMap;
28use std::fs;
29use std::path::Path;
30
31const MAGIC: &[u8; 4] = b"TIX1";
32const HEADER_SIZE: usize = 20; pub struct TextIndex {
36 data: memmap2::Mmap,
37 word_count: usize,
38 total_postings: usize,
39 block_count: usize,
40 postings_start: usize,
41}
42
43struct DictEntry<'a> {
44 word: &'a str,
45 posting_start: usize,
46 posting_len: usize,
47}
48
49pub fn tokenize(text: &str) -> Vec<String> {
53 let mut words = Vec::new();
54 let mut cur = String::new();
55 for ch in text.chars() {
56 if ch.is_alphanumeric() {
57 cur.extend(ch.to_lowercase());
58 } else {
59 if cur.chars().count() >= 2 {
60 words.push(std::mem::take(&mut cur));
61 } else {
62 cur.clear();
63 }
64 }
65 }
66 if cur.chars().count() >= 2 {
67 words.push(cur);
68 }
69 words
70}
71
72impl TextIndex {
73 pub fn open(path: &Path) -> Option<Self> {
76 if !path.exists() {
77 return None;
78 }
79 let file = fs::File::open(path).ok()?;
80 let data = unsafe { memmap2::Mmap::map(&file).ok()? };
81 if data.len() < HEADER_SIZE || &data[0..4] != MAGIC {
82 return None;
83 }
84 let word_count = u32_at(&data, 4)? as usize;
85 let total_postings = u32_at(&data, 8)? as usize;
86 let block_count = u32_at(&data, 12)? as usize;
87 let postings_start = u32_at(&data, 16)? as usize;
88 let offsets_end = HEADER_SIZE.checked_add(word_count.checked_mul(4)?)?;
89 let postings_bytes = total_postings.checked_mul(4)?;
90 if postings_start < offsets_end
91 || data.len() < postings_start.checked_add(postings_bytes)?
92 || !postings_start.is_multiple_of(4)
93 {
94 return None;
95 }
96 Some(TextIndex {
97 data,
98 word_count,
99 total_postings,
100 block_count,
101 postings_start,
102 })
103 }
104
105 pub fn block_count(&self) -> usize {
107 self.block_count
108 }
109
110 pub fn word_count_public(&self) -> usize {
112 self.word_count
113 }
114
115 fn u32_at(&self, off: usize) -> Option<u32> {
116 self.data
117 .get(off..off + 4)
118 .map(|s| u32::from_le_bytes(s.try_into().unwrap()))
119 }
120
121 fn u16_at(&self, off: usize) -> Option<u16> {
122 self.data
123 .get(off..off + 2)
124 .map(|s| u16::from_le_bytes(s.try_into().unwrap()))
125 }
126
127 fn entry(&self, i: usize) -> Option<DictEntry<'_>> {
129 if i >= self.word_count {
130 return None;
131 }
132 let entry_off = self.u32_at(HEADER_SIZE + i * 4)? as usize;
133 let wlen = self.u16_at(entry_off)? as usize;
134 let start = entry_off.checked_add(2)?;
135 let end = start.checked_add(wlen)?;
136 let word = std::str::from_utf8(self.data.get(start..end)?).ok()?;
137 let ps = self.u32_at(end)? as usize;
138 let pl = self.u32_at(end + 4)? as usize;
139 if ps.checked_add(pl)? > self.total_postings {
140 return None;
141 }
142 Some(DictEntry {
143 word,
144 posting_start: ps,
145 posting_len: pl,
146 })
147 }
148
149 fn postings(&self, e: &DictEntry<'_>) -> &[u32] {
151 let start = self.postings_start + e.posting_start * 4;
152 let ptr = self.data[start..].as_ptr() as *const u32;
153 unsafe { std::slice::from_raw_parts(ptr, e.posting_len) }
156 }
157
158 fn find_word(&self, target: &str) -> Option<usize> {
160 let mut lo = 0usize;
161 let mut hi = self.word_count;
162 while lo < hi {
163 let mid = (lo + hi) / 2;
164 match self.entry(mid)?.word.cmp(target) {
165 std::cmp::Ordering::Less => lo = mid + 1,
166 std::cmp::Ordering::Greater => hi = mid,
167 std::cmp::Ordering::Equal => return Some(mid),
168 }
169 }
170 None
171 }
172
173 fn range_words_starting_with(&self, prefix: &str) -> (usize, usize) {
176 let mut lo = 0usize;
177 let mut hi = self.word_count;
178 while lo < hi {
179 let mid = (lo + hi) / 2;
180 if let Some(e) = self.entry(mid) {
181 if e.word < prefix {
182 lo = mid + 1;
183 } else {
184 hi = mid;
185 }
186 } else {
187 break;
188 }
189 }
190 let start = lo;
191 hi = self.word_count;
192 while lo < hi {
193 let mid = (lo + hi) / 2;
194 if let Some(e) = self.entry(mid) {
195 if e.word.starts_with(prefix) {
196 lo = mid + 1;
197 } else {
198 hi = mid;
199 }
200 } else {
201 break;
202 }
203 }
204 (start, lo)
205 }
206
207 fn blocks_containing_token(&self, token: &str) -> Vec<u32> {
215 let mut out: Vec<u32> = Vec::new();
216 for i in 0..self.word_count {
217 let Some(e) = self.entry(i) else { break };
218 if e.word.contains(token) {
219 out.extend_from_slice(self.postings(&e));
220 }
221 }
222 out.sort_unstable();
223 out.dedup();
224 out
225 }
226
227 pub fn candidates_substring(&self, query_lower: &str) -> Option<Vec<u32>> {
234 let tokens = tokenize(query_lower);
235 if tokens.is_empty() {
236 return None;
237 }
238 let mut sets: Vec<Vec<u32>> = Vec::with_capacity(tokens.len());
239 for t in &tokens {
240 let set = self.blocks_containing_token(t);
241 if set.is_empty() {
242 return Some(Vec::new());
243 }
244 sets.push(set);
245 }
246 sets.sort_by_key(|v| v.len());
248 let mut result = std::mem::take(&mut sets[0]);
249 for other in &sets[1..] {
250 if result.is_empty() {
251 break;
252 }
253 result.retain(|&id| other.binary_search(&id).is_ok());
254 }
255 Some(result)
256 }
257
258 pub fn candidates_lexical(&self, tokens: &[String]) -> Option<Vec<u32>> {
263 if tokens.is_empty() {
264 return None;
265 }
266 let mut union: Vec<u32> = Vec::new();
267 for t in tokens {
268 let chars: Vec<char> = t.chars().collect();
269 if chars.len() < 3 {
270 continue;
271 }
272 if chars.len() < 5 {
273 if let Some(i) = self.find_word(t) {
275 if let Some(e) = self.entry(i) {
276 union.extend_from_slice(self.postings(&e));
277 }
278 }
279 continue;
280 }
281 let prefix: String = chars[..5].iter().collect();
285 let (lo, hi) = self.range_words_starting_with(&prefix);
286 for i in lo..hi {
287 if let Some(e) = self.entry(i) {
288 union.extend_from_slice(self.postings(&e));
289 }
290 }
291 }
292 union.sort_unstable();
293 union.dedup();
294 Some(union)
295 }
296
297 pub fn candidates_lexical_min_matches(
318 &self,
319 tokens: &[String],
320 min_matches: u32,
321 ) -> Option<Vec<u32>> {
322 if tokens.is_empty() {
323 return None;
324 }
325 if min_matches <= 1 {
326 return self.candidates_lexical(tokens);
327 }
328
329 let mut counts: HashMap<u32, u32> = HashMap::new();
334 for t in tokens {
335 let chars: Vec<char> = t.chars().collect();
336 if chars.len() < 3 {
337 continue;
338 }
339 let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();
340 if chars.len() < 5 {
341 if let Some(i) = self.find_word(t) {
342 if let Some(e) = self.entry(i) {
343 seen.extend(self.postings(&e).iter().copied());
344 }
345 }
346 } else {
347 let prefix: String = chars[..5].iter().collect();
348 let (lo, hi) = self.range_words_starting_with(&prefix);
349 for i in lo..hi {
350 if let Some(e) = self.entry(i) {
351 seen.extend(self.postings(&e).iter().copied());
352 }
353 }
354 }
355 for block in seen {
356 *counts.entry(block).or_insert(0) += 1;
357 }
358 }
359
360 let mut kept: Vec<u32> = counts
361 .into_iter()
362 .filter(|(_, c)| *c >= min_matches)
363 .map(|(b, _)| b)
364 .collect();
365 kept.sort_unstable();
366 Some(kept)
367 }
368}
369
370fn u32_at(data: &[u8], off: usize) -> Option<u32> {
371 data.get(off..off + 4)
372 .map(|s| u32::from_le_bytes(s.try_into().unwrap()))
373}
374
375pub fn build_text_index<'a, I>(
378 texts: I,
379 block_count: usize,
380 output_path: &Path,
381) -> Result<(), String>
382where
383 I: IntoIterator<Item = &'a str>,
384{
385 let mut map: HashMap<String, Vec<u32>> = HashMap::new();
388 let mut total_postings = 0usize;
389 for (i, text) in texts.into_iter().enumerate() {
390 let mut words = tokenize(text);
391 words.sort_unstable();
392 words.dedup();
393 for w in words {
394 total_postings += 1;
395 map.entry(w).or_default().push(i as u32);
396 }
397 }
398
399 let mut words: Vec<(&String, &Vec<u32>)> = map.iter().collect();
400 words.sort_by(|a, b| a.0.cmp(b.0));
401
402 let mut buf = Vec::with_capacity(HEADER_SIZE + words.len() * 4 + total_postings * 4);
403 buf.extend_from_slice(MAGIC);
404 buf.extend_from_slice(&(words.len() as u32).to_le_bytes());
405 buf.extend_from_slice(&(total_postings as u32).to_le_bytes());
406 buf.extend_from_slice(&(block_count as u32).to_le_bytes());
407 buf.extend_from_slice(&0u32.to_le_bytes());
411
412 let offsets_pos = buf.len();
413 buf.resize(buf.len() + words.len() * 4, 0);
414
415 let mut posting_cursor = 0usize;
416 let mut postings_buf: Vec<u8> = Vec::with_capacity(total_postings * 4);
417 for (wi, (w, list)) in words.iter().enumerate() {
418 let entry_off = buf.len();
419 let off = offsets_pos + wi * 4;
420 buf[off..off + 4].copy_from_slice(&(entry_off as u32).to_le_bytes());
421 let wb = w.as_bytes();
422 buf.extend_from_slice(&(wb.len() as u16).to_le_bytes());
423 buf.extend_from_slice(wb);
424 buf.extend_from_slice(&(posting_cursor as u32).to_le_bytes());
425 buf.extend_from_slice(&(list.len() as u32).to_le_bytes());
426 for &id in list.iter() {
427 postings_buf.extend_from_slice(&id.to_le_bytes());
428 }
429 posting_cursor += list.len();
430 }
431 while buf.len() % 4 != 0 {
433 buf.push(0);
434 }
435 let postings_offset = buf.len() as u32;
436 buf[16..20].copy_from_slice(&postings_offset.to_le_bytes());
437 buf.extend_from_slice(&postings_buf);
438
439 let tmp_path = output_path.with_extension("bin.tmp");
440 fs::write(&tmp_path, &buf).map_err(|e| format!("write text_index.bin: {e}"))?;
441 fs::rename(&tmp_path, output_path).map_err(|e| format!("rename text_index.bin: {e}"))?;
442 Ok(())
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448
449 fn build_from(texts: &[&str]) -> (tempfile::TempDir, TextIndex) {
450 let dir = tempfile::tempdir().unwrap();
451 let path = dir.path().join("text_index.bin");
452 build_text_index(texts.iter().copied(), texts.len(), &path).unwrap();
453 let idx = TextIndex::open(&path).unwrap();
454 (dir, idx)
455 }
456
457 #[test]
458 fn min_matches_of_one_is_exactly_the_unbounded_union() {
459 let texts = [
463 "the user has a cat named bella",
464 "cats and dogs are both common pets",
465 "gastric bypass treats type two diabetes",
466 "diabetes mellitus is a chronic disease",
467 "bypass surgery has risks",
468 "unrelated text about gardening tools",
469 ];
470 let (_dir, idx) = build_from(&texts);
471 let queries: Vec<Vec<String>> = vec![
472 vec!["cat".into()],
473 vec!["diabetes".into()],
474 vec!["bypass".into()],
475 vec!["diabetes".into(), "bypass".into()],
476 vec!["the".into(), "cat".into(), "named".into()],
477 vec!["missingword".into()],
478 vec![],
479 ];
480 for tokens in &queries {
481 let old = idx.candidates_lexical(tokens);
482 let bounded = idx.candidates_lexical_min_matches(tokens, 1);
483 assert_eq!(old, bounded, "tokens {tokens:?} must be identical at 1");
484 assert_eq!(idx.candidates_lexical_min_matches(tokens, 0), old);
486 }
487 }
488
489 #[test]
490 fn min_matches_narrows_the_set_without_ever_widening_it() {
491 let texts = [
495 "gastric bypass treats type two diabetes mellitus",
496 "diabetes is mentioned here but surgery is not",
497 "gastric sleeve is a different operation entirely",
498 "gardening tools have nothing to do with the above",
499 ];
500 let (_dir, idx) = build_from(&texts);
501 let tokens = vec!["diabetes".to_string(), "bypass".to_string()];
502
503 let all = idx.candidates_lexical_min_matches(&tokens, 1).unwrap();
504 assert_eq!(all, vec![0, 1], "the union is both matching blocks");
505 let both = idx.candidates_lexical_min_matches(&tokens, 2).unwrap();
506 assert_eq!(both, vec![0], "only block 0 contains both terms");
507 for b in &both {
508 assert!(all.contains(b), "{b} must be a subset of the union");
509 }
510 let impossible = idx.candidates_lexical_min_matches(&tokens, 5).unwrap();
513 assert!(impossible.is_empty());
514 }
515
516 #[test]
517 fn tokenize_basic() {
518 assert_eq!(tokenize("Rust is fast!"), vec!["rust", "is", "fast"]);
519 assert_eq!(tokenize("a b"), Vec::<String>::new());
520 assert_eq!(tokenize("rust-mmap"), vec!["rust", "mmap"]);
521 assert_eq!(tokenize(""), Vec::<String>::new());
522 assert_eq!(tokenize("ÁRvíZ"), vec!["árvíz"]);
524 }
525
526 #[test]
527 fn roundtrip_and_substring_candidates() {
528 let (_dir, idx) = build_from(&[
529 "Rust is fast",
530 "Memory is managed by the runtime",
531 "rust zooms across depths",
532 ]);
533 assert_eq!(idx.block_count(), 3);
534
535 let c = idx.candidates_substring("rust").unwrap();
537 assert_eq!(c, vec![0, 2]);
538
539 let c = idx.candidates_substring("rust is").unwrap();
541 assert_eq!(c, vec![0]);
542
543 let c = idx.candidates_substring("rus").unwrap();
545 assert_eq!(c, vec![0, 2]);
546
547 assert_eq!(
549 idx.candidates_substring("xyzzy").unwrap(),
550 Vec::<u32>::new()
551 );
552
553 assert!(idx.candidates_substring("a").is_none());
555 }
556
557 #[test]
558 fn lexical_candidates_match_token_similarity() {
559 use crate::relevance::RelevanceQuery;
560 let texts = [
561 "rusting is a memory effect",
562 "rusti prefix of rusting",
563 "rust never sleeps",
564 "quick brown fox",
565 ];
566 let (_dir, idx) = build_from(&texts);
567
568 let q = RelevanceQuery::new("rusting");
572 let cands = idx.candidates_lexical(q.tokens()).unwrap();
573 assert_eq!(cands, vec![0, 1]);
574
575 let q = RelevanceQuery::new("rust");
577 let cands = idx.candidates_lexical(q.tokens()).unwrap();
578 assert_eq!(cands, vec![2]);
579 }
580
581 #[test]
582 fn missing_or_empty_lexical() {
583 let (_dir, idx) = build_from(&["one two three"]);
584 let cands = idx.candidates_lexical(&["zzz".to_string()]).unwrap();
585 assert!(cands.is_empty());
586 assert!(idx.candidates_lexical(&[]).is_none());
587 }
588
589 #[test]
590 fn truncated_or_corrupt_file_fails_open() {
591 let dir = tempfile::tempdir().unwrap();
592 let path = dir.path().join("text_index.bin");
593 build_text_index(["hello world"].iter().copied(), 1, &path).unwrap();
594 let bytes = fs::read(&path).unwrap();
595 fs::write(&path, &bytes[..bytes.len() - 3]).unwrap();
597 assert!(TextIndex::open(&path).is_none());
598 fs::write(&path, b"NOPE").unwrap();
600 assert!(TextIndex::open(&path).is_none());
601 }
602
603 #[test]
604 fn scale_search_is_selective() {
605 let mut texts: Vec<String> = Vec::with_capacity(50_000);
608 for i in 0..50_000u32 {
609 texts.push(format!("unique_term_{i} common filler text"));
610 }
611 let owned: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
612 let dir = tempfile::tempdir().unwrap();
613 let path = dir.path().join("text_index.bin");
614 build_text_index(owned.iter().copied(), owned.len(), &path).unwrap();
615 let idx = TextIndex::open(&path).unwrap();
616
617 let c = idx.candidates_substring("unique_term_49999").unwrap();
620 assert_eq!(c.len(), 1);
621 assert_eq!(c[0], 49999);
622 let c = idx.candidates_substring("unique_term_4242").unwrap();
625 assert!(c.contains(&4242), "matching block must be a candidate");
626 assert!(
627 c.len() <= 15,
628 "candidates must be selective, got {}",
629 c.len()
630 );
631 let c = idx.candidates_substring("common").unwrap();
633 assert_eq!(c.len(), 50_000);
634 }
635}