use bytes::Bytes;
use super::{
cursor::{TermCursor, TermMeta},
metadata::NormTable,
};
use crate::superfile::{
ReadError,
error::FtsError,
fts::{
bm25,
positions::{decode_run, skip_run},
},
};
pub(super) struct PhraseMember {
pub(super) cursor: TermCursor,
pub(super) positions: Bytes,
pub(super) term_meta: Option<TermMeta>,
pub(super) inline_position: Option<u32>,
pub(super) idf: f32,
pub(super) run_offsets: Vec<u32>,
pub(super) run_offsets_block: usize,
pub(super) pos_scratch: Vec<u32>,
}
const NO_BLOCK_CACHED: usize = usize::MAX;
impl PhraseMember {
pub(super) fn decode_current_positions(&mut self) -> Result<(), FtsError> {
self.pos_scratch.clear();
if let Some(p) = self.inline_position {
self.pos_scratch.push(p);
return Ok(());
}
let block = self.cursor.current_block;
if self.run_offsets_block != block {
self.run_offsets.clear();
let term_meta = self.term_meta.as_ref().expect("PFOR member has term meta");
let mut at =
term_meta.positions_block_offset(self.cursor.bytes.as_ref(), block) as usize;
for i in 0..self.cursor.block_n {
self.run_offsets.push(at as u32);
skip_run(&self.positions, &mut at, self.cursor.block_tfs[i]).ok_or_else(|| {
FtsError::Read(ReadError::MalformedVersion(
"position runs truncated within block".into(),
))
})?;
}
self.run_offsets_block = block;
}
let pair = self.cursor.pos;
let mut at = self.run_offsets[pair] as usize;
decode_run(
&self.positions,
&mut at,
self.cursor.block_tfs[pair],
&mut self.pos_scratch,
)
.ok_or_else(|| {
FtsError::Read(ReadError::MalformedVersion(
"position run truncated or overflowing".into(),
))
})?;
Ok(())
}
}
pub(super) struct PhraseCursor {
pub(super) members: Vec<PhraseMember>,
pub(super) align_order: Vec<usize>,
pub(super) idf_x_k1p1: f32,
pub(super) term_max_bm25: f32,
pub(super) current_doc: u32,
pub(super) current_tf: u32,
pub(super) verify_scratch: Vec<u32>,
}
impl PhraseCursor {
pub(super) fn new(
cursors: Vec<TermCursor>,
positions: Vec<Bytes>,
positional: Vec<(Option<TermMeta>, Option<u32>)>,
) -> Result<Self, FtsError> {
debug_assert!(cursors.len() >= 2, "single-token phrases degrade to terms");
debug_assert_eq!(cursors.len(), positions.len());
debug_assert_eq!(cursors.len(), positional.len());
let mut idf_sum = 0.0f32;
let mut min_scaled_bound = f32::INFINITY;
let members: Vec<PhraseMember> = cursors
.into_iter()
.zip(positions)
.zip(positional)
.map(|((cursor, positions), (term_meta, inline_position))| {
let idf = cursor.idf_x_k1p1 / (bm25::K1 + 1.0);
min_scaled_bound = min_scaled_bound.min(cursor.term_max_bm25 / idf);
idf_sum += idf;
PhraseMember {
cursor,
positions,
term_meta,
inline_position,
idf,
run_offsets: Vec::new(),
run_offsets_block: NO_BLOCK_CACHED,
pos_scratch: Vec::new(),
}
})
.collect();
let mut align_order: Vec<usize> = (0..members.len()).collect();
align_order.sort_by_key(|&i| members[i].cursor.block_count());
let mut cursor = Self {
idf_x_k1p1: idf_sum * (bm25::K1 + 1.0),
term_max_bm25: idf_sum * min_scaled_bound,
members,
align_order,
current_doc: 0,
current_tf: 0,
verify_scratch: Vec::new(),
};
cursor.seek_match(0, f32::NEG_INFINITY, &NormTable::empty())?;
Ok(cursor)
}
#[inline]
pub(super) fn is_exhausted(&self) -> bool {
self.current_doc == u32::MAX
}
#[inline]
pub(super) fn current_doc_id(&self) -> u32 {
self.current_doc
}
pub(super) fn skip_to(&mut self, target: u32) -> Result<(), FtsError> {
if self.is_exhausted() || self.current_doc >= target {
return Ok(());
}
self.seek_match(target, f32::NEG_INFINITY, &NormTable::empty())
}
pub(super) fn skip_to_pruned(
&mut self,
target: u32,
bar: f32,
dl_norm_k1: &NormTable,
) -> Result<(), FtsError> {
if self.is_exhausted() || self.current_doc >= target {
return Ok(());
}
self.seek_match(target, bar, dl_norm_k1)
}
pub(super) fn seek_match(
&mut self,
mut from: u32,
bar: f32,
dl_norm_k1: &NormTable,
) -> Result<(), FtsError> {
'docs: loop {
let mut aligned = from;
let mut oi = 0usize;
while oi < self.align_order.len() {
let mi = self.align_order[oi];
let c = &mut self.members[mi].cursor;
c.skip_to(aligned);
if c.is_exhausted() {
self.current_doc = u32::MAX;
self.current_tf = 0;
return Ok(());
}
let here = c.current_doc_id();
if here > aligned {
aligned = here;
oi = 0;
continue;
}
oi += 1;
}
if bar > f32::NEG_INFINITY {
let min_tf = self
.members
.iter()
.map(|m| m.cursor.current_tf())
.min()
.expect("members >= 2");
let ub =
bm25::score_with_dl_norm_k1(self.idf_x_k1p1, min_tf, dl_norm_k1.get(aligned));
if ub < bar {
from = match aligned.checked_add(1) {
Some(next) => next,
None => {
self.current_doc = u32::MAX;
self.current_tf = 0;
return Ok(());
}
};
continue 'docs;
}
}
let tf = self.verify_at_aligned()?;
if tf > 0 {
self.current_doc = aligned;
self.current_tf = tf;
return Ok(());
}
from = match aligned.checked_add(1) {
Some(next) => next,
None => {
self.current_doc = u32::MAX;
self.current_tf = 0;
return Ok(());
}
};
continue 'docs;
}
}
pub(super) fn verify_at_aligned(&mut self) -> Result<u32, FtsError> {
let anchor = self.align_order[0];
let anchor_off = anchor as u32;
self.members[anchor].decode_current_positions()?;
self.verify_scratch.clear();
for &pa in &self.members[anchor].pos_scratch {
if let Some(start) = pa.checked_sub(anchor_off) {
self.verify_scratch.push(start);
}
}
for oi in 1..self.align_order.len() {
if self.verify_scratch.is_empty() {
break;
}
let j = self.align_order[oi];
self.members[j].decode_current_positions()?;
let plist = &self.members[j].pos_scratch;
let off = j as u32;
let mut w = 0usize;
for r in 0..self.verify_scratch.len() {
let start = self.verify_scratch[r];
let keep = start
.checked_add(off)
.is_some_and(|want| plist.binary_search(&want).is_ok());
if keep {
self.verify_scratch[w] = start;
w += 1;
}
}
self.verify_scratch.truncate(w);
}
Ok(self.verify_scratch.len() as u32)
}
#[inline]
pub(super) fn score_current(&self, dl_norm_k1: f32) -> f32 {
bm25::score_with_dl_norm_k1(self.idf_x_k1p1, self.current_tf, dl_norm_k1)
}
pub(super) fn block_max_in_range(&mut self, range_start: u32, range_end: u32) -> f32 {
let mut min_scaled = f32::INFINITY;
for m in self.members.iter_mut() {
let b = m.cursor.block_max_in_range(range_start, range_end);
min_scaled = min_scaled.min(b / m.idf);
}
let idf_sum = self.idf_x_k1p1 / (bm25::K1 + 1.0);
idf_sum * min_scaled
}
}
pub(super) enum AnyCursor {
Term(TermCursor),
Phrase(PhraseCursor),
}
impl AnyCursor {
#[inline]
pub(super) fn is_exhausted(&self) -> bool {
match self {
AnyCursor::Term(c) => c.is_exhausted(),
AnyCursor::Phrase(c) => c.is_exhausted(),
}
}
#[inline]
pub(super) fn current_doc_id(&self) -> u32 {
match self {
AnyCursor::Term(c) => c.current_doc_id(),
AnyCursor::Phrase(c) => c.current_doc_id(),
}
}
pub(super) fn skip_to(&mut self, target: u32) -> Result<(), FtsError> {
match self {
AnyCursor::Term(c) => {
c.skip_to(target);
Ok(())
}
AnyCursor::Phrase(c) => c.skip_to(target),
}
}
pub(super) fn skip_to_pruned(
&mut self,
target: u32,
bar: f32,
dl_norm_k1: &NormTable,
) -> Result<(), FtsError> {
match self {
AnyCursor::Term(c) => {
c.skip_to(target);
Ok(())
}
AnyCursor::Phrase(c) => c.skip_to_pruned(target, bar, dl_norm_k1),
}
}
#[inline]
pub(super) fn score_current(&self, dl_norm_k1: f32) -> f32 {
match self {
AnyCursor::Term(c) => {
bm25::score_with_dl_norm_k1(c.idf_x_k1p1, c.current_tf(), dl_norm_k1)
}
AnyCursor::Phrase(c) => c.score_current(dl_norm_k1),
}
}
#[inline]
pub(super) fn term_max_bm25(&self) -> f32 {
match self {
AnyCursor::Term(c) => c.term_max_bm25,
AnyCursor::Phrase(c) => c.term_max_bm25,
}
}
#[inline]
pub(super) fn block_max_in_range(&mut self, range_start: u32, range_end: u32) -> f32 {
match self {
AnyCursor::Term(c) => c.block_max_in_range(range_start, range_end),
AnyCursor::Phrase(c) => c.block_max_in_range(range_start, range_end),
}
}
}
#[cfg(test)]
mod tests {
use super::{super::test_util::*, *};
use crate::superfile::fts::reader::{FtsReader, core::ClauseLists};
fn phrase(terms: &[&str]) -> Vec<Vec<String>> {
vec![terms.iter().map(|t| t.to_string()).collect()]
}
#[tokio::test]
async fn phrase_matches_adjacent_in_order_only() {
let (blob, json) = build_phrase_blob();
let r = FtsReader::open(blob, json).expect("open");
let phrases = phrase(&["new", "york"]);
let hits = r
.search_excluding(
"title",
ClauseLists {
should_phrases: &phrases,
..ClauseLists::default()
},
10,
f32::NEG_INFINITY,
)
.await
.expect("phrase search");
let ids: Vec<u32> = hits.iter().map(|(d, _)| *d).collect();
let mut sorted = ids.clone();
sorted.sort_unstable();
assert_eq!(sorted, vec![0, 2, 4], "adjacency in order only");
assert_eq!(hits[0].0, 4, "double occurrence ranks first");
}
#[tokio::test]
async fn phrase_composes_with_clauses() {
let (blob, json) = build_phrase_blob();
let r = FtsReader::open(blob, json).expect("open");
let ny = phrase(&["new", "york"]);
let hits = r
.search_excluding(
"title",
ClauseLists {
musts: &["the"],
must_phrases: &ny,
..ClauseLists::default()
},
10,
f32::NEG_INFINITY,
)
.await
.expect("must phrase + term");
assert_eq!(
hits.iter().map(|(d, _)| *d).collect::<Vec<_>>(),
vec![2],
"+\"new york\" +the"
);
let hits = r
.search_excluding(
"title",
ClauseLists {
shoulds: &["haven"],
negative_phrases: &ny,
..ClauseLists::default()
},
10,
f32::NEG_INFINITY,
)
.await
.expect("negated phrase");
let mut ids: Vec<u32> = hits.iter().map(|(d, _)| *d).collect();
ids.sort_unstable();
assert_eq!(ids, vec![1, 3], "haven docs don't contain the phrase");
}
#[tokio::test]
async fn phrase_with_absent_member_matches_nothing() {
let (blob, json) = build_phrase_blob();
let r = FtsReader::open(blob, json).expect("open");
let ghost = phrase(&["new", "zealand"]);
let hits = r
.search_excluding(
"title",
ClauseLists {
must_phrases: &ghost,
..ClauseLists::default()
},
10,
f32::NEG_INFINITY,
)
.await
.expect("ghost phrase");
assert!(hits.is_empty());
}
#[tokio::test]
async fn phrase_on_positionless_column_is_typed_error() {
use crate::superfile::fts::builder::FtsBuilder;
let mut b = FtsBuilder::new(crate::test_helpers::default_tokenizer());
b.register_column("title".into(), false).expect("register");
b.add_doc(0, 0, "new york").expect("add doc");
let blob = Bytes::from(b.finish().expect("finish"));
let r =
FtsReader::open(blob, r#"[{"name":"title","tokenizer":"ascii_lower"}]"#).expect("open");
let phrases = phrase(&["new", "york"]);
let err = r
.search_excluding(
"title",
ClauseLists {
should_phrases: &phrases,
..ClauseLists::default()
},
10,
f32::NEG_INFINITY,
)
.await
.expect_err("must be a typed error");
assert!(matches!(err, FtsError::PositionsUnavailable { .. }));
}
}