1use super::index::{FieldId, SegmentIndex};
37
38pub(crate) enum PhraseReadError<Control> {
39 Storage(super::sealed::SealedSegmentError),
40 Control(Control),
41}
42
43impl<Control> From<super::postings::PostingsError> for PhraseReadError<Control> {
44 fn from(error: super::postings::PostingsError) -> Self {
45 Self::Storage(error.into())
46 }
47}
48
49impl<Control> From<super::sealed::SealedSegmentError> for PhraseReadError<Control> {
50 fn from(error: super::sealed::SealedSegmentError) -> Self {
51 Self::Storage(error)
52 }
53}
54
55struct SegmentPhrase<'index> {
56 readers: Vec<Option<super::postings::PostingsReader<'index>>>,
57 order: Vec<usize>,
58 rows: u32,
59}
60
61pub(crate) struct PreparedPhrase<'index> {
64 segments: Vec<SegmentPhrase<'index>>,
65 slop: u32,
66 bytes: usize,
67}
68
69impl<'index> PreparedPhrase<'index> {
70 pub(crate) fn allocation_bytes(&self) -> usize {
71 self.bytes
72 }
73
74 pub(crate) fn new<Control>(
75 index: &'index super::index::LexicalIndex,
76 query: &super::query::LexicalQuery,
77 mut reserve: impl FnMut(Option<usize>) -> Result<(), Control>,
78 mut checkpoint: impl FnMut() -> Result<(), Control>,
79 ) -> Result<Option<Self>, PhraseReadError<Control>> {
80 let super::query::LexicalQuery::Phrase { terms, slop, field } = query else {
81 return Ok(None);
82 };
83 let mut work =
84 super::control::WorkCheck::new(|| checkpoint().map_err(PhraseReadError::Control));
85 work.check_now()?;
86 let mut bytes = index
87 .segments()
88 .len()
89 .checked_mul(std::mem::size_of::<SegmentPhrase<'_>>());
90 for segment in index.segments() {
91 work.step()?;
92 bytes = bytes.and_then(|bytes| {
93 terms
94 .len()
95 .checked_mul(
96 std::mem::size_of::<Option<super::postings::PostingsReader<'_>>>()
97 + std::mem::size_of::<usize>(),
98 )
99 .and_then(|extra| bytes.checked_add(extra))
100 });
101 for term in terms {
102 work.step()?;
103 let extra = segment.position_reader_bytes_controlled(term, *field, &mut work)?;
104 bytes = bytes.and_then(|bytes| extra.and_then(|extra| bytes.checked_add(extra)));
105 }
106 }
107 reserve(bytes).map_err(PhraseReadError::Control)?;
108 let bytes = bytes.ok_or(PhraseReadError::Storage(
109 super::sealed::SealedSegmentError::Geometry("phrase allocation size overflow"),
110 ))?;
111 let mut segments = Vec::with_capacity(index.segments().len());
112 for segment in index.segments() {
113 work.step()?;
114 let mut readers = Vec::with_capacity(terms.len());
115 for term in terms {
116 work.step()?;
117 readers.push(segment.position_reader_controlled(term, *field, &mut work)?);
118 }
119 let mut order = Vec::with_capacity(terms.len());
120 for slot in 0..terms.len() {
121 work.step()?;
122 order.push(slot);
123 }
124 let frequency = |slot: &usize| {
125 readers
126 .get(*slot)
127 .and_then(Option::as_ref)
128 .map_or(0, |reader| reader.document_frequency())
129 };
130 super::control::sort_by(
131 &mut order,
132 |left, right| frequency(left).cmp(&frequency(right)),
133 &mut work,
134 )?;
135 segments.push(SegmentPhrase {
136 readers,
137 order,
138 rows: segment.row_count(),
139 });
140 }
141 work.check_now()?;
142 Ok(Some(Self {
143 segments,
144 slop: *slop,
145 bytes,
146 }))
147 }
148
149 pub(crate) fn matches<Control>(
150 &self,
151 doc: super::search::GlobalDocId,
152 mut reserve: impl FnMut(Option<usize>) -> Result<(), Control>,
153 mut checkpoint: impl FnMut() -> Result<(), Control>,
154 ) -> Result<bool, PhraseReadError<Control>> {
155 let invalid = || {
156 PhraseReadError::Storage(super::sealed::SealedSegmentError::Geometry(
157 "phrase candidate is outside its pinned segment",
158 ))
159 };
160 let mut work =
161 super::control::WorkCheck::new(|| checkpoint().map_err(PhraseReadError::Control));
162 work.check_now()?;
163 let segment = self
164 .segments
165 .get(doc.segment as usize)
166 .ok_or_else(invalid)?;
167 if doc.row >= segment.rows {
168 return Err(invalid());
169 }
170 let views_bytes = segment
171 .readers
172 .len()
173 .checked_mul(std::mem::size_of::<Option<super::postings::RowPositions<'_>>>());
174 let scratch = views_bytes.and_then(|bytes| self.bytes.checked_add(bytes));
175 reserve(scratch).map_err(PhraseReadError::Control)?;
176 let mut views = vec![None; segment.readers.len()];
177 let mut positions = 0_usize;
178 let mut maximum = 1_usize;
179 for slot in &segment.order {
182 work.step()?;
183 let Some(reader) = segment.readers.get(*slot).and_then(Option::as_ref) else {
184 return Ok(false);
185 };
186 let Some(view) = reader.positions_controlled(doc.row, &mut work)? else {
187 return Ok(false);
188 };
189 positions = positions.checked_add(view.len()).ok_or_else(invalid)?;
190 maximum = maximum.max(view.len());
191 *views.get_mut(*slot).ok_or_else(invalid)? = Some(view);
192 }
193 let bytes = scratch
196 .and_then(|bytes| {
197 segment
198 .readers
199 .len()
200 .checked_mul(std::mem::size_of::<Vec<u32>>())
201 .and_then(|extra| bytes.checked_add(extra))
202 })
203 .and_then(|bytes| {
204 positions
205 .checked_mul(std::mem::size_of::<u32>())
206 .and_then(|extra| bytes.checked_add(extra))
207 })
208 .and_then(|bytes| {
209 maximum
210 .checked_mul(2 * std::mem::size_of::<(u32, u32)>())
211 .and_then(|extra| bytes.checked_add(extra))
212 });
213 reserve(bytes).map_err(PhraseReadError::Control)?;
214 let mut streams = Vec::with_capacity(views.len());
215 for view in views {
216 work.step()?;
217 streams.push(view.ok_or_else(invalid)?.decode_controlled(&mut work)?);
218 }
219 Ok(minimum_displacement_controlled(&streams, &mut work)?
220 .is_some_and(|cost| cost <= self.slop))
221 }
222}
223
224#[derive(Clone, Debug, Eq, PartialEq)]
226pub struct PhraseQuery {
227 pub terms: Vec<Vec<u8>>,
229 pub slop: u32,
231 pub field: FieldId,
233}
234
235#[derive(Clone, Copy, Debug, Eq, PartialEq)]
237pub enum PhraseError {
238 Empty,
240}
241
242impl std::fmt::Display for PhraseError {
243 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 formatter.write_str("a phrase query needs at least one term")
245 }
246}
247
248impl std::error::Error for PhraseError {}
249
250#[must_use]
255pub fn minimum_displacement(streams: &[Vec<u32>]) -> Option<u32> {
256 let mut work = super::control::WorkCheck::new(|| Ok::<(), std::convert::Infallible>(()));
257 match minimum_displacement_controlled(streams, &mut work) {
258 Ok(displacement) => displacement,
259 Err(never) => match never {},
260 }
261}
262
263pub(crate) fn minimum_displacement_controlled<E>(
264 streams: &[Vec<u32>],
265 work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
266) -> Result<Option<u32>, E> {
267 work.check_now()?;
268 let Some(first) = streams.first() else {
269 return Ok(None);
270 };
271 if streams.len() == 1 {
272 return Ok((!first.is_empty()).then_some(0));
273 }
274 for stream in streams {
275 work.step()?;
276 if stream.is_empty() {
277 return Ok(None);
278 }
279 }
280
281 let mut best: Option<u32> = None;
282 for anchor in first {
283 work.step()?;
284 let mut previous: Vec<(u32, u32)> = vec![(*anchor, 0)];
286 for (offset, stream) in streams.iter().enumerate().skip(1) {
287 #[cfg(test)]
288 DP_TERM_OFFSET.with(|value| value.set(offset));
289 work.step()?;
290 let expected = u32::try_from(offset).unwrap_or(u32::MAX);
291 let mut current: Vec<(u32, u32)> = Vec::with_capacity(stream.len());
292 for candidate in stream {
293 work.step()?;
294 let mut best_previous: Option<u32> = None;
295 for (position, cost) in &previous {
296 work.step()?;
297 #[cfg(test)]
298 DP_PREDECESSOR_PROBES.with(|value| value.set(value.get() + 1));
299 if position <= candidate {
300 best_previous = Some(best_previous.map_or(*cost, |best| best.min(*cost)));
301 }
302 }
303 let Some(best_previous) = best_previous else {
304 continue;
305 };
306 let relative = candidate.saturating_sub(*anchor);
307 let displacement = relative.abs_diff(expected);
308 current.push((*candidate, best_previous.saturating_add(displacement)));
309 }
310 if current.is_empty() {
311 previous.clear();
312 break;
313 }
314 previous = current;
315 }
316 for (_, cost) in previous {
317 work.step()?;
318 best = Some(best.map_or(cost, |current| current.min(cost)));
319 }
320 }
321 work.check_now()?;
322 Ok(best)
323}
324
325#[cfg(test)]
326thread_local! {
327 static DP_PREDECESSOR_PROBES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
328 static DP_TERM_OFFSET: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
329}
330
331#[must_use]
333pub fn streams_match(streams: &[Vec<u32>], slop: u32) -> bool {
334 minimum_displacement(streams).is_some_and(|cost| cost <= slop)
335}
336
337pub fn search_segment(
344 segment: &SegmentIndex,
345 query: &PhraseQuery,
346) -> Result<Vec<u32>, PhraseError> {
347 if query.terms.is_empty() {
348 return Err(PhraseError::Empty);
349 }
350 let Some(first) = query.terms.first() else {
354 return Err(PhraseError::Empty);
355 };
356 let Some(anchor_list) = segment.posting_list(first, query.field) else {
357 return Ok(Vec::new());
358 };
359
360 let mut matches = Vec::new();
361 for posting in anchor_list.postings() {
362 let mut streams: Vec<Vec<u32>> = Vec::with_capacity(query.terms.len());
363 let mut complete = true;
364 for term in &query.terms {
365 let Some(list) = segment.posting_list(term, query.field) else {
366 complete = false;
367 break;
368 };
369 let Some(entry) = list
370 .postings()
371 .iter()
372 .find(|candidate| candidate.docid == posting.docid)
373 else {
374 complete = false;
375 break;
376 };
377 streams.push(entry.positions.clone());
378 }
379 if complete && streams_match(&streams, query.slop) {
380 matches.push(posting.docid);
381 }
382 }
383 Ok(matches)
384}
385
386#[cfg(test)]
387#[allow(
388 clippy::expect_used,
389 clippy::indexing_slicing,
390 clippy::panic,
391 clippy::unwrap_used
392)]
393mod tests {
394 use super::*;
395 use crate::fts::index::{DEFAULT_FIELD, Document};
396 use crate::fts::tokenizer::{Analyzer, Profile, TokenizerConfig, Vocabulary};
397
398 #[test]
399 fn astra_18_prepared_phrase_cancels_during_position_decode() {
400 use crate::fts::index::LexicalIndex;
401 use crate::fts::postings::POSITION_DECODE_PROBES;
402 use crate::fts::query::LexicalQuery;
403 use crate::fts::sealed::SealedSegment;
404 use crate::fts::search::GlobalDocId;
405 let analyzer = Analyzer::new(TokenizerConfig::text_default()).expect("analyzer");
406 let mut active = SegmentIndex::new();
407 let text = format!("{}beta", "alpha ".repeat(4_096));
408 active
409 .push_document(&analyzer, &Document::with_text(&text))
410 .expect("long row");
411 let bytes = SealedSegment::seal(&active)
412 .expect("seal")
413 .encode_region()
414 .expect("persist");
415 let mut index = LexicalIndex::new();
416 index.push_sealed(SealedSegment::decode_region(&bytes).expect("reopen"));
417 let query =
418 LexicalQuery::phrase(vec![b"alpha".to_vec(), b"beta".to_vec()], 0, DEFAULT_FIELD);
419 let prepared = PreparedPhrase::new(&index, &query, |_| Ok::<_, &str>(()), || Ok(()))
420 .unwrap_or_else(|_| panic!("prepare"))
421 .expect("phrase");
422 POSITION_DECODE_PROBES.with(|value| value.set(0));
423 crate::fts::preparation_observer::begin();
424 let result = prepared.matches(
425 GlobalDocId { segment: 0, row: 0 },
426 |_| Ok(()),
427 || {
428 if POSITION_DECODE_PROBES.with(std::cell::Cell::get) >= 64 {
429 Err("cancelled")
430 } else {
431 Ok(())
432 }
433 },
434 );
435 let probes = POSITION_DECODE_PROBES.with(std::cell::Cell::get);
436 let observed = crate::fts::preparation_observer::phrase_position_work();
437 crate::fts::preparation_observer::take();
438 println!("prepared_decode_probes={probes}, position_work={observed:?}");
439 assert!(matches!(result, Err(PhraseReadError::Control("cancelled"))));
440 assert!(
441 (64..=128).contains(&probes),
442 "prepared phrase must check inside row decode"
443 );
444 assert_eq!(
445 observed.0, probes,
446 "partial work survives the cancellation error"
447 );
448 assert!(
449 prepared
450 .matches(
451 GlobalDocId { segment: 0, row: 0 },
452 |_| Ok::<_, ()>(()),
453 || Ok(())
454 )
455 .unwrap_or_else(|_| panic!("clean phrase")),
456 "last alpha and beta are adjacent"
457 );
458 }
459
460 #[test]
461 fn astra_18_phrase_cancels_inside_predecessor_scan() {
462 let streams = vec![vec![0], (1..=4_096).collect(), vec![4_097]];
463 assert_eq!(minimum_displacement(&streams), Some(4_095));
464 DP_PREDECESSOR_PROBES.with(|value| value.set(0));
465 DP_TERM_OFFSET.with(|value| value.set(0));
466 let mut checks = 0;
467 let mut work = super::super::control::WorkCheck::new(|| {
468 checks += 1;
469 if DP_TERM_OFFSET.with(std::cell::Cell::get) == 2
470 && DP_PREDECESSOR_PROBES.with(std::cell::Cell::get) > 4_096
471 {
472 Err("cancelled")
473 } else {
474 Ok(())
475 }
476 });
477 let result = minimum_displacement_controlled(&streams, &mut work);
478 let probes = DP_PREDECESSOR_PROBES.with(std::cell::Cell::get);
479 println!("checks={checks}, predecessor_probes={probes}");
480 assert_eq!(result, Err("cancelled"));
481 assert!(
482 probes > 4_096 && probes <= 4_096 + 128,
483 "must stop within the final predecessor scan"
484 );
485 let mut work = super::super::control::WorkCheck::new(|| Ok::<(), ()>(()));
486 assert_eq!(
487 minimum_displacement_controlled(&streams, &mut work),
488 Ok(Some(4_095))
489 );
490 }
491
492 #[test]
493 fn astra_11_phrase_slop_repetition_and_field_boundaries_match_oracle() {
494 use crate::fts::index::LexicalIndex;
495 use crate::fts::query::LexicalQuery;
496 use crate::fts::sealed::SealedSegment;
497 use crate::fts::search::GlobalDocId;
498 let texts = [
499 "alpha beta alpha",
500 "alpha filler beta alpha",
501 "beta alpha",
502 "alpha alpha beta",
503 "alpha the beta",
504 ];
505 let literal = [
508 [vec![0, 2], vec![1]],
509 [vec![0, 3], vec![2]],
510 [vec![1], vec![0]],
511 [vec![0, 1], vec![2]],
512 [vec![0], vec![2]],
513 ];
514 let analyzer = Analyzer::new(TokenizerConfig::text_default()).expect("analyzer");
515 let mut active = SegmentIndex::new();
516 for text in texts {
517 let mut doc = Document::with_text(text);
518 doc.set(FieldId(1), "beta alpha");
519 active.push_document(&analyzer, &doc).expect("document");
520 }
521 let bytes = SealedSegment::seal(&active)
522 .expect("seal")
523 .encode_region()
524 .expect("persist");
525 let mut index = LexicalIndex::new();
526 index
527 .push_sealed(SealedSegment::decode_region(&bytes).expect("reopen persisted positions"));
528 fn enumerate(streams: &[Vec<u32>], chosen: &mut Vec<u32>, slop: u32) -> bool {
529 if chosen.len() == streams.len() {
530 let first = chosen[0];
531 return chosen
532 .iter()
533 .enumerate()
534 .map(|(i, value)| value.saturating_sub(first).abs_diff(i as u32))
535 .sum::<u32>()
536 <= slop;
537 }
538 for value in &streams[chosen.len()] {
539 if chosen.last().is_none_or(|last| value >= last) {
540 chosen.push(*value);
541 if enumerate(streams, chosen, slop) {
542 return true;
543 }
544 chosen.pop();
545 }
546 }
547 false
548 }
549 for field in [DEFAULT_FIELD, FieldId(1), FieldId(2)] {
550 for words in [vec![0, 1], vec![0, 0], vec![1, 0], vec![0, 1, 0]] {
551 let terms = words
552 .iter()
553 .map(|word| {
554 if *word == 0 {
555 b"alpha".to_vec()
556 } else {
557 b"beta".to_vec()
558 }
559 })
560 .collect::<Vec<_>>();
561 for slop in [0, 1, 2, 6] {
562 let query = LexicalQuery::phrase(terms.clone(), slop, field);
563 let prepared =
564 PreparedPhrase::new(&index, &query, |_| Ok::<_, ()>(()), || Ok(()))
565 .unwrap_or_else(|_| panic!("prepare"))
566 .expect("phrase");
567 for (row, source) in literal.iter().enumerate() {
568 let streams = words
569 .iter()
570 .map(|word| match field.0 {
571 0 => source[*word].clone(),
572 1 => vec![if *word == 0 { 1 } else { 0 }],
573 _ => vec![],
574 })
575 .collect::<Vec<_>>();
576 let expected = enumerate(&streams, &mut Vec::new(), slop);
577 let actual = prepared
578 .matches(
579 GlobalDocId {
580 segment: 0,
581 row: row as u32,
582 },
583 |_| Ok::<_, ()>(()),
584 || Ok(()),
585 )
586 .unwrap_or_else(|_| panic!("positions"));
587 assert_eq!(
588 actual, expected,
589 "row={row} field={field:?} words={words:?} slop={slop}"
590 );
591 if field.0 < 2 {
592 assert_eq!(
593 actual,
594 crate::fts::query::phrase_matches(
595 &analyzer,
596 if field.0 == 0 {
597 texts[row]
598 } else {
599 "beta alpha"
600 },
601 &terms,
602 slop
603 ),
604 "old text matcher secondary control"
605 );
606 }
607 }
608 }
609 }
610 }
611 }
612
613 fn segment_of(analyzer: &Analyzer, texts: &[&str]) -> SegmentIndex {
614 let mut segment = SegmentIndex::new();
615 for text in texts {
616 segment
617 .push_document(analyzer, &Document::with_text(text))
618 .expect("indexable");
619 }
620 segment
621 }
622
623 fn code_analyzer() -> Analyzer {
624 Analyzer::new(Profile::Code.config()).expect("valid config")
625 }
626
627 fn phrase(terms: &[&str], slop: u32) -> PhraseQuery {
628 PhraseQuery {
629 terms: terms.iter().map(|term| term.as_bytes().to_vec()).collect(),
630 slop,
631 field: DEFAULT_FIELD,
632 }
633 }
634
635 #[test]
636 fn slop_zero_and_slop_one_semantics_match_the_pinned_fixtures() {
637 let analyzer = code_analyzer();
638 let segment = segment_of(
639 &analyzer,
640 &[
641 "quick brown fox", "quick red brown", "brown quick", "quick", ],
646 );
647
648 assert_eq!(
650 search_segment(&segment, &phrase(&["quick", "brown"], 0)).expect("valid"),
651 vec![0]
652 );
653 assert_eq!(
655 search_segment(&segment, &phrase(&["quick", "brown"], 1)).expect("valid"),
656 vec![0, 1]
657 );
658 assert!(
662 !search_segment(&segment, &phrase(&["quick", "brown"], 1))
663 .expect("valid")
664 .contains(&2)
665 );
666 }
667
668 #[test]
669 fn a_single_term_phrase_is_just_a_term_query() {
670 let analyzer = code_analyzer();
671 let segment = segment_of(&analyzer, &["alpha beta", "gamma"]);
672 assert_eq!(
673 search_segment(&segment, &phrase(&["alpha"], 0)).expect("valid"),
674 vec![0]
675 );
676 }
677
678 #[test]
679 fn an_empty_phrase_is_a_typed_error() {
680 let analyzer = code_analyzer();
681 let segment = segment_of(&analyzer, &["alpha"]);
682 assert_eq!(
683 search_segment(&segment, &phrase(&[], 0)),
684 Err(PhraseError::Empty)
685 );
686 }
687
688 #[test]
689 fn synonym_stacked_phrase_matches_the_underscored_identifier() {
690 let mut vocabulary = Vocabulary::new();
694 vocabulary
695 .declare(
696 "put_if_match",
697 &[&["put", "if", "match"][..], &["putifmatch"][..]],
698 )
699 .expect("valid declaration");
700 let analyzer = Analyzer::new(Profile::Code.config().with_vocabulary(vocabulary))
701 .expect("valid config");
702 let segment = segment_of(&analyzer, &["call put_if_match now"]);
703
704 assert_eq!(
706 search_segment(&segment, &phrase(&["put", "if", "match"], 0)).expect("valid"),
707 vec![0]
708 );
709 assert_eq!(
711 search_segment(&segment, &phrase(&["put_if_match"], 0)).expect("valid"),
712 vec![0]
713 );
714 }
715
716 #[test]
717 fn a_phrase_crossing_a_stopword_gap_needs_the_slop_the_gap_costs() {
718 let analyzer = Analyzer::new(TokenizerConfig::text_default()).expect("valid config");
722 let segment = segment_of(&analyzer, &["state of the art"]);
723 assert!(
724 search_segment(&segment, &phrase(&["state", "art"], 1))
725 .expect("valid")
726 .is_empty()
727 );
728 assert_eq!(
729 search_segment(&segment, &phrase(&["state", "art"], 2)).expect("valid"),
730 vec![0]
731 );
732 }
733
734 #[test]
735 fn displacement_is_exact_not_greedy() {
736 let streams = vec![vec![0_u32, 5], vec![6]];
740 assert_eq!(minimum_displacement(&streams), Some(0));
741 assert!(streams_match(&streams, 0));
742 }
743
744 #[test]
745 fn a_missing_term_never_matches() {
746 assert_eq!(minimum_displacement(&[vec![1_u32], Vec::new()]), None);
747 assert!(!streams_match(&[vec![1_u32], Vec::new()], 100));
748 }
749
750 #[test]
751 fn stacked_positions_cost_nothing_to_cross() {
752 let streams = vec![vec![0_u32], vec![0], vec![1]];
754 assert_eq!(minimum_displacement(&streams), Some(2));
757 }
758
759 #[test]
760 fn an_empty_stream_list_yields_no_displacement() {
761 assert_eq!(minimum_displacement(&[]), None);
762 assert_eq!(minimum_displacement(&[Vec::new()]), None);
763 }
764}