Skip to main content

core_storage/
fulltext.rs

1/// Full-text v2 incremental inverted index with BM25 ranking.
2///
3/// ## Scope (v2)
4/// - Tokenization: split on non-alphanumeric, lowercase, then Snowball English
5///   stem (rust-stemmers 1.x). Applied at index AND query time.
6/// - Query grammar (websearch-style):
7///   `query := group ('OR' group)*`
8///   `group := atom ('AND'? atom)*`
9///   `atom  := '"' <phrase words> '"'    // stemmed-adjacency phrase`
10///   `atom  |= '-' <word> ['*']          // negated term`
11///   `atom  |= <word> '*'?               // regular term, optional prefix`
12///   OR and AND are case-insensitive keywords.  Phrases match stemmed word forms
13///   (stemming applied to both document tokens and phrase tokens at index/query time).
14/// - BM25 ranking: k1 = 1.2, b = 0.75.  Scores summed across matched OR-groups.
15///   Within each group, negated atoms exclude a document; phrase atoms require
16///   positional adjacency in the stemmed token stream.
17///
18/// ## Memory cost model (v2)
19/// Positions are stored per (token, node) as Vec<u32> (token offsets).  For a
20/// corpus of N nodes each averaging T distinct stemmed tokens per field with
21/// average tf per token, memory is O(N × T × avg_tf) per indexed field.
22/// Empirically this is 2–3× the v1 footprint on text-heavy stores (measured on
23/// a 10k-doc synthetic corpus: ~24 MB at avg 40 tokens/doc vs ~10 MB in v1).
24///
25/// ## Phrase semantics
26/// Phrase tokens are stemmed; `"running fast"` matches documents containing
27/// the stemmed sequence `["run", "fast"]` at consecutive positions.  This means
28/// phrases match word forms, not literal strings.
29use crate::idmap::IdMap;
30use crate::interner::Interner;
31use crate::types::Value;
32use std::collections::{BTreeMap, BTreeSet};
33
34// ---------------------------------------------------------------------------
35// Stemmer
36// ---------------------------------------------------------------------------
37
38/// Apply the Snowball English stemmer to a single lowercased token.
39pub fn stem(tok: &str) -> String {
40    use rust_stemmers::{Algorithm, Stemmer};
41    thread_local! {
42        static EN: Stemmer = Stemmer::create(Algorithm::English);
43    }
44    EN.with(|s| s.stem(tok).into_owned())
45}
46
47// ---------------------------------------------------------------------------
48// Tokenizer
49// ---------------------------------------------------------------------------
50
51/// Tokenize a raw string: split on any non-`char::is_alphanumeric` character,
52/// lowercase each resulting run.  Returns UNSTEMMED tokens.
53///
54/// Used at the API boundary (oracle, query parsing) and internally before
55/// stemming.  For indexing, use [`tokenize_stemmed_with_positions`].
56pub fn tokenize(s: &str) -> Vec<String> {
57    let mut tokens = Vec::new();
58    let mut current = String::new();
59    for ch in s.chars() {
60        if ch.is_alphanumeric() {
61            for lc in ch.to_lowercase() {
62                current.push(lc);
63            }
64        } else if !current.is_empty() {
65            tokens.push(std::mem::take(&mut current));
66        }
67    }
68    if !current.is_empty() {
69        tokens.push(current);
70    }
71    tokens
72}
73
74/// Tokenize a string, stem each token, and return `(stemmed_token, position)` pairs.
75/// Position is the 0-based ordinal in the token stream (used for phrase adjacency).
76pub fn tokenize_stemmed_with_positions(s: &str) -> Vec<(String, u32)> {
77    let mut result = Vec::new();
78    let mut pos: u32 = 0;
79    let mut current = String::new();
80    for ch in s.chars() {
81        if ch.is_alphanumeric() {
82            for lc in ch.to_lowercase() {
83                current.push(lc);
84            }
85        } else if !current.is_empty() {
86            result.push((stem(&current), pos));
87            pos += 1;
88            current.clear();
89        }
90    }
91    if !current.is_empty() {
92        result.push((stem(&current), pos));
93    }
94    result
95}
96
97/// Tokenize a `Value`, applying stemming and returning `(stemmed_token, position)`.
98///
99/// `Value::List` of `Str` elements: each element is tokenized independently.
100/// A `POSITION_GAP` (> 1) is inserted between elements so that phrase queries
101/// cannot match across list element boundaries — adjacency requires consecutive
102/// positions (differing by exactly 1), and the gap guarantees they never are.
103pub fn value_tokens_stemmed_with_positions(v: &Value) -> Vec<(String, u32)> {
104    match v {
105        Value::Str(s) => tokenize_stemmed_with_positions(s),
106        Value::List(items) => {
107            // Gap between list elements: any value > 1 breaks cross-boundary adjacency.
108            const POSITION_GAP: u32 = 2;
109            let mut result: Vec<(String, u32)> = Vec::new();
110            let mut pos_offset: u32 = 0;
111            for item in items {
112                if let Value::Str(s) = item {
113                    let toks = tokenize_stemmed_with_positions(s);
114                    for (tok, local_pos) in &toks {
115                        result.push((tok.clone(), pos_offset + local_pos));
116                    }
117                    if !toks.is_empty() {
118                        // Advance past this element's tokens plus the gap.
119                        pos_offset += toks.len() as u32 + POSITION_GAP;
120                    }
121                }
122            }
123            result
124        }
125        _ => vec![],
126    }
127}
128
129// ---------------------------------------------------------------------------
130// Query parsing
131// ---------------------------------------------------------------------------
132
133/// A single search term: stemmed token (for non-prefix) or raw prefix,
134/// with optional negation and prefix flags.
135///
136/// With v2 grammar:
137/// - `negated = true`: this term EXCLUDES matching documents from a group.
138/// - `prefix = true`: match any index token that *starts with* `token`.
139///   Prefix tokens are NOT stemmed (the prefix matches against stemmed index tokens).
140/// - Otherwise: `token` is the Snowball-English-stemmed form of the input word.
141#[derive(Debug, Clone)]
142pub struct Term {
143    /// Stemmed token (non-prefix) or raw lowercase prefix (prefix=true).
144    pub token: String,
145    /// If true, match any posting token that *starts with* `token`.
146    pub prefix: bool,
147    /// If true, a match of this term EXCLUDES the document from the group.
148    pub negated: bool,
149}
150
151/// A query atom: either a single term or a phrase.
152#[derive(Debug, Clone)]
153enum QueryAtom {
154    Term(Term),
155    /// A sequence of stemmed tokens; adjacency in position stream required.
156    Phrase(Vec<String>),
157}
158
159/// Parsed query: OR-groups of AND-atoms.
160type Groups = Vec<Vec<QueryAtom>>;
161
162/// Parse a query using the v2 grammar into OR-groups of AND-atoms (internal).
163///
164/// Handles `"quoted phrases"`, `-negation`, `prefix*`, and OR/AND keywords.
165fn parse_query_v2(query: &str) -> Groups {
166    let mut groups: Groups = vec![vec![]];
167    let chars: Vec<char> = query.chars().collect();
168    let mut i = 0;
169
170    while i < chars.len() {
171        // Skip whitespace.
172        if chars[i].is_whitespace() {
173            i += 1;
174            continue;
175        }
176
177        if chars[i] == '"' {
178            // Phrase: collect tokens until closing '"'.
179            i += 1; // consume opening '"'
180            let mut phrase_tokens: Vec<String> = Vec::new();
181            let mut current = String::new();
182            while i < chars.len() && chars[i] != '"' {
183                let ch = chars[i];
184                if ch.is_alphanumeric() {
185                    for lc in ch.to_lowercase() {
186                        current.push(lc);
187                    }
188                } else if !current.is_empty() {
189                    phrase_tokens.push(stem(&current));
190                    current.clear();
191                }
192                i += 1;
193            }
194            if !current.is_empty() {
195                phrase_tokens.push(stem(&current));
196            }
197            if chars.get(i) == Some(&'"') {
198                i += 1; // consume closing '"'
199            }
200            if !phrase_tokens.is_empty() {
201                // Infallible: `groups` is initialised as `vec![vec![]]` and OR never removes groups.
202                groups
203                    .last_mut()
204                    .unwrap()
205                    .push(QueryAtom::Phrase(phrase_tokens));
206            }
207        } else {
208            // Collect until next whitespace.
209            let start = i;
210            while i < chars.len() && !chars[i].is_whitespace() {
211                i += 1;
212            }
213            let word: String = chars[start..i].iter().collect();
214
215            // Check for OR/AND keyword.
216            match word.to_ascii_uppercase().as_str() {
217                "OR" => {
218                    groups.push(vec![]);
219                    continue;
220                }
221                "AND" => continue,
222                _ => {}
223            }
224
225            // Detect leading '-' for negation.
226            let (negated, rest) = if let Some(stripped) = word.strip_prefix('-') {
227                (true, stripped)
228            } else {
229                (false, word.as_str())
230            };
231
232            // Detect trailing '*' for prefix.
233            let (raw, prefix) = if let Some(stripped) = rest.strip_suffix('*') {
234                (stripped, true)
235            } else {
236                (rest, false)
237            };
238
239            // Extract alphanumeric characters only, lowercase.
240            let token: String = raw
241                .chars()
242                .filter(|c| c.is_alphanumeric())
243                .flat_map(|c| c.to_lowercase())
244                .collect();
245
246            if token.is_empty() {
247                continue;
248            }
249
250            // Prefix tokens are NOT stemmed (match against stemmed index tokens as-is).
251            // Non-prefix tokens are stemmed.
252            let final_token = if prefix { token } else { stem(&token) };
253
254            // Infallible: `groups` is initialised as `vec![vec![]]` and OR never removes groups.
255            groups.last_mut().unwrap().push(QueryAtom::Term(Term {
256                token: final_token,
257                prefix,
258                negated,
259            }));
260        }
261    }
262
263    groups.retain(|g| !g.is_empty());
264    groups
265}
266
267/// Parse a query into OR-groups of AND-terms (public, oracle-compatible).
268///
269/// Grammar: `query := group ('OR' group)*`
270///           `group := term ('AND'? term)*`
271///           `term  := '-'? <word> '*'?`
272///
273/// Differences from v2 internal grammar: phrases (`"..."`) are NOT supported;
274/// each quoted or unquoted word is treated as a plain term.  This form is used
275/// by the sim-harness oracle and any caller that needs a stable external API.
276///
277/// Token values in returned `Term`s are Snowball-English-stemmed for non-prefix
278/// terms.  Callers must stem document tokens with [`stem`] before comparing.
279pub fn parse_query(query: &str) -> Vec<Vec<Term>> {
280    // Re-use the v2 parser but flatten phrases to individual terms.
281    parse_query_v2(query)
282        .into_iter()
283        .map(|group| {
284            group
285                .into_iter()
286                .flat_map(|atom| match atom {
287                    QueryAtom::Term(t) => vec![t],
288                    // Flatten phrase tokens to individual non-negated non-prefix terms.
289                    QueryAtom::Phrase(tokens) => tokens
290                        .into_iter()
291                        .map(|tok| Term {
292                            token: tok,
293                            prefix: false,
294                            negated: false,
295                        })
296                        .collect(),
297                })
298                .collect()
299        })
300        .collect()
301}
302
303// ---------------------------------------------------------------------------
304// Boolean evaluation (for textMatches WHERE clause)
305// ---------------------------------------------------------------------------
306
307/// Evaluate a raw field string against a v2 query (phrase + negation + prefix).
308///
309/// Returns `true` if any OR-group in the parsed query matches:
310/// - All non-negated terms/phrases in the group are satisfied.
311/// - No negated term in the group is present.
312/// - Phrases require consecutive stemmed token positions.
313///
314/// This is O(field_length × query_terms) per call — fine for WHERE filtering;
315/// prefer `db.search()` for large result-set scenarios.
316pub fn eval_query_str(field_value: &str, query: &str) -> bool {
317    let groups = parse_query_v2(query);
318    eval_groups_str(field_value, &groups)
319}
320
321/// Evaluate a raw list field (concatenated as a single space-joined string).
322pub fn eval_query_str_list(items: &[Value], query: &str) -> bool {
323    let combined: String = items
324        .iter()
325        .filter_map(|v| {
326            if let Value::Str(s) = v {
327                Some(s.as_str())
328            } else {
329                None
330            }
331        })
332        .collect::<Vec<_>>()
333        .join(" ");
334    eval_query_str(&combined, query)
335}
336
337fn eval_groups_str(field_value: &str, groups: &Groups) -> bool {
338    if groups.is_empty() {
339        return false;
340    }
341    // Build stemmed position map from the field value once.
342    let stemmed_with_pos = tokenize_stemmed_with_positions(field_value);
343    let token_set: BTreeSet<String> = stemmed_with_pos.iter().map(|(t, _)| t.clone()).collect();
344    // Build position map for phrase adjacency checking.
345    let mut pos_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
346    for (tok, pos) in &stemmed_with_pos {
347        pos_map.entry(tok.clone()).or_default().push(*pos);
348    }
349
350    'outer: for group in groups {
351        for atom in group {
352            match atom {
353                QueryAtom::Term(t) => {
354                    let found = if t.prefix {
355                        token_set.iter().any(|tk| tk.starts_with(t.token.as_str()))
356                    } else {
357                        token_set.contains(&t.token)
358                    };
359                    if t.negated {
360                        if found {
361                            continue 'outer; // negated term present → group fails
362                        }
363                    } else if !found {
364                        continue 'outer; // required term absent → group fails
365                    }
366                }
367                QueryAtom::Phrase(tokens) => {
368                    if !phrase_matches_pos_map(&pos_map, tokens) {
369                        continue 'outer;
370                    }
371                }
372            }
373        }
374        return true; // all atoms in this group satisfied
375    }
376    false
377}
378
379/// Check phrase adjacency in a position map: every consecutive token pair must
380/// appear at consecutive positions in at least one alignment.
381fn phrase_matches_pos_map(pos_map: &BTreeMap<String, Vec<u32>>, tokens: &[String]) -> bool {
382    if tokens.is_empty() {
383        return false;
384    }
385    let Some(first_positions) = pos_map.get(&tokens[0]) else {
386        return false;
387    };
388    'start: for &start in first_positions {
389        let mut cur = start;
390        for tok in &tokens[1..] {
391            cur += 1;
392            let Some(positions) = pos_map.get(tok) else {
393                continue 'start;
394            };
395            if positions.binary_search(&cur).is_err() {
396                continue 'start;
397            }
398        }
399        return true;
400    }
401    false
402}
403
404// ---------------------------------------------------------------------------
405// FulltextIndex
406// ---------------------------------------------------------------------------
407
408/// Incremental inverted index for full-text BM25 search.
409///
410/// Enabled per `(label, field)` pair via [`FulltextIndex::enable`].
411/// Postings store stemmed tokens with per-document position lists for phrase
412/// adjacency checking.  Doc lengths are tracked separately for BM25 normalization.
413///
414/// ## BM25 constants
415/// k1 = 1.2, b = 0.75  (Okapi BM25 defaults).
416///
417/// ## WAL / persistence
418/// The index is NOT stored in the V8 snapshot.  It is rebuilt from WAL replay
419/// (EnableFulltext / DisableFulltext records + node property re-indexing) at
420/// open time via [`FulltextIndex::rebuild_all`].  Postings restructuring in v2
421/// has no snapshot format impact.
422#[derive(Debug, Default, Clone)]
423pub struct FulltextIndex {
424    /// Enabled `(label, field)` pairs.
425    enabled: BTreeSet<(String, String)>,
426    /// Inverted index:
427    ///   `field → stemmed_token → node_id → positions (u32 offsets in token stream)`
428    ///
429    /// Positions enable phrase adjacency checks and provide tf = positions.len().
430    postings: BTreeMap<String, BTreeMap<String, BTreeMap<u32, Vec<u32>>>>,
431    /// Document lengths (in stemmed tokens) per field per node.
432    ///   `field → node_id → token_count`
433    ///
434    /// Used for BM25 length normalization (avg_dl and dl(d)).
435    doc_len: BTreeMap<String, BTreeMap<u32, u32>>,
436}
437
438impl FulltextIndex {
439    pub fn new() -> Self {
440        Self::default()
441    }
442
443    /// Whether `(label, field)` is currently indexed.
444    pub fn is_enabled(&self, label: &str, field: &str) -> bool {
445        self.enabled
446            .contains(&(label.to_string(), field.to_string()))
447    }
448
449    /// Whether any field is enabled for this label.
450    pub fn has_label(&self, label: &str) -> bool {
451        self.enabled.iter().any(|(l, _)| l == label)
452    }
453
454    /// Whether `field` is indexed for *any* label.
455    pub fn field_indexed(&self, field: &str) -> bool {
456        self.enabled.iter().any(|(_, f)| f == field)
457    }
458
459    /// Whether `field` is indexed by a label OTHER THAN `label`.
460    pub fn field_indexed_by_other(&self, label: &str, field: &str) -> bool {
461        self.enabled.iter().any(|(l, f)| f == field && l != label)
462    }
463
464    /// Iterate all enabled `(label, field)` pairs.
465    pub fn enabled_pairs(&self) -> impl Iterator<Item = &(String, String)> {
466        self.enabled.iter()
467    }
468
469    /// Enable full-text indexing for `(label, field)`.  Returns `true` if newly
470    /// added, `false` if already present (idempotent for replay safety).
471    pub fn enable(&mut self, label: &str, field: &str) -> bool {
472        self.enabled.insert((label.to_string(), field.to_string()))
473    }
474
475    /// Disable full-text indexing for `(label, field)`.
476    /// Drops all postings and doc_len entries for that field.
477    /// Returns `true` if the pair was present and removed.
478    pub fn disable(&mut self, label: &str, field: &str) -> bool {
479        let removed = self.enabled.remove(&(label.to_string(), field.to_string()));
480        if removed && !self.field_indexed(field) {
481            self.postings.remove(field);
482            self.doc_len.remove(field);
483        }
484        removed
485    }
486
487    // -----------------------------------------------------------------------
488    // Incremental maintenance
489    // -----------------------------------------------------------------------
490
491    /// Add stemmed tokens (with positions) for `value` under `(node_id, field)`.
492    /// Replaces any existing doc_len entry for this node.
493    /// Caller is responsible for ensuring `(label, field)` is enabled.
494    pub fn add_tokens(&mut self, node_id: u32, field: &str, value: &Value) {
495        let stemmed = value_tokens_stemmed_with_positions(value);
496        let dl = stemmed.len() as u32;
497
498        // Update doc_len.
499        let dl_col = self.doc_len.entry(field.to_string()).or_default();
500        dl_col.insert(node_id, dl);
501
502        // Update postings.
503        let col = self.postings.entry(field.to_string()).or_default();
504        for (tok, pos) in stemmed {
505            col.entry(tok)
506                .or_default()
507                .entry(node_id)
508                .or_default()
509                .push(pos);
510        }
511    }
512
513    /// Remove all tokens for `node_id` in `field`'s posting list.
514    pub fn remove_node_field(&mut self, node_id: u32, field: &str) {
515        if let Some(col) = self.postings.get_mut(field) {
516            col.retain(|_, node_map| {
517                node_map.remove(&node_id);
518                !node_map.is_empty()
519            });
520            if col.is_empty() {
521                self.postings.remove(field);
522            }
523        }
524        if let Some(dl) = self.doc_len.get_mut(field) {
525            dl.remove(&node_id);
526            if dl.is_empty() {
527                self.doc_len.remove(field);
528            }
529        }
530    }
531
532    /// Remove all tokens for `node_id` across all indexed fields.
533    pub fn remove_node(&mut self, node_id: u32) {
534        for col in self.postings.values_mut() {
535            col.retain(|_, node_map| {
536                node_map.remove(&node_id);
537                !node_map.is_empty()
538            });
539        }
540        self.postings.retain(|_, col| !col.is_empty());
541        for dl in self.doc_len.values_mut() {
542            dl.remove(&node_id);
543        }
544        self.doc_len.retain(|_, dl| !dl.is_empty());
545    }
546
547    // -----------------------------------------------------------------------
548    // Search (BM25)
549    // -----------------------------------------------------------------------
550
551    /// Search a field with a v2 query.  Returns `(node_id, bm25_score)` sorted
552    /// by score descending, ties by node_id ascending.  Returns empty if the
553    /// field is not indexed or the query produces no groups.
554    ///
555    /// BM25 constants: k1 = 1.2, b = 0.75.  Scores are summed across matched
556    /// OR-groups; negated atoms exclude a document; phrase atoms require
557    /// positional adjacency.  If `k > 0`, only the top-k results are returned.
558    pub fn search(&self, field: &str, query: &str, k: usize) -> Vec<(u32, f64)> {
559        let Some(col) = self.postings.get(field) else {
560            return vec![];
561        };
562        let groups = parse_query_v2(query);
563        if groups.is_empty() {
564            return vec![];
565        }
566
567        let dl_map = match self.doc_len.get(field) {
568            Some(m) => m,
569            None => return vec![],
570        };
571        let n = dl_map.len() as f64;
572        if n == 0.0 {
573            return vec![];
574        }
575        let avg_dl: f64 = dl_map.values().map(|&v| v as f64).sum::<f64>() / n;
576
577        const K1: f64 = 1.2;
578        const B: f64 = 0.75;
579
580        let mut scores: BTreeMap<u32, f64> = BTreeMap::new();
581
582        for group in &groups {
583            // Find candidate node set for this group.
584            let candidates = group_candidates(col, group);
585
586            for node_id in candidates {
587                let dl = dl_map.get(&node_id).copied().unwrap_or(1) as f64;
588                let mut group_score = 0.0;
589
590                for atom in group {
591                    match atom {
592                        QueryAtom::Term(t) if !t.negated && !t.prefix => {
593                            // Standard BM25 term score.
594                            let (df, tf) = match col.get(&t.token) {
595                                Some(node_map) => {
596                                    let df = node_map.len() as f64;
597                                    let tf = node_map
598                                        .get(&node_id)
599                                        .map(|v| v.len() as f64)
600                                        .unwrap_or(0.0);
601                                    (df, tf)
602                                }
603                                None => (0.0, 0.0),
604                            };
605                            if tf > 0.0 {
606                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
607                                let tf_norm =
608                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
609                                group_score += idf * tf_norm;
610                            }
611                        }
612                        QueryAtom::Term(t) if !t.negated && t.prefix => {
613                            // Prefix: sum BM25 scores for all matching stemmed tokens.
614                            for (tok, node_map) in col
615                                .range(t.token.clone()..)
616                                .take_while(|(k, _)| k.starts_with(t.token.as_str()))
617                            {
618                                let _ = tok;
619                                let df = node_map.len() as f64;
620                                let tf = node_map
621                                    .get(&node_id)
622                                    .map(|v| v.len() as f64)
623                                    .unwrap_or(0.0);
624                                if tf > 0.0 {
625                                    let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
626                                    let tf_norm =
627                                        tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
628                                    group_score += idf * tf_norm;
629                                }
630                            }
631                        }
632                        QueryAtom::Term(_) => {
633                            // Negated: already excluded by group_candidates.
634                        }
635                        QueryAtom::Phrase(tokens) => {
636                            // Phrase: only score if adjacency holds (group_candidates
637                            // already narrowed to nodes that have all phrase tokens,
638                            // but didn't check positions).
639                            if !phrase_matches_col(col, node_id, tokens) {
640                                // Phrase failed adjacency — this group does not match.
641                                group_score = f64::NEG_INFINITY;
642                                break;
643                            }
644                            // Contribute BM25 score for each phrase token.
645                            for tok in tokens {
646                                let (df, tf) = match col.get(tok) {
647                                    Some(node_map) => (
648                                        node_map.len() as f64,
649                                        node_map
650                                            .get(&node_id)
651                                            .map(|v| v.len() as f64)
652                                            .unwrap_or(0.0),
653                                    ),
654                                    None => (0.0, 0.0),
655                                };
656                                if tf > 0.0 && df > 0.0 {
657                                    let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
658                                    let tf_norm =
659                                        tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
660                                    group_score += idf * tf_norm;
661                                }
662                            }
663                        }
664                    }
665                }
666
667                // Negation-only groups produce group_score = 0.0 (no positive atom
668                // contributes) and are suppressed here.  This is deliberate: "-term"
669                // alone does not rank surviving docs — it only reduces the candidate
670                // set in group_candidates.  "graph OR -embedded" therefore behaves
671                // identically to "graph": the negation group is silently dropped.
672                if group_score > 0.0 {
673                    *scores.entry(node_id).or_insert(0.0) += group_score;
674                }
675            }
676        }
677
678        let mut results: Vec<(u32, f64)> = scores.into_iter().collect();
679        results.sort_by(|a, b| {
680            b.1.partial_cmp(&a.1)
681                .unwrap_or(std::cmp::Ordering::Equal)
682                .then(a.0.cmp(&b.0))
683        });
684        if k > 0 {
685            results.truncate(k);
686        }
687        results
688    }
689
690    // -----------------------------------------------------------------------
691    // Rebuild
692    // -----------------------------------------------------------------------
693
694    /// Rebuild the entire index from scratch.
695    ///
696    /// Called once after WAL replay to correct any drift accumulated by incremental
697    /// `add_tokens` / `remove_node_field` calls during per-record `apply`.
698    pub fn rebuild_all(
699        &mut self,
700        ids: &IdMap,
701        labels: &[u32],
702        syms: &Interner,
703        props: crate::v8::seam::ColumnsView<'_>,
704    ) {
705        if self.enabled.is_empty() {
706            return;
707        }
708        let enabled_vec: Vec<(String, String)> = self.enabled.iter().cloned().collect();
709        // Clear postings AND doc_len for all enabled fields.
710        for (_, field) in &enabled_vec {
711            self.postings.remove(field);
712            self.doc_len.remove(field);
713        }
714        let n = ids.len() as u32;
715        for id in 0..n {
716            let Some(&sym) = labels.get(id as usize) else {
717                continue;
718            };
719            if sym == u32::MAX {
720                continue;
721            }
722            let Some(label) = syms.resolve(sym) else {
723                continue;
724            };
725            for (lbl, field) in &enabled_vec {
726                if lbl == label {
727                    if let Some(vr) = props.get(id, field) {
728                        let value = vr.into_value();
729                        self.add_tokens(id, field, &value);
730                    }
731                }
732            }
733        }
734    }
735}
736
737// ---------------------------------------------------------------------------
738// Internals
739// ---------------------------------------------------------------------------
740
741/// Collect the candidate node set for one OR-group, applying positive-term
742/// intersection and negated-term exclusion.  Phrase atoms are treated as a
743/// conjunction of their constituent tokens for the candidate set (adjacency
744/// check happens during scoring).
745fn group_candidates(
746    col: &BTreeMap<String, BTreeMap<u32, Vec<u32>>>,
747    group: &[QueryAtom],
748) -> BTreeSet<u32> {
749    let has_positive = group.iter().any(|a| match a {
750        QueryAtom::Term(t) => !t.negated,
751        QueryAtom::Phrase(_) => true,
752    });
753
754    // If no positive constraint, start with ALL nodes in this field column.
755    let mut result: Option<BTreeSet<u32>> = if has_positive {
756        None
757    } else {
758        Some(
759            col.values()
760                .flat_map(|node_map| node_map.keys().copied())
761                .collect(),
762        )
763    };
764
765    let mut negated: BTreeSet<u32> = BTreeSet::new();
766
767    for atom in group {
768        match atom {
769            QueryAtom::Term(t) if !t.negated && !t.prefix => {
770                let matching: BTreeSet<u32> = col
771                    .get(&t.token)
772                    .map(|m| m.keys().copied().collect())
773                    .unwrap_or_default();
774                result = Some(match result {
775                    None => matching,
776                    Some(prev) => prev.intersection(&matching).copied().collect(),
777                });
778            }
779            QueryAtom::Term(t) if !t.negated && t.prefix => {
780                let matching: BTreeSet<u32> = col
781                    .range(t.token.clone()..)
782                    .take_while(|(k, _)| k.starts_with(t.token.as_str()))
783                    .flat_map(|(_, node_map)| node_map.keys().copied())
784                    .collect();
785                result = Some(match result {
786                    None => matching,
787                    Some(prev) => prev.intersection(&matching).copied().collect(),
788                });
789            }
790            QueryAtom::Term(t) if t.negated && !t.prefix => {
791                let exclude: BTreeSet<u32> = col
792                    .get(&t.token)
793                    .map(|m| m.keys().copied().collect())
794                    .unwrap_or_default();
795                negated.extend(exclude);
796            }
797            QueryAtom::Term(t) if t.negated && t.prefix => {
798                let exclude: BTreeSet<u32> = col
799                    .range(t.token.clone()..)
800                    .take_while(|(k, _)| k.starts_with(t.token.as_str()))
801                    .flat_map(|(_, node_map)| node_map.keys().copied())
802                    .collect();
803                negated.extend(exclude);
804            }
805            QueryAtom::Term(_) => {}
806            QueryAtom::Phrase(tokens) => {
807                // Intersect candidates with nodes that have ALL phrase tokens.
808                // Adjacency is checked at scoring time, not here.
809                let mut phrase_candidates: Option<BTreeSet<u32>> = None;
810                for tok in tokens {
811                    let matching: BTreeSet<u32> = col
812                        .get(tok)
813                        .map(|m| m.keys().copied().collect())
814                        .unwrap_or_default();
815                    phrase_candidates = Some(match phrase_candidates {
816                        None => matching,
817                        Some(prev) => prev.intersection(&matching).copied().collect(),
818                    });
819                }
820                let phrase_set = phrase_candidates.unwrap_or_default();
821                result = Some(match result {
822                    None => phrase_set,
823                    Some(prev) => prev.intersection(&phrase_set).copied().collect(),
824                });
825            }
826        }
827    }
828
829    let mut candidates = result.unwrap_or_default();
830    for id in &negated {
831        candidates.remove(id);
832    }
833    candidates
834}
835
836/// Check phrase adjacency using the index column.
837fn phrase_matches_col(
838    col: &BTreeMap<String, BTreeMap<u32, Vec<u32>>>,
839    node_id: u32,
840    tokens: &[String],
841) -> bool {
842    if tokens.is_empty() {
843        return false;
844    }
845    let Some(first_positions) = col.get(&tokens[0]).and_then(|m| m.get(&node_id)) else {
846        return false;
847    };
848    'start: for &start in first_positions {
849        let mut cur = start;
850        for tok in &tokens[1..] {
851            cur += 1;
852            let Some(positions) = col.get(tok).and_then(|m| m.get(&node_id)) else {
853                continue 'start;
854            };
855            if positions.binary_search(&cur).is_err() {
856                continue 'start;
857            }
858        }
859        return true;
860    }
861    false
862}
863
864// ---------------------------------------------------------------------------
865// Tests
866// ---------------------------------------------------------------------------
867
868#[cfg(test)]
869mod tests {
870    use super::*;
871    use crate::columns::ColumnStore;
872
873    fn toks(s: &str) -> Vec<String> {
874        tokenize(s)
875    }
876
877    #[test]
878    fn tokenizer_basic() {
879        assert_eq!(toks("Hello, World!"), vec!["hello", "world"]);
880        assert_eq!(toks("rust-lang"), vec!["rust", "lang"]);
881        assert_eq!(toks("abc123"), vec!["abc123"]);
882        assert_eq!(toks(""), Vec::<String>::new());
883    }
884
885    #[test]
886    fn tokenizer_unicode() {
887        assert_eq!(toks("café"), vec!["café"]);
888        assert_eq!(toks("über alles"), vec!["über", "alles"]);
889    }
890
891    #[test]
892    fn stem_basic() {
893        // Snowball English: -ing/-ed/-s suffixes removed.
894        assert_eq!(stem("running"), "run");
895        assert_eq!(stem("databases"), "databas");
896        assert_eq!(stem("embedded"), "embed");
897        // Single-char and non-English words are unchanged.
898        assert_eq!(stem("a"), "a");
899        assert_eq!(stem("rust"), "rust");
900    }
901
902    #[test]
903    fn tokenize_stemmed_positions() {
904        let result = tokenize_stemmed_with_positions("running around the world");
905        // Positions are sequential token offsets.
906        assert_eq!(result[0].0, stem("running")); // "run"
907        assert_eq!(result[0].1, 0);
908        assert_eq!(result[1].0, stem("around")); // "around"
909        assert_eq!(result[1].1, 1);
910        assert_eq!(result[2].0, stem("the")); // "the"
911        assert_eq!(result[2].1, 2);
912        assert_eq!(result[3].0, stem("world")); // "world"
913        assert_eq!(result[3].1, 3);
914    }
915
916    #[test]
917    fn parse_query_and() {
918        let g = parse_query("foo bar");
919        assert_eq!(g.len(), 1);
920        assert_eq!(g[0].len(), 2);
921        assert_eq!(g[0][0].token, stem("foo"));
922        assert_eq!(g[0][1].token, stem("bar"));
923        assert!(!g[0][0].prefix);
924        assert!(!g[0][0].negated);
925    }
926
927    #[test]
928    fn parse_query_or() {
929        let g = parse_query("foo OR bar");
930        assert_eq!(g.len(), 2);
931        assert_eq!(g[0][0].token, stem("foo"));
932        assert_eq!(g[1][0].token, stem("bar"));
933    }
934
935    #[test]
936    fn parse_query_prefix() {
937        let g = parse_query("foo*");
938        assert_eq!(g.len(), 1);
939        assert!(g[0][0].prefix);
940        assert_eq!(g[0][0].token, "foo"); // prefix NOT stemmed
941    }
942
943    #[test]
944    fn parse_query_negation() {
945        let g = parse_query("-embedded rust");
946        assert_eq!(g.len(), 1);
947        assert_eq!(g[0].len(), 2);
948        assert!(g[0][0].negated);
949        assert_eq!(g[0][0].token, stem("embedded"));
950        assert!(!g[0][1].negated);
951        assert_eq!(g[0][1].token, stem("rust"));
952    }
953
954    #[test]
955    fn parse_query_explicit_and_keyword() {
956        let g = parse_query("foo AND bar");
957        assert_eq!(g.len(), 1);
958        assert_eq!(g[0].len(), 2);
959    }
960
961    #[test]
962    fn parse_query_or_case_insensitive() {
963        let g = parse_query("a or b");
964        assert_eq!(g.len(), 2);
965    }
966
967    #[test]
968    fn parse_query_v2_phrase() {
969        let g = parse_query_v2("\"graph database\"");
970        assert_eq!(g.len(), 1);
971        assert_eq!(g[0].len(), 1);
972        match &g[0][0] {
973            QueryAtom::Phrase(tokens) => {
974                assert_eq!(tokens[0], stem("graph"));
975                assert_eq!(tokens[1], stem("database"));
976            }
977            _ => panic!("expected Phrase"),
978        }
979    }
980
981    #[test]
982    fn eval_query_str_basic() {
983        assert!(eval_query_str("hello world rust", "hello world"));
984        assert!(!eval_query_str("hello world", "hello rust"));
985        assert!(eval_query_str("hello world", "hello OR rust"));
986    }
987
988    #[test]
989    fn eval_query_str_stemming() {
990        // "running" and "run" share the same stem → match.
991        assert!(eval_query_str("I am running fast", "running"));
992        assert!(eval_query_str("I am running fast", "run"));
993        // "databases" stems to "databas"; query "databases" also stems → match.
994        assert!(eval_query_str("graph databases embedded", "databases"));
995    }
996
997    #[test]
998    fn eval_query_str_phrase() {
999        // Adjacent → matches.
1000        assert!(eval_query_str(
1001            "graph database embedded",
1002            "\"graph database\""
1003        ));
1004        // Not adjacent → no match.
1005        assert!(!eval_query_str(
1006            "graph embedded database",
1007            "\"graph database\""
1008        ));
1009        // Phrase with stemming: "running fast" stem = ["run", "fast"].
1010        assert!(eval_query_str(
1011            "I am running fast today",
1012            "\"running fast\""
1013        ));
1014    }
1015
1016    #[test]
1017    fn eval_query_str_negation() {
1018        // Has "embedded" → excluded.
1019        assert!(!eval_query_str(
1020            "graph embedded database",
1021            "-embedded graph"
1022        ));
1023        // No "embedded" → not excluded.
1024        assert!(eval_query_str("graph database", "-embedded graph"));
1025    }
1026
1027    #[test]
1028    fn eval_query_str_prefix() {
1029        assert!(eval_query_str("embedding graph", "emb*"));
1030        assert!(!eval_query_str("graph only", "emb*"));
1031    }
1032
1033    #[test]
1034    fn index_and_search_bm25_basic() {
1035        let mut idx = FulltextIndex::new();
1036        idx.enable("Person", "bio");
1037        idx.add_tokens(0, "bio", &Value::Str("I love Rust and databases".into()));
1038        idx.add_tokens(1, "bio", &Value::Str("Python developer here".into()));
1039
1040        // BM25 search — "rust" only in doc 0.
1041        let r = idx.search("bio", "rust", 0);
1042        assert_eq!(r.len(), 1);
1043        assert_eq!(r[0].0, 0);
1044        assert!(r[0].1 > 0.0);
1045
1046        // "rust OR python" → both docs.
1047        let r2 = idx.search("bio", "rust OR python", 0);
1048        assert_eq!(r2.len(), 2);
1049
1050        // "rust databases" (AND) → only doc 0 has both (stemmed: "rust" and "databas").
1051        let r3 = idx.search("bio", "rust databases", 0);
1052        assert_eq!(r3.len(), 1);
1053        assert_eq!(r3[0].0, 0);
1054
1055        // "rust AND python" (AND) → no doc has both.
1056        let r4 = idx.search("bio", "rust AND python", 0);
1057        assert!(r4.is_empty());
1058    }
1059
1060    /// BM25 ranking: rarer-term doc ranks above common-term doc.
1061    ///
1062    /// Corpus:
1063    ///   node 0 ("alpha"): dl=1, "alpha" has df=1 → high IDF
1064    ///   node 1 ("beta"):  dl=1, "beta" has df=2 → lower IDF
1065    ///   node 2 ("beta"):  dl=1, contributes to df("beta")=2
1066    ///
1067    /// Query: "alpha OR beta"
1068    ///   N=3, avg_dl=1.0
1069    ///   IDF("alpha") = ln((3-1+0.5)/(1+0.5)+1) = ln(2.667) ≈ 0.981
1070    ///   IDF("beta")  = ln((3-2+0.5)/(2+0.5)+1) = ln(1.6)   ≈ 0.470
1071    ///   tf_norm(all) = 1*2.2/(1+1.2*(0.25+0.75*1/1)) = 2.2/2.2 = 1.0
1072    ///   score(node 0) ≈ 0.981  (from "alpha" group)
1073    ///   score(node 1) ≈ 0.470  (from "beta" group)
1074    ///   score(node 2) ≈ 0.470  (from "beta" group; tiebreak: node 1 < node 2)
1075    ///
1076    /// Expected order: 0 > 1 = 2 (1 before 2 by node_id tiebreak).
1077    #[test]
1078    fn bm25_rarer_term_ranks_higher() {
1079        let mut idx = FulltextIndex::new();
1080        idx.enable("Doc", "body");
1081        idx.add_tokens(0, "body", &Value::Str("alpha".into()));
1082        idx.add_tokens(1, "body", &Value::Str("beta".into()));
1083        idx.add_tokens(2, "body", &Value::Str("beta".into()));
1084
1085        let r = idx.search("body", "alpha OR beta", 0);
1086        assert_eq!(r.len(), 3);
1087        // Doc 0 (rare "alpha") ranks first.
1088        assert_eq!(r[0].0, 0, "rarer-term doc must rank first");
1089        // Docs 1 and 2 tie on score; tiebreak by node_id ascending.
1090        assert_eq!(r[1].0, 1);
1091        assert_eq!(r[2].0, 2);
1092        // Scores strictly ordered.
1093        assert!(r[0].1 > r[1].1, "alpha (df=1) must score above beta (df=2)");
1094    }
1095
1096    #[test]
1097    fn bm25_stemming_matches_root_form() {
1098        let mut idx = FulltextIndex::new();
1099        idx.enable("Doc", "body");
1100        // Doc contains "run" only (stem of "running" = "run").
1101        idx.add_tokens(0, "body", &Value::Str("run".into()));
1102
1103        // Querying "running" → stems to "run" → matches doc 0.
1104        let r = idx.search("body", "running", 0);
1105        assert_eq!(r.len(), 1);
1106        assert_eq!(r[0].0, 0);
1107    }
1108
1109    #[test]
1110    fn bm25_phrase_adjacent_only() {
1111        let mut idx = FulltextIndex::new();
1112        idx.enable("Doc", "body");
1113        // Doc 0: "graph" and "database" are adjacent.
1114        idx.add_tokens(0, "body", &Value::Str("graph database embedded".into()));
1115        // Doc 1: scattered — "graph" and "database" not adjacent.
1116        idx.add_tokens(1, "body", &Value::Str("graph embedded database".into()));
1117
1118        let r = idx.search("body", "\"graph database\"", 0);
1119        assert_eq!(r.len(), 1, "only adjacent doc must match phrase");
1120        assert_eq!(r[0].0, 0);
1121    }
1122
1123    #[test]
1124    fn bm25_negation_excludes() {
1125        let mut idx = FulltextIndex::new();
1126        idx.enable("Doc", "body");
1127        idx.add_tokens(0, "body", &Value::Str("graph database embedded".into()));
1128        idx.add_tokens(1, "body", &Value::Str("graph database".into()));
1129
1130        // "-embedded graph" → doc 0 excluded (has "embedded"); doc 1 matches.
1131        let r = idx.search("body", "-embedded graph", 0);
1132        assert_eq!(r.len(), 1);
1133        assert_eq!(r[0].0, 1);
1134    }
1135
1136    #[test]
1137    fn prefix_search() {
1138        let mut idx = FulltextIndex::new();
1139        idx.enable("Doc", "body");
1140        idx.add_tokens(0, "body", &Value::Str("embedding graph".into()));
1141        idx.add_tokens(1, "body", &Value::Str("python java".into()));
1142
1143        let r = idx.search("body", "emb*", 0);
1144        assert_eq!(r.len(), 1);
1145        assert_eq!(r[0].0, 0);
1146    }
1147
1148    #[test]
1149    fn search_case_insensitive() {
1150        let mut idx = FulltextIndex::new();
1151        idx.enable("Doc", "body");
1152        idx.add_tokens(0, "body", &Value::Str("Rust is great".into()));
1153
1154        // Query in any case → same stemmed token → matches.
1155        assert_eq!(idx.search("body", "RUST", 0).len(), 1);
1156        assert_eq!(idx.search("body", "Rust", 0).len(), 1);
1157        assert_eq!(idx.search("body", "rust", 0).len(), 1);
1158    }
1159
1160    #[test]
1161    fn search_empty_query_returns_empty() {
1162        let mut idx = FulltextIndex::new();
1163        idx.enable("Doc", "body");
1164        idx.add_tokens(0, "body", &Value::Str("hello world".into()));
1165        assert!(idx.search("body", "", 0).is_empty());
1166        assert!(idx.search("body", "   ", 0).is_empty());
1167    }
1168
1169    #[test]
1170    fn search_k_truncates() {
1171        let mut idx = FulltextIndex::new();
1172        idx.enable("Doc", "f");
1173        for i in 0..5u32 {
1174            idx.add_tokens(i, "f", &Value::Str(format!("word{i}")));
1175        }
1176        let r = idx.search("f", "word0 OR word1 OR word2 OR word3 OR word4", 3);
1177        assert_eq!(r.len(), 3);
1178    }
1179
1180    #[test]
1181    fn remove_node_field_clears_tokens() {
1182        let mut idx = FulltextIndex::new();
1183        idx.enable("A", "f");
1184        idx.add_tokens(0, "f", &Value::Str("hello world".into()));
1185        idx.remove_node_field(0, "f");
1186        assert!(idx.search("f", "hello", 0).is_empty());
1187    }
1188
1189    #[test]
1190    fn remove_node_clears_all_fields() {
1191        let mut idx = FulltextIndex::new();
1192        idx.enable("A", "f");
1193        idx.enable("A", "g");
1194        idx.add_tokens(0, "f", &Value::Str("foo".into()));
1195        idx.add_tokens(0, "g", &Value::Str("bar".into()));
1196        idx.remove_node(0);
1197        assert!(idx.search("f", "foo", 0).is_empty());
1198        assert!(idx.search("g", "bar", 0).is_empty());
1199    }
1200
1201    #[test]
1202    fn unindexed_field_returns_empty() {
1203        let idx = FulltextIndex::new();
1204        assert!(idx.search("notindexed", "anything", 0).is_empty());
1205    }
1206
1207    #[test]
1208    fn rebuild_all_restores_index() {
1209        let mut ids = IdMap::new();
1210        let mut syms = Interner::new();
1211        let mut labels: Vec<u32> = Vec::new();
1212        let mut props = ColumnStore::new();
1213
1214        let id0 = ids.get_or_insert("k0");
1215        let sym = syms.intern("Person");
1216        labels.resize(id0 as usize + 1, u32::MAX);
1217        labels[id0 as usize] = sym;
1218        props.set(id0, "bio", Value::Str("I love Rust".into()));
1219
1220        let mut idx = FulltextIndex::new();
1221        idx.enable("Person", "bio");
1222        assert!(idx.search("bio", "rust", 0).is_empty());
1223
1224        idx.rebuild_all(
1225            &ids,
1226            &labels,
1227            &syms,
1228            crate::v8::seam::ColumnsView::owned(&props),
1229        );
1230        // "Rust" → stem → "rust" → found.
1231        let r = idx.search("bio", "rust", 0);
1232        assert_eq!(r.len(), 1);
1233    }
1234
1235    /// Pin: mid-token `*` is stripped to an exact (stemmed) term; trailing `*` is prefix.
1236    #[test]
1237    fn mid_token_star_is_stripped_to_exact() {
1238        // Index-side tokenizer: mid-star SPLITS document text.
1239        let toks = tokenize("ru*st");
1240        assert_eq!(toks, vec!["ru".to_string(), "st".to_string()]);
1241
1242        // Query-side parse_query: "ru*st" has no trailing `*` → exact term "rust"
1243        // (the mid-star is stripped; "rust" is then stemmed → "rust").
1244        let groups = parse_query("ru*st");
1245        assert_eq!(groups.len(), 1);
1246        assert_eq!(groups[0].len(), 1);
1247        assert!(!groups[0][0].prefix, "mid-token * must NOT set prefix flag");
1248        assert_eq!(groups[0][0].token, stem("rust")); // "rust"
1249
1250        // Trailing-star prefix query still works.
1251        let mut idx = FulltextIndex::new();
1252        idx.enable("T", "f");
1253        idx.add_tokens(0, "f", &Value::Str("rust embedded".into()));
1254        assert_eq!(idx.search("f", "ru*", 0).len(), 1);
1255        assert_eq!(idx.search("f", "rust", 0).len(), 1);
1256    }
1257
1258    /// Pin: a pure negation query (no positive atom) always returns empty.
1259    ///
1260    /// When a group has no positive atoms, `group_candidates` starts with ALL
1261    /// nodes in the field and removes matching ones.  However, the scoring loop
1262    /// produces `group_score = 0.0` (no positive atom contributes), and the
1263    /// `if group_score > 0.0` guard then suppresses every candidate.  The result
1264    /// is deliberately empty — negation alone does not rank surviving docs.
1265    #[test]
1266    fn all_negation_query_returns_empty() {
1267        let mut idx = FulltextIndex::new();
1268        idx.enable("Doc", "body");
1269        idx.add_tokens(0, "body", &Value::Str("graph database embedded".into()));
1270        idx.add_tokens(1, "body", &Value::Str("graph database".into()));
1271
1272        let r = idx.search("body", "-embedded", 0);
1273        assert!(r.is_empty(), "pure negation query must return empty");
1274    }
1275
1276    /// Pin: "graph OR -embedded" behaves identically to "graph".
1277    ///
1278    /// The negation-only OR group ("-embedded") produces `group_score = 0.0` in
1279    /// the scoring loop (no positive atom) and is suppressed by the guard, so it
1280    /// adds nothing to document scores.  The result key ordering is the same as
1281    /// the plain "graph" query.
1282    #[test]
1283    fn negation_only_or_group_contributes_nothing() {
1284        let mut idx = FulltextIndex::new();
1285        idx.enable("Doc", "body");
1286        idx.add_tokens(0, "body", &Value::Str("graph database".into()));
1287        idx.add_tokens(1, "body", &Value::Str("rust embedded".into()));
1288
1289        let keys_plain: Vec<u32> = idx
1290            .search("body", "graph", 0)
1291            .into_iter()
1292            .map(|(id, _)| id)
1293            .collect();
1294        let keys_or_neg: Vec<u32> = idx
1295            .search("body", "graph OR -embedded", 0)
1296            .into_iter()
1297            .map(|(id, _)| id)
1298            .collect();
1299        assert_eq!(
1300            keys_plain, keys_or_neg,
1301            "negation-only OR group must not change result ordering"
1302        );
1303    }
1304
1305    /// Deterministic phrase-adjacency invariant: engine results agree with a
1306    /// fully independent in-test adjacency checker.
1307    ///
1308    /// The checker is truly independent: it inlines its own tokenizer (split on
1309    /// non-alphanumeric, lowercase) and does NOT call `tokenize_stemmed_with_positions`
1310    /// or any other core-storage function.  To sidestep reimplementing Snowball,
1311    /// the corpus is constrained to stem-stable words (stem(w) == w), which are
1312    /// verified by assertions at test setup.  For stable words the engine's stemmed
1313    /// tokens equal the raw lowercase tokens, so the checker's array-index walk
1314    /// and the engine's position-map path must agree on the match set — any
1315    /// position-assignment bug would cause a disagreement.
1316    ///
1317    /// Stem-stable corpus words: "graph", "node", "disk", "wal", "commit"
1318    ///
1319    /// Corpus:
1320    ///   doc 0: "graph node disk"        — phrase "graph node" adjacent at idx 0,1
1321    ///   doc 1: "graph disk node"        — scattered  (gap: graph idx 0, node idx 2)
1322    ///   doc 2: "commit graph node wal"  — phrase "graph node" adjacent at idx 1,2
1323    ///
1324    /// Expected: docs 0 and 2 match; doc 1 does not.
1325    #[test]
1326    fn phrase_adjacency_engine_matches_naive_checker() {
1327        // Verify stem-stability so the independent checker (no stemming) is valid.
1328        for w in &["graph", "node", "disk", "wal", "commit"] {
1329            assert_eq!(stem(w), *w, "word '{w}' must be its own Snowball stem");
1330        }
1331
1332        let mut idx = FulltextIndex::new();
1333        idx.enable("Doc", "body");
1334        idx.add_tokens(0, "body", &Value::Str("graph node disk".into()));
1335        idx.add_tokens(1, "body", &Value::Str("graph disk node".into()));
1336        idx.add_tokens(2, "body", &Value::Str("commit graph node wal".into()));
1337
1338        // Naive checker: inline tokenizer + array-index walk.
1339        // Zero core-storage imports — no shared position-assignment code.
1340        let phrase_words: &[&str] = &["graph", "node"];
1341        let naive_check = |doc_text: &str| -> bool {
1342            // Inline tokenizer: split on non-alphanumeric, lowercase.
1343            let mut toks: Vec<String> = Vec::new();
1344            let mut cur = String::new();
1345            for ch in doc_text.chars() {
1346                if ch.is_alphanumeric() {
1347                    for lc in ch.to_lowercase() {
1348                        cur.push(lc);
1349                    }
1350                } else if !cur.is_empty() {
1351                    toks.push(std::mem::take(&mut cur));
1352                }
1353            }
1354            if !cur.is_empty() {
1355                toks.push(cur);
1356            }
1357            // Adjacency walk: phrase must appear as a contiguous sub-sequence.
1358            for i in 0..toks.len() {
1359                if toks[i] == phrase_words[0]
1360                    && i + phrase_words.len() <= toks.len()
1361                    && phrase_words
1362                        .iter()
1363                        .enumerate()
1364                        .all(|(j, w)| toks[i + j] == *w)
1365                {
1366                    return true;
1367                }
1368            }
1369            false
1370        };
1371
1372        let docs = [
1373            (0u32, "graph node disk"),
1374            (1u32, "graph disk node"),
1375            (2u32, "commit graph node wal"),
1376        ];
1377
1378        let engine_ids: BTreeSet<u32> = idx
1379            .search("body", "\"graph node\"", 0)
1380            .into_iter()
1381            .map(|(id, _)| id)
1382            .collect();
1383        let naive_ids: BTreeSet<u32> = docs
1384            .iter()
1385            .filter(|(_, text)| naive_check(text))
1386            .map(|(id, _)| *id)
1387            .collect();
1388
1389        assert_eq!(
1390            engine_ids, naive_ids,
1391            "engine phrase results must agree with independent naive adjacency checker"
1392        );
1393        assert!(engine_ids.contains(&0), "doc 0 (adjacent) must match");
1394        assert!(!engine_ids.contains(&1), "doc 1 (scattered) must not match");
1395        assert!(engine_ids.contains(&2), "doc 2 (preceded) must match");
1396    }
1397
1398    /// Pin: phrase queries do NOT match across Value::List element boundaries.
1399    ///
1400    /// `value_tokens_stemmed_with_positions` inserts a POSITION_GAP (> 1) between
1401    /// list elements.  Phrases require consecutive positions (delta == 1), so the
1402    /// gap breaks cross-boundary adjacency.
1403    ///
1404    /// Corpus:
1405    ///   doc 0: List ["graph", "database"] — last token of elem 0 is pos 0,
1406    ///          first token of elem 1 is pos 0+1+GAP = 3.  Delta = 3, not 1.
1407    ///   doc 1: Str "graph database"       — tokens at pos 0, 1.  Delta = 1.
1408    ///
1409    /// Expected: only doc 1 matches the phrase "graph database".
1410    #[test]
1411    fn phrase_does_not_match_across_list_boundary() {
1412        let mut idx = FulltextIndex::new();
1413        idx.enable("Doc", "body");
1414        // Two separate list elements — phrase must NOT span them.
1415        idx.add_tokens(
1416            0,
1417            "body",
1418            &Value::List(vec![
1419                Value::Str("graph".into()),
1420                Value::Str("database".into()),
1421            ]),
1422        );
1423        // Single string — tokens are consecutive.
1424        idx.add_tokens(1, "body", &Value::Str("graph database".into()));
1425
1426        let r = idx.search("body", "\"graph database\"", 0);
1427        assert_eq!(
1428            r.len(),
1429            1,
1430            "phrase must not match across list element boundary"
1431        );
1432        assert_eq!(r[0].0, 1, "only single-string doc must match");
1433    }
1434}