Skip to main content

a3s_code_core/workspace/retrieval/
lexical.rs

1use super::catalog::ChunkCatalogSnapshot;
2use super::types::{
3    WorkspaceChunk, WorkspaceIndexError, WorkspaceIndexResult, WorkspaceLexicalEngine,
4};
5use crate::workspace::WorkspacePath;
6use rayon::prelude::*;
7use std::collections::{HashMap, HashSet};
8use std::mem::size_of;
9use std::sync::Arc;
10
11const DEFAULT_QUERY_TERM_LIMIT: usize = 32;
12const DEFAULT_CANDIDATE_FILE_LIMIT: usize = 256;
13const DEFAULT_RESULT_LIMIT: usize = 10;
14const MAX_RESULT_LIMIT: usize = 25;
15const MAX_QUERY_BYTES: usize = 2_048;
16const DEFAULT_RESULTS_PER_FILE: usize = 2;
17const PARALLEL_BUILD_MIN_DOCUMENTS: usize = 128;
18const PARALLEL_BUILD_MIN_BYTES: usize = 64 * 1024;
19
20pub(crate) fn should_parallelize_build(document_count: usize, text_bytes: usize) -> bool {
21    document_count >= PARALLEL_BUILD_MIN_DOCUMENTS
22        && text_bytes >= PARALLEL_BUILD_MIN_BYTES
23        && std::thread::available_parallelism()
24            .map(|workers| workers.get() > 1)
25            .unwrap_or(true)
26}
27
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub struct LexicalSearchRequest {
30    pub query: String,
31    pub path: WorkspacePath,
32    pub glob: Option<String>,
33    pub limit: usize,
34    pub max_candidate_files: usize,
35    pub max_results_per_file: usize,
36}
37
38impl LexicalSearchRequest {
39    pub fn new(query: impl Into<String>) -> Self {
40        Self {
41            query: query.into(),
42            path: WorkspacePath::root(),
43            glob: None,
44            limit: DEFAULT_RESULT_LIMIT,
45            max_candidate_files: DEFAULT_CANDIDATE_FILE_LIMIT,
46            max_results_per_file: DEFAULT_RESULTS_PER_FILE,
47        }
48    }
49}
50
51#[derive(Clone, Debug)]
52pub struct LexicalSearchHit {
53    pub chunk: Arc<WorkspaceChunk>,
54    pub score: f64,
55}
56
57#[derive(Clone, Debug)]
58pub struct LexicalSearchResult {
59    pub catalog_revision: u64,
60    pub source_revision: u64,
61    pub lexical_engine: WorkspaceLexicalEngine,
62    pub query_terms: Vec<String>,
63    pub matching_files: usize,
64    pub selected_files: usize,
65    pub scored_chunks: usize,
66    pub candidate_truncated: bool,
67    pub hits: Vec<LexicalSearchHit>,
68}
69
70/// One dependency-free lexical document used by minimal builds.
71#[derive(Debug, Clone)]
72struct PortableDocument {
73    term_frequencies: HashMap<String, u32>,
74    length: usize,
75}
76
77impl PortableDocument {
78    fn from_tokens(tokens: &[String]) -> Self {
79        let mut term_frequencies = HashMap::new();
80        for token in tokens {
81            *term_frequencies.entry(token.clone()).or_insert(0) += 1;
82        }
83        Self {
84            term_frequencies,
85            length: tokens.len(),
86        }
87    }
88}
89
90#[derive(Debug, Clone)]
91struct PortablePosting {
92    document: usize,
93    term_frequency: u32,
94}
95
96/// Small, deterministic scorer used by minimal builds that intentionally omit
97/// the native zvec artifact. It shares the exact tokenizer and result contract
98/// with the zvec path, so disabling native artifacts never removes BM25.
99pub(crate) struct PortableLexicalIndex {
100    documents: Vec<PortableDocument>,
101    postings: HashMap<String, Vec<PortablePosting>>,
102    terms: HashSet<String>,
103    estimated_bytes: usize,
104}
105
106impl PortableLexicalIndex {
107    fn build(documents: &[(String, String)]) -> WorkspaceIndexResult<Self> {
108        let mut seen_keys = HashSet::new();
109        for (key, _) in documents {
110            if key.is_empty() || key.contains('\0') {
111                return Err(WorkspaceIndexError::InvalidConfig(
112                    "lexical document key must be non-empty and contain no NUL byte".to_owned(),
113                ));
114            }
115            if !seen_keys.insert(key) {
116                return Err(WorkspaceIndexError::InvalidConfig(
117                    "lexical document keys must be unique".to_owned(),
118                ));
119            }
120        }
121        // Tokenization and per-document term-frequency construction are pure
122        // CPU work. Rayon keeps the resulting vector in input order, which is
123        // required for deterministic BM25 tie-breaking and chunk ordinals.
124        // Tiny partitions stay serial because scheduling overhead would cost
125        // more than the work; large rebuilds use every available worker.
126        let parallel = should_parallelize_build(
127            documents.len(),
128            documents
129                .iter()
130                .fold(0usize, |total, (_, text)| total.saturating_add(text.len())),
131        );
132        let tokenized = if parallel {
133            documents
134                .par_iter()
135                .filter_map(|(_, text)| {
136                    let tokens = tokenize(text);
137                    (!tokens.is_empty()).then_some(tokens)
138                })
139                .collect::<Vec<_>>()
140        } else {
141            documents
142                .iter()
143                .filter_map(|(_, text)| {
144                    let tokens = tokenize(text);
145                    (!tokens.is_empty()).then_some(tokens)
146                })
147                .collect::<Vec<_>>()
148        };
149        let indexed = if parallel {
150            tokenized
151                .par_iter()
152                .map(|tokens| PortableDocument::from_tokens(tokens))
153                .collect::<Vec<_>>()
154        } else {
155            tokenized
156                .iter()
157                .map(|tokens| PortableDocument::from_tokens(tokens))
158                .collect::<Vec<_>>()
159        };
160        let terms = if parallel {
161            tokenized
162                .par_iter()
163                .flat_map_iter(|tokens| tokens.iter().cloned())
164                .collect::<HashSet<_>>()
165        } else {
166            tokenized
167                .iter()
168                .flat_map(|tokens| tokens.iter().cloned())
169                .collect::<HashSet<_>>()
170        };
171        let mut postings = if parallel {
172            indexed
173                .par_iter()
174                .enumerate()
175                .fold(
176                    HashMap::<String, Vec<PortablePosting>>::new,
177                    |mut postings, (document, stats)| {
178                        for (term, frequency) in &stats.term_frequencies {
179                            postings
180                                .entry(term.clone())
181                                .or_default()
182                                .push(PortablePosting {
183                                    document,
184                                    term_frequency: *frequency,
185                                });
186                        }
187                        postings
188                    },
189                )
190                .reduce(
191                    HashMap::<String, Vec<PortablePosting>>::new,
192                    |mut left, mut right| {
193                        for (term, mut values) in right.drain() {
194                            left.entry(term).or_default().append(&mut values);
195                        }
196                        left
197                    },
198                )
199        } else {
200            let mut postings = HashMap::<String, Vec<PortablePosting>>::new();
201            for (document, stats) in indexed.iter().enumerate() {
202                for (term, frequency) in &stats.term_frequencies {
203                    postings
204                        .entry(term.clone())
205                        .or_default()
206                        .push(PortablePosting {
207                            document,
208                            term_frequency: *frequency,
209                        });
210                }
211            }
212            postings
213        };
214        if parallel {
215            // Rayon reduction order is intentionally unspecified. Restore
216            // document order in each posting list before scoring so floating
217            // point accumulation and deterministic ties remain stable.
218            for values in postings.values_mut() {
219                values.sort_unstable_by_key(|posting| posting.document);
220            }
221        }
222        let estimated_bytes = size_of::<Self>()
223            .saturating_add(indexed.len().saturating_mul(size_of::<PortableDocument>()))
224            .saturating_add(
225                indexed
226                    .iter()
227                    .flat_map(|document| document.term_frequencies.keys())
228                    .map(|term| term.capacity())
229                    .sum::<usize>(),
230            )
231            .saturating_add(
232                postings
233                    .iter()
234                    .map(|(term, values)| {
235                        term.capacity().saturating_add(
236                            values.len().saturating_mul(size_of::<PortablePosting>()),
237                        )
238                    })
239                    .sum::<usize>(),
240            );
241        Ok(Self {
242            documents: indexed,
243            postings,
244            terms,
245            estimated_bytes,
246        })
247    }
248
249    fn document_count(&self) -> usize {
250        self.documents.len()
251    }
252
253    fn estimated_bytes(&self) -> usize {
254        self.estimated_bytes
255    }
256
257    fn has_any_term(&self, terms: &[String]) -> bool {
258        terms.iter().any(|term| self.terms.contains(term))
259    }
260
261    fn search(&self, terms: &[String], limit: usize) -> WorkspaceIndexResult<Vec<(usize, f64)>> {
262        if terms.is_empty() || limit == 0 || self.documents.is_empty() {
263            return Ok(Vec::new());
264        }
265        const K1: f64 = 1.2;
266        const B: f64 = 0.75;
267        let document_count = self.documents.len() as f64;
268        let average_length = (self
269            .documents
270            .iter()
271            .map(|document| document.length)
272            .sum::<usize>() as f64
273            / document_count)
274            .max(1.0);
275        let mut scores = vec![0.0; self.documents.len()];
276        let mut seen = HashSet::new();
277        for term in terms {
278            if !seen.insert(term.as_str()) {
279                continue;
280            }
281            let Some(postings) = self.postings.get(term) else {
282                continue;
283            };
284            let document_frequency = postings.len() as f64;
285            let idf = (1.0
286                + (document_count - document_frequency + 0.5) / (document_frequency + 0.5))
287                .ln();
288            for posting in postings {
289                let length_ratio = self.documents[posting.document].length as f64 / average_length;
290                let frequency = posting.term_frequency as f64;
291                let denominator = frequency + K1 * (1.0 - B + B * length_ratio);
292                scores[posting.document] +=
293                    idf * (frequency * (K1 + 1.0) / denominator.max(f64::EPSILON));
294            }
295        }
296        let mut hits = scores
297            .into_iter()
298            .enumerate()
299            .filter(|(_, score)| score.is_finite() && *score > 0.0)
300            .collect::<Vec<_>>();
301        hits.sort_by(|left, right| {
302            right
303                .1
304                .total_cmp(&left.1)
305                .then_with(|| left.0.cmp(&right.0))
306        });
307        hits.truncate(limit);
308        Ok(hits)
309    }
310}
311
312/// Backend-neutral lexical index handle. The native zvec binding is the
313/// product default; the portable variant is available only for minimal builds.
314pub(crate) enum LexicalIndex {
315    Portable(PortableLexicalIndex),
316    #[cfg(feature = "zvec-rust-fts")]
317    ZvecRust(super::zvec_rust::ZvecRustLexicalIndex),
318}
319
320impl LexicalIndex {
321    fn build(
322        documents: &[(String, String)],
323        engine: WorkspaceLexicalEngine,
324    ) -> WorkspaceIndexResult<Self> {
325        match engine {
326            WorkspaceLexicalEngine::Portable => {
327                PortableLexicalIndex::build(documents).map(Self::Portable)
328            }
329            WorkspaceLexicalEngine::ZvecRust => {
330                #[cfg(feature = "zvec-rust-fts")]
331                {
332                    super::zvec_rust::ZvecRustLexicalIndex::build(
333                        documents
334                            .iter()
335                            .map(|(key, text)| (key.as_str(), text.as_str())),
336                    )
337                    .map(Self::ZvecRust)
338                    .map_err(|error| {
339                        WorkspaceIndexError::InvalidConfig(format!(
340                            "zvec-rust lexical index failed: {error}"
341                        ))
342                    })
343                }
344                #[cfg(not(feature = "zvec-rust-fts"))]
345                {
346                    Err(WorkspaceIndexError::InvalidConfig(
347                        "WorkspaceLexicalEngine::ZvecRust requires the zvec-rust-fts feature"
348                            .to_owned(),
349                    ))
350                }
351            }
352        }
353    }
354
355    pub(crate) fn document_count(&self) -> usize {
356        match self {
357            Self::Portable(index) => index.document_count(),
358            #[cfg(feature = "zvec-rust-fts")]
359            Self::ZvecRust(index) => index.document_count(),
360        }
361    }
362
363    pub(crate) fn estimated_bytes(&self) -> usize {
364        match self {
365            Self::Portable(index) => index.estimated_bytes(),
366            #[cfg(feature = "zvec-rust-fts")]
367            Self::ZvecRust(index) => index.estimated_bytes(),
368        }
369    }
370
371    pub(crate) fn has_any_term(&self, terms: &[String]) -> bool {
372        match self {
373            Self::Portable(index) => index.has_any_term(terms),
374            #[cfg(feature = "zvec-rust-fts")]
375            Self::ZvecRust(index) => index.has_any_term(terms),
376        }
377    }
378
379    pub(crate) fn search(
380        &self,
381        terms: &[String],
382        limit: usize,
383    ) -> WorkspaceIndexResult<Vec<(usize, f64)>> {
384        match self {
385            Self::Portable(index) => index.search(terms, limit),
386            #[cfg(feature = "zvec-rust-fts")]
387            Self::ZvecRust(index) => index.search(terms, limit).map_err(|error| {
388                WorkspaceIndexError::InvalidQuery(format!("zvec-rust FTS search failed: {error}"))
389            }),
390        }
391    }
392}
393
394/// Build one bounded lexical index through the selected backend.
395///
396/// The query-time path and incremental workspace catalog intentionally call the
397/// same function, keeping backend selection in one place.
398pub(crate) fn build_lexical_index<I, K, T>(
399    documents: I,
400    engine: WorkspaceLexicalEngine,
401) -> WorkspaceIndexResult<LexicalIndex>
402where
403    I: IntoIterator<Item = (K, T)>,
404    K: AsRef<str>,
405    T: AsRef<str>,
406{
407    let documents = documents
408        .into_iter()
409        .map(|(key, text)| (key.as_ref().to_owned(), text.as_ref().to_owned()))
410        .collect::<Vec<_>>();
411    LexicalIndex::build(&documents, engine)
412}
413
414pub(crate) struct LexicalPartition {
415    index: LexicalIndex,
416    chunks: Arc<[Arc<WorkspaceChunk>]>,
417    pub(crate) document_count: usize,
418}
419
420impl LexicalPartition {
421    /// Build the catalog's lexical partition through the selected FTS API.
422    ///
423    /// Code still owns chunk admission and path policy, while tokenization,
424    /// postings, BM25 statistics, and deterministic score ordering are owned
425    /// by the selected engine. Workspace source remains authoritative and no
426    /// durable cross-session index is introduced by lexical search.
427    pub(crate) fn build(
428        chunks: Arc<[Arc<WorkspaceChunk>]>,
429        engine: WorkspaceLexicalEngine,
430    ) -> WorkspaceIndexResult<Self> {
431        let indexed_chunks: Arc<[Arc<WorkspaceChunk>]> = Arc::from(
432            chunks
433                .iter()
434                .filter(|chunk| !tokenize(chunk.text.as_ref()).is_empty())
435                .cloned()
436                .collect::<Vec<_>>(),
437        );
438        let index = build_lexical_index(
439            indexed_chunks
440                .iter()
441                .map(|chunk| (chunk.id.as_str(), chunk.text.as_ref())),
442            engine,
443        )?;
444        let document_count = index.document_count();
445        Ok(Self {
446            index,
447            chunks: indexed_chunks,
448            document_count,
449        })
450    }
451
452    fn has_any_term(&self, terms: &[String]) -> bool {
453        self.index.has_any_term(terms)
454    }
455
456    pub(crate) fn estimated_bytes(&self) -> usize {
457        self.index.estimated_bytes()
458    }
459
460    fn search(
461        &self,
462        terms: &[String],
463        limit: usize,
464    ) -> WorkspaceIndexResult<Vec<(Arc<WorkspaceChunk>, f64)>> {
465        if terms.is_empty() || limit == 0 {
466            return Ok(Vec::new());
467        }
468        self.index.search(terms, limit).map(|hits| {
469            hits.into_iter()
470                .filter_map(|(ordinal, score)| {
471                    self.chunks
472                        .get(ordinal)
473                        .cloned()
474                        .map(|chunk| (chunk, score))
475                })
476                .collect()
477        })
478    }
479}
480
481pub(crate) fn search_catalog(
482    snapshot: &ChunkCatalogSnapshot,
483    request: &LexicalSearchRequest,
484) -> Result<LexicalSearchResult, WorkspaceIndexError> {
485    validate_request(request)?;
486    let terms = query_terms(request.query.trim(), DEFAULT_QUERY_TERM_LIMIT);
487    if terms.is_empty() {
488        return Err(WorkspaceIndexError::InvalidQuery(
489            "query must contain a letter, number, underscore, or CJK character".to_owned(),
490        ));
491    }
492    let glob = request
493        .glob
494        .as_deref()
495        .map(glob::Pattern::new)
496        .transpose()
497        .map_err(|error| WorkspaceIndexError::InvalidQuery(error.to_string()))?;
498
499    let matching = snapshot
500        .state
501        .files
502        .iter()
503        .filter(|(path, file)| {
504            path_matches(path, &request.path, glob.as_ref()) && file.lexical.has_any_term(&terms)
505        })
506        .collect::<Vec<_>>();
507    let matching_files = matching.len();
508    let candidate_truncated = matching_files > request.max_candidate_files;
509    let selected = matching
510        .into_iter()
511        .take(request.max_candidate_files)
512        .collect::<Vec<_>>();
513    let selected_files = selected.len();
514    let document_count = selected
515        .iter()
516        .map(|(_, file)| file.lexical.document_count)
517        .sum::<usize>();
518    if document_count == 0 {
519        return Ok(LexicalSearchResult {
520            catalog_revision: snapshot.revision(),
521            source_revision: snapshot.source_revision(),
522            lexical_engine: snapshot.lexical_engine(),
523            query_terms: terms,
524            matching_files,
525            selected_files: selected.len(),
526            scored_chunks: 0,
527            candidate_truncated,
528            hits: Vec::new(),
529        });
530    }
531    let mut ranked = Vec::new();
532    // The final contract admits at most `max_results_per_file` hits from any
533    // one file. Asking each backend for more candidates only adds native FTS
534    // work (and result materialization) without changing the observable
535    // ordering.
536    let per_file_limit = request.limit.min(request.max_results_per_file);
537    for (_, file) in selected {
538        for (chunk, score) in file.lexical.search(&terms, per_file_limit)? {
539            if score.is_finite() && score > 0.0 {
540                ranked.push(LexicalSearchHit { chunk, score });
541            }
542        }
543    }
544    ranked.sort_by(|left, right| {
545        right
546            .score
547            .total_cmp(&left.score)
548            .then_with(|| left.chunk.path.cmp(&right.chunk.path))
549            .then_with(|| left.chunk.start_byte.cmp(&right.chunk.start_byte))
550            .then_with(|| left.chunk.id.cmp(&right.chunk.id))
551    });
552
553    let mut per_file = HashMap::<Arc<str>, usize>::new();
554    let hits = ranked
555        .into_iter()
556        .filter(|hit| {
557            let count = per_file.entry(Arc::clone(&hit.chunk.path)).or_default();
558            if *count >= request.max_results_per_file {
559                return false;
560            }
561            *count += 1;
562            true
563        })
564        .take(request.limit)
565        .collect();
566
567    Ok(LexicalSearchResult {
568        catalog_revision: snapshot.revision(),
569        source_revision: snapshot.source_revision(),
570        lexical_engine: snapshot.lexical_engine(),
571        query_terms: terms,
572        matching_files,
573        selected_files,
574        scored_chunks: document_count,
575        candidate_truncated,
576        hits,
577    })
578}
579
580pub(crate) fn validate_request(request: &LexicalSearchRequest) -> Result<(), WorkspaceIndexError> {
581    if request.query.trim().is_empty() {
582        return Err(WorkspaceIndexError::InvalidQuery(
583            "query must not be empty".to_owned(),
584        ));
585    }
586    if request.query.len() > MAX_QUERY_BYTES {
587        return Err(WorkspaceIndexError::InvalidQuery(format!(
588            "query exceeds the {MAX_QUERY_BYTES}-byte limit"
589        )));
590    }
591    if request.limit == 0 || request.limit > MAX_RESULT_LIMIT {
592        return Err(WorkspaceIndexError::InvalidQuery(format!(
593            "limit must be from 1 to {MAX_RESULT_LIMIT}"
594        )));
595    }
596    if request.max_candidate_files == 0 || request.max_results_per_file == 0 {
597        return Err(WorkspaceIndexError::InvalidQuery(
598            "candidate and per-file limits must be greater than zero".to_owned(),
599        ));
600    }
601    Ok(())
602}
603
604pub(crate) fn path_matches(path: &str, base: &WorkspacePath, glob: Option<&glob::Pattern>) -> bool {
605    let relative = if base.is_root() {
606        path
607    } else if path == base.as_str() {
608        path.rsplit('/').next().unwrap_or(path)
609    } else {
610        let Some(relative) = path
611            .strip_prefix(base.as_str())
612            .and_then(|path| path.strip_prefix('/'))
613        else {
614            return false;
615        };
616        relative
617    };
618    glob.is_none_or(|pattern| pattern.matches(relative) || pattern.matches(path))
619}
620
621pub(crate) fn query_terms(query: &str, limit: usize) -> Vec<String> {
622    let mut seen = HashSet::new();
623    tokenize(query)
624        .into_iter()
625        .filter(|term| seen.insert(term.clone()))
626        .take(limit)
627        .collect()
628}
629
630pub(crate) fn tokenize(text: &str) -> Vec<String> {
631    let mut tokens = Vec::new();
632    let mut word = String::new();
633    let mut previous_cjk = None;
634
635    for ch in text.chars() {
636        if is_cjk(ch) {
637            flush_word(&mut word, &mut tokens);
638            tokens.push(ch.to_string());
639            if let Some(previous) = previous_cjk {
640                tokens.push(format!("{previous}{ch}"));
641            }
642            previous_cjk = Some(ch);
643        } else {
644            previous_cjk = None;
645            if ch.is_alphanumeric() || ch == '_' {
646                word.push(ch);
647            } else {
648                flush_word(&mut word, &mut tokens);
649            }
650        }
651    }
652    flush_word(&mut word, &mut tokens);
653    tokens
654}
655
656fn flush_word(word: &mut String, tokens: &mut Vec<String>) {
657    if word.is_empty() {
658        return;
659    }
660    if !word.chars().any(char::is_alphanumeric) {
661        word.clear();
662        return;
663    }
664    let mut variants = vec![word.to_lowercase()];
665    for segment in word.split('_').filter(|segment| !segment.is_empty()) {
666        variants.push(segment.to_lowercase());
667        variants.extend(split_identifier(segment));
668    }
669    let mut seen = HashSet::new();
670    tokens.extend(
671        variants
672            .into_iter()
673            .filter(|variant| !variant.is_empty() && seen.insert(variant.clone())),
674    );
675    word.clear();
676}
677
678fn split_identifier(identifier: &str) -> Vec<String> {
679    let chars = identifier.chars().collect::<Vec<_>>();
680    if chars.is_empty() {
681        return Vec::new();
682    }
683    let mut parts = Vec::new();
684    let mut start = 0usize;
685    for index in 1..chars.len() {
686        let previous = chars[index - 1];
687        let current = chars[index];
688        let next = chars.get(index + 1).copied();
689        let at_case_boundary = previous.is_lowercase() && current.is_uppercase();
690        let at_acronym_boundary = previous.is_uppercase()
691            && current.is_uppercase()
692            && next.is_some_and(char::is_lowercase);
693        let at_numeric_boundary = previous.is_numeric() != current.is_numeric()
694            && previous.is_alphanumeric()
695            && current.is_alphanumeric();
696        if at_case_boundary || at_acronym_boundary || at_numeric_boundary {
697            parts.push(
698                chars[start..index]
699                    .iter()
700                    .collect::<String>()
701                    .to_lowercase(),
702            );
703            start = index;
704        }
705    }
706    parts.push(chars[start..].iter().collect::<String>().to_lowercase());
707    parts
708}
709
710fn is_cjk(ch: char) -> bool {
711    matches!(
712        ch as u32,
713        0x3400..=0x4dbf
714            | 0x4e00..=0x9fff
715            | 0xf900..=0xfaff
716            | 0x20000..=0x2fa1f
717            | 0x3040..=0x30ff
718            | 0xac00..=0xd7af
719    )
720}
721
722#[cfg(test)]
723mod tests {
724    use super::PortableLexicalIndex;
725
726    #[test]
727    fn large_portable_build_keeps_dense_ordinals_after_empty_documents() {
728        let documents = (0..256)
729            .map(|index| {
730                let text = if index % 13 == 0 {
731                    " \n\t".to_owned()
732                } else {
733                    format!(
734                        "{} {}",
735                        if index == 129 {
736                            "parallelneedle"
737                        } else {
738                            "common"
739                        },
740                        "workspace payload ".repeat(64)
741                    )
742                };
743                (format!("doc-{index:03}"), text)
744            })
745            .collect::<Vec<_>>();
746        let index = PortableLexicalIndex::build(&documents).expect("portable index");
747        let hits = index
748            .search(&["parallelneedle".to_owned()], 1)
749            .expect("portable query");
750        let expected_ordinal = (0..129).filter(|index| index % 13 != 0).count();
751        assert_eq!(hits.first().map(|hit| hit.0), Some(expected_ordinal));
752        assert_eq!(index.document_count(), 236);
753    }
754}