use std::collections::BTreeMap;
use fst::automaton::Str;
use fst::{Automaton, IntoStreamer, Map, MapBuilder, Streamer};
use roaring::RoaringBitmap;
use concept_graph::ordinal::Ordinal;
use crate::tokenize::{prefixes, tokens};
#[derive(Debug, thiserror::Error)]
pub enum BuildError {
#[error("cannot build the word dictionary")]
Fst(#[from] fst::Error),
#[error("too many designations for a u32 ordinal")]
TooMany(#[source] std::num::TryFromIntError),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Entry {
pub concept: Ordinal,
pub index: u32,
pub term_length: u16,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Input<'a> {
pub concept: Ordinal,
pub index: u32,
pub term: &'a str,
pub language: &'a str,
pub use_ordinal: u32,
pub active: bool,
pub refsets: &'a [u32],
}
#[derive(Debug, Default)]
pub struct IndexBuilder {
words: BTreeMap<String, RoaringBitmap>,
entries: Vec<Entry>,
languages: BTreeMap<String, RoaringBitmap>,
uses: BTreeMap<u32, RoaringBitmap>,
refsets: BTreeMap<u32, RoaringBitmap>,
active: RoaringBitmap,
lengths: BTreeMap<u16, RoaringBitmap>,
}
impl IndexBuilder {
pub fn add(&mut self, input: &Input<'_>) -> Result<u32, BuildError> {
let ordinal = u32::try_from(self.entries.len()).map_err(BuildError::TooMany)?;
let term_length = u16::try_from(input.term.chars().count()).unwrap_or(u16::MAX);
self.entries.push(Entry {
concept: input.concept,
index: input.index,
term_length,
});
self.lengths.entry(term_length).or_default().insert(ordinal);
for word in tokens(input.term) {
self.words.entry(word).or_default().insert(ordinal);
}
self.languages
.entry(input.language.to_owned())
.or_default()
.insert(ordinal);
self.uses
.entry(input.use_ordinal)
.or_default()
.insert(ordinal);
for refset in input.refsets {
self.refsets.entry(*refset).or_default().insert(ordinal);
}
if input.active {
self.active.insert(ordinal);
}
Ok(ordinal)
}
pub fn build(self) -> Result<TextIndex, BuildError> {
let mut dictionary = MapBuilder::memory();
let mut postings = Vec::with_capacity(self.words.len());
for (position, (word, bitmap)) in self.words.into_iter().enumerate() {
dictionary.insert(word.as_bytes(), u64::try_from(position).unwrap_or(u64::MAX))?;
postings.push(bitmap);
}
let bytes = dictionary.into_inner()?;
Ok(TextIndex {
dictionary: Map::new(bytes)?,
postings,
entries: self.entries,
languages: self.languages,
uses: self.uses,
refsets: self.refsets,
active: self.active,
lengths: self.lengths,
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Query {
pub text: String,
pub language: Option<String>,
pub refset: Option<u32>,
pub uses: Option<Vec<u32>>,
pub active_only: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hits {
pub total: u64,
pub designations: Vec<u32>,
}
#[derive(Debug)]
pub struct TextIndex {
dictionary: Map<Vec<u8>>,
postings: Vec<RoaringBitmap>,
entries: Vec<Entry>,
languages: BTreeMap<String, RoaringBitmap>,
uses: BTreeMap<u32, RoaringBitmap>,
refsets: BTreeMap<u32, RoaringBitmap>,
active: RoaringBitmap,
lengths: BTreeMap<u16, RoaringBitmap>,
}
impl TextIndex {
pub fn from_parts(parts: OwnedParts) -> Result<Self, BuildError> {
Ok(Self {
dictionary: Map::new(parts.dictionary)?,
postings: parts.postings,
entries: parts.entries,
languages: parts.languages,
uses: parts.uses,
refsets: parts.refsets,
active: parts.active,
lengths: parts.lengths,
})
}
#[must_use]
pub fn dictionary_size_in_bytes(&self) -> usize {
self.dictionary.as_fst().as_inner().capacity()
}
#[must_use]
pub fn postings_size_in_bytes(&self) -> usize {
concept_graph::footprint::bitmaps(&self.postings)
}
#[must_use]
pub fn entries_size_in_bytes(&self) -> usize {
concept_graph::footprint::vector(&self.entries)
}
#[must_use]
pub fn filters_size_in_bytes(&self) -> usize {
let sets = |total: usize, set: &RoaringBitmap| {
total.saturating_add(concept_graph::footprint::bitmap(set))
};
let tags = self
.languages
.keys()
.fold(0_usize, |total, tag| total.saturating_add(tag.capacity()));
[
self.languages.values().fold(
concept_graph::footprint::entries::<String, RoaringBitmap>(self.languages.len()),
sets,
),
self.uses.values().fold(
concept_graph::footprint::entries::<u32, RoaringBitmap>(self.uses.len()),
sets,
),
self.refsets.values().fold(
concept_graph::footprint::entries::<u32, RoaringBitmap>(self.refsets.len()),
sets,
),
self.lengths.values().fold(
concept_graph::footprint::entries::<u16, RoaringBitmap>(self.lengths.len()),
sets,
),
concept_graph::footprint::bitmap(&self.active),
tags,
]
.into_iter()
.fold(0_usize, usize::saturating_add)
}
#[must_use]
pub fn size_in_bytes(&self) -> usize {
self.dictionary_size_in_bytes()
.saturating_add(self.postings_size_in_bytes())
.saturating_add(self.entries_size_in_bytes())
.saturating_add(self.filters_size_in_bytes())
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[must_use]
pub fn words(&self) -> usize {
self.postings.len()
}
#[must_use]
pub fn entry(&self, designation: u32) -> Option<Entry> {
self.entries
.get(usize::try_from(designation).unwrap_or(usize::MAX))
.copied()
}
#[must_use]
pub fn prefix_postings(&self, prefix: &str) -> RoaringBitmap {
let mut out = RoaringBitmap::new();
let mut stream = self
.dictionary
.search(Str::new(prefix).starts_with())
.into_stream();
while let Some((_, position)) = stream.next() {
if let Some(bitmap) = usize::try_from(position)
.ok()
.and_then(|p| self.postings.get(p))
{
out |= bitmap;
}
}
out
}
#[must_use]
pub fn matches(&self, query: &Query) -> RoaringBitmap {
let words = prefixes(&query.text);
let mut result: Option<RoaringBitmap> = None;
for word in &words {
let postings = self.prefix_postings(word);
result = Some(match result {
None => postings,
Some(current) => current & postings,
});
}
let mut result = result.unwrap_or_else(|| self.active.clone() | self.inactive());
if let Some(language) = &query.language {
result &= self.languages.get(language).unwrap_or(&EMPTY);
}
if let Some(refset) = query.refset {
result &= self.refsets.get(&refset).unwrap_or(&EMPTY);
}
if let Some(uses) = &query.uses {
let mut allowed = RoaringBitmap::new();
for use_ordinal in uses {
allowed |= self.uses.get(use_ordinal).unwrap_or(&EMPTY);
}
result &= allowed;
}
if query.active_only {
result &= &self.active;
}
result
}
fn inactive(&self) -> RoaringBitmap {
let mut all = RoaringBitmap::new();
all.insert_range(0..u32::try_from(self.entries.len()).unwrap_or(u32::MAX));
all - &self.active
}
#[must_use]
pub fn search(&self, query: &Query, offset: usize, count: usize) -> Hits {
let matches = self.matches(query);
let total = matches.len();
let mut ranked: Vec<(u16, u32)> = matches
.iter()
.map(|d| (self.entry(d).map_or(u16::MAX, |e| e.term_length), d))
.collect();
ranked.sort_unstable();
let designations = ranked
.into_iter()
.skip(offset)
.take(count)
.map(|(_, d)| d)
.collect();
Hits {
total,
designations,
}
}
#[must_use]
pub fn parts(&self) -> Parts<'_> {
Parts {
dictionary: self.dictionary.as_fst().as_bytes(),
postings: &self.postings,
entries: &self.entries,
languages: &self.languages,
uses: &self.uses,
refsets: &self.refsets,
active: &self.active,
lengths: &self.lengths,
}
}
}
static EMPTY: std::sync::LazyLock<RoaringBitmap> = std::sync::LazyLock::new(RoaringBitmap::new);
#[derive(Debug)]
pub struct Parts<'a> {
pub dictionary: &'a [u8],
pub postings: &'a [RoaringBitmap],
pub entries: &'a [Entry],
pub languages: &'a BTreeMap<String, RoaringBitmap>,
pub uses: &'a BTreeMap<u32, RoaringBitmap>,
pub refsets: &'a BTreeMap<u32, RoaringBitmap>,
pub active: &'a RoaringBitmap,
pub lengths: &'a BTreeMap<u16, RoaringBitmap>,
}
#[derive(Debug, Default)]
pub struct OwnedParts {
pub dictionary: Vec<u8>,
pub postings: Vec<RoaringBitmap>,
pub entries: Vec<Entry>,
pub languages: BTreeMap<String, RoaringBitmap>,
pub uses: BTreeMap<u32, RoaringBitmap>,
pub refsets: BTreeMap<u32, RoaringBitmap>,
pub active: RoaringBitmap,
pub lengths: BTreeMap<u16, RoaringBitmap>,
}
#[cfg(test)]
mod tests {
use super::{IndexBuilder, Input, Query};
use concept_graph::ordinal::Ordinal;
fn index() -> super::TextIndex {
let mut builder = IndexBuilder::default();
let rows = [
(0, 0, "Heart failure", "en", 1, true, vec![0]),
(0, 1, "Cardiac failure", "en", 1, true, vec![0, 1]),
(0, 2, "Hartfalen", "nl", 1, true, vec![2]),
(1, 0, "Heart", "en", 1, true, vec![0]),
(2, 0, "Heart transplant", "en", 0, false, vec![]),
];
for (concept, index, term, language, use_ordinal, active, refsets) in rows {
builder
.add(&Input {
concept: Ordinal::new(concept),
index,
term,
language,
use_ordinal,
active,
refsets: &refsets,
})
.expect("adds");
}
builder.build().expect("builds")
}
#[test]
fn prefixes_match_any_word_and_intersect_across_words() {
let index = index();
let hits = index.search(
&Query {
text: "hea".to_owned(),
..Query::default()
},
0,
10,
);
assert_eq!(hits.total, 3);
assert_eq!(hits.designations, vec![3, 0, 4]);
let both = index.search(
&Query {
text: "fail hea".to_owned(),
..Query::default()
},
0,
10,
);
assert_eq!(both.designations, vec![0]);
let none = index.search(
&Query {
text: "xyz".to_owned(),
..Query::default()
},
0,
10,
);
assert_eq!(none.total, 0);
}
#[test]
fn filters_are_bitmap_intersections() {
let index = index();
let dutch = index.search(
&Query {
text: "hart".to_owned(),
language: Some("nl".to_owned()),
..Query::default()
},
0,
10,
);
assert_eq!(dutch.designations, vec![2]);
let refset = index.search(
&Query {
text: String::new(),
refset: Some(1),
..Query::default()
},
0,
10,
);
assert_eq!(refset.designations, vec![1]);
let active = index.search(
&Query {
text: "heart".to_owned(),
active_only: true,
..Query::default()
},
0,
10,
);
assert_eq!(active.designations, vec![3, 0]);
let fsn = index.search(
&Query {
text: String::new(),
uses: Some(vec![0]),
..Query::default()
},
0,
10,
);
assert_eq!(fsn.designations, vec![4]);
}
#[test]
fn paging_is_stable() {
let index = index();
let all = index.search(&Query::default(), 0, 10);
assert_eq!(all.total, 5);
let first = index.search(&Query::default(), 0, 2);
let second = index.search(&Query::default(), 2, 2);
let third = index.search(&Query::default(), 4, 2);
let mut joined = first.designations.clone();
joined.extend(second.designations);
joined.extend(third.designations);
assert_eq!(joined, all.designations);
assert_eq!(
index.entry(2).map(|e| (e.concept, e.index, e.term_length)),
Some((Ordinal::new(0), 2, 9))
);
}
}