Skip to main content

wm_memory/
search.rs

1//! Search engine — Tantivy full-text search.
2//!
3//! Provides BM25-scored full-text search over memory content.
4//! Index is stored alongside the LMDB store in a separate directory.
5//!
6//! Recall-quality hygiene (see `docs/TANTIVY_RECALL_QUALITY_FIX.md`):
7//! - Queries are stripped of common stopwords before parsing.
8//! - Terms are only quoted when they contain reserved query syntax
9//!   (plain terms — including hyphenated compounds — pass through so the
10//!   tokenizer can split them into phrase matches).
11//! - Content is sanitized at index time (binary/garbage content is skipped).
12//! - Results are filtered by optional absolute and/or relative score floors,
13//!   and output content is scrubbed of control characters.
14
15use crate::MemoryId;
16use serde::{Deserialize, Serialize};
17use wm_core::{CoreError, Galaxy, Result};
18
19use std::path::Path;
20use std::sync::Mutex;
21use std::sync::atomic::{AtomicU64, Ordering};
22use tantivy::{
23    Index, IndexReader, IndexWriter, ReloadPolicy,
24    collector::TopDocs,
25    doc,
26    query::QueryParser,
27    schema::{
28        Field, STORED, STRING, Schema, TantivyDocument, TextFieldIndexing, TextOptions, Value,
29    },
30};
31
32/// Maximum content length (in chars) indexed into Tantivy.
33pub const MAX_INDEX_CONTENT_LEN: usize = 8 * 1024;
34
35/// Minimum printable-char ratio for content to be indexed (0.9 = max 10% garbage).
36pub const MIN_PRINTABLE_RATIO: f32 = 0.9;
37
38/// Common English stopwords stripped from queries before parsing.
39///
40/// Mirrors the client-side stopword list (Antigravity `wmMemory.ts`) so the
41/// server and client agree on which tokens are meaningless for recall.
42pub const STOPWORDS: &[&str] = &[
43    "a",
44    "about",
45    "after",
46    "again",
47    "all",
48    "also",
49    "am",
50    "an",
51    "and",
52    "any",
53    "are",
54    "as",
55    "at",
56    "be",
57    "been",
58    "being",
59    "before",
60    "between",
61    "both",
62    "but",
63    "by",
64    "can",
65    "could",
66    "did",
67    "do",
68    "does",
69    "during",
70    "each",
71    "few",
72    "for",
73    "from",
74    "further",
75    "had",
76    "has",
77    "have",
78    "he",
79    "her",
80    "here",
81    "hers",
82    "herself",
83    "him",
84    "himself",
85    "his",
86    "how",
87    "i",
88    "if",
89    "in",
90    "into",
91    "is",
92    "it",
93    "its",
94    "itself",
95    "just",
96    "me",
97    "might",
98    "more",
99    "most",
100    "my",
101    "myself",
102    "no",
103    "nor",
104    "not",
105    "of",
106    "off",
107    "on",
108    "once",
109    "only",
110    "or",
111    "other",
112    "our",
113    "ours",
114    "ourselves",
115    "out",
116    "over",
117    "own",
118    "same",
119    "shall",
120    "she",
121    "should",
122    "so",
123    "some",
124    "such",
125    "than",
126    "that",
127    "the",
128    "their",
129    "theirs",
130    "them",
131    "themselves",
132    "then",
133    "there",
134    "these",
135    "they",
136    "this",
137    "those",
138    "through",
139    "to",
140    "too",
141    "under",
142    "until",
143    "up",
144    "us",
145    "very",
146    "was",
147    "we",
148    "were",
149    "what",
150    "when",
151    "where",
152    "which",
153    "while",
154    "who",
155    "whom",
156    "why",
157    "will",
158    "with",
159    "would",
160    "you",
161    "your",
162    "yours",
163    "yourself",
164    "yourselves",
165];
166
167/// Search options controlling recall behavior.
168#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
169pub struct SearchOptions {
170    /// Maximum number of results.
171    pub limit: usize,
172    /// Optional galaxy filter (matches the stored galaxy string).
173    pub galaxy: Option<Galaxy>,
174    /// Absolute BM25 score floor; hits scoring below are dropped.
175    pub min_score: Option<f32>,
176    /// Relative floor: hits scoring below `top_score * ratio` are dropped
177    /// (e.g. `0.05` keeps only hits within 5% of the top result).
178    pub relative_floor: Option<f32>,
179    /// Use OR semantics instead of conjunction (deprecated — OR is now the
180    /// default; this flag is kept for API compatibility but does not change
181    /// behavior).
182    pub relaxed: bool,
183}
184
185impl Default for SearchOptions {
186    fn default() -> Self {
187        Self {
188            limit: 20,
189            galaxy: None,
190            min_score: None,
191            relative_floor: None,
192            relaxed: false,
193        }
194    }
195}
196
197/// Search result item.
198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
199pub struct SearchResult {
200    /// Memory UUID (as string)
201    pub memory_id: String,
202    /// Galaxy name
203    pub galaxy: String,
204    /// Raw BM25 score
205    pub score: f32,
206    /// Score relative to the top hit (1.0 = top result, 0.0 = no results)
207    pub normalized_score: f32,
208    /// Content snippet (control characters scrubbed)
209    pub content: String,
210}
211
212/// Tracked health of the Tantivy index relative to LMDB.
213///
214/// Because Tantivy indexing is best-effort (an indexing failure does not
215/// roll back the LMDB write), the index can drift from the store. This
216/// struct tracks successes and failures so `wm doctor` and `system.health`
217/// can report degraded state instead of silently claiming healthy.
218#[derive(Debug, Default)]
219pub struct IndexHealth {
220    /// Successful index/deindex operations since startup.
221    pub successes: AtomicU64,
222    /// Failed index/deindex operations since startup.
223    pub failures: AtomicU64,
224    /// Last error message (empty string if none).
225    last_error: Mutex<String>,
226}
227
228impl IndexHealth {
229    fn record_success(&self) {
230        self.successes.fetch_add(1, Ordering::Relaxed);
231    }
232
233    fn record_failure(&self, err: &str) {
234        self.failures.fetch_add(1, Ordering::Relaxed);
235        if let Ok(mut guard) = self.last_error.lock() {
236            *guard = err.to_string();
237        }
238    }
239
240    /// Snapshot the health as a JSON value for tool output.
241    #[must_use]
242    pub fn snapshot(&self) -> serde_json::Value {
243        let successes = self.successes.load(Ordering::Relaxed);
244        let failures = self.failures.load(Ordering::Relaxed);
245        let last_error = self
246            .last_error
247            .lock()
248            .map(|g| g.clone())
249            .unwrap_or_default();
250        let degraded = failures > 0;
251        serde_json::json!({
252            "successes": successes,
253            "failures": failures,
254            "degraded": degraded,
255            "last_error": if last_error.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(last_error) },
256        })
257    }
258}
259
260/// Format the Tantivy writer-creation error with actionable lock context
261/// (backlog B1: the bare `Lockfile: LockBusy` message named neither the
262/// index path nor the likely holder, costing a debug session to isolate).
263fn format_writer_lock_error(err: &str, index_path: &Path) -> String {
264    let is_lock = err.contains("ock") && (err.contains("Busy") || err.contains("lock"));
265    if is_lock {
266        format!(
267            "Tantivy writer: {err} — the search index at {} is locked by another process. \
268             A running `wm serve` or `wm daemon` on this store holds it; find it with \
269             `pgrep -af wm` and stop it, or start this server with --readonly.",
270            index_path.display()
271        )
272    } else {
273        format!("Tantivy writer: {err}")
274    }
275}
276
277/// The full-text search engine backed by Tantivy.
278pub struct SearchEngine {
279    index: Index,
280    reader: IndexReader,
281    writer: Mutex<Option<IndexWriter>>,
282    field_id: Field,
283    field_galaxy: Field,
284    field_content: Field,
285    field_tags: Field,
286    field_timestamp: Field,
287    /// Tracked index health — failures are recorded so callers can detect
288    /// degraded state instead of silently reporting healthy.
289    health: IndexHealth,
290    /// True when the on-disk index had an incompatible schema and was
291    /// replaced with a fresh empty index at open time. The old index was
292    /// moved aside (`.schema-mismatch.<timestamp>` sibling); callers that
293    /// own the canonical LMDB store should rebuild via
294    /// [`crate::reindex::rebuild_index`].
295    schema_migrated: bool,
296}
297
298impl SearchEngine {
299    /// Build the Tantivy schema for memory indexing.
300    fn build_schema() -> (Schema, Field, Field, Field, Field, Field) {
301        let mut schema_builder = Schema::builder();
302        let field_id = schema_builder.add_text_field("memory_id", STRING | STORED);
303        let field_galaxy = schema_builder.add_text_field("galaxy", STRING | STORED);
304        // Use en_stem tokenizer for content and tags so that morphological
305        // variants match (e.g. "graduate" ↔ "graduated", "degree" ↔ "degrees").
306        let stem_indexing = TextFieldIndexing::default()
307            .set_tokenizer("en_stem")
308            .set_index_option(tantivy::schema::IndexRecordOption::WithFreqsAndPositions);
309        let stem_text = TextOptions::default()
310            .set_indexing_options(stem_indexing.clone())
311            .set_stored();
312        let stem_tags = TextOptions::default().set_indexing_options(stem_indexing);
313        let field_content = schema_builder.add_text_field("content", stem_text);
314        let field_tags = schema_builder.add_text_field("tags", stem_tags);
315        let field_timestamp = schema_builder.add_i64_field("timestamp", STORED);
316        let schema = schema_builder.build();
317        (
318            schema,
319            field_id,
320            field_galaxy,
321            field_content,
322            field_tags,
323            field_timestamp,
324        )
325    }
326
327    /// Open (or create) the Tantivy index at `path`, migrating an
328    /// incompatible schema when `writable`.
329    ///
330    /// The Tantivy index is a derived index over the canonical LMDB store.
331    /// When the on-disk index was written by an older version with an
332    /// incompatible schema, a writable open moves the old directory aside
333    /// (`<name>.schema-mismatch.<millis>` sibling) and creates a fresh empty
334    /// index — the caller should rebuild it from LMDB via
335    /// [`crate::reindex::rebuild_index`]. A read-only open refuses to migrate
336    /// and returns an error directing the user to `wm reindex`.
337    fn open_index(path: &Path, schema: &Schema, writable: bool) -> Result<(Index, bool)> {
338        let directory = tantivy::directory::MmapDirectory::open(path)
339            .map_err(|e| CoreError::Memory(format!("Tantivy open directory: {e}")))?;
340        if !writable {
341            // `open_or_create` initializes an index in an existing but empty
342            // directory. A preservation open must only accept an already
343            // materialized index, never create its metadata or segments.
344            let index = Index::open(directory).map_err(|e| {
345                CoreError::Memory(format!(
346                    "Tantivy readonly open-existing at {}: {e}",
347                    path.display()
348                ))
349            })?;
350            if index.schema() != *schema {
351                return Err(CoreError::Memory(format!(
352                    "Tantivy index at {} was created with an incompatible schema by an \
353                     older version. Run 'wm reindex' (or start 'wm serve' without \
354                     --readonly) to migrate and rebuild it from the canonical store.",
355                    path.display()
356                )));
357            }
358            return Ok((index, false));
359        }
360        match Index::open_or_create(directory, schema.clone()) {
361            Ok(index) => Ok((index, false)),
362            Err(tantivy::error::TantivyError::SchemaError(_)) => {
363                let ts = std::time::SystemTime::now()
364                    .duration_since(std::time::UNIX_EPOCH)
365                    .map_or(0, |d| d.as_millis());
366                let file_name = path
367                    .file_name()
368                    .and_then(|n| n.to_str())
369                    .unwrap_or("tantivy");
370                let backup = path.with_file_name(format!("{file_name}.schema-mismatch.{ts}"));
371                std::fs::rename(path, &backup).map_err(|e| {
372                    CoreError::Memory(format!(
373                        "Tantivy schema migration — rename old index to {}: {e}",
374                        backup.display()
375                    ))
376                })?;
377                std::fs::create_dir_all(path).map_err(|e| {
378                    CoreError::Memory(format!("Tantivy schema migration — create index dir: {e}"))
379                })?;
380                tracing::warn!(
381                    "Tantivy index schema mismatch — old index moved to {}; creating a fresh \
382                     index (rebuild from LMDB will follow)",
383                    backup.display()
384                );
385                let directory = tantivy::directory::MmapDirectory::open(path)
386                    .map_err(|e| CoreError::Memory(format!("Tantivy open directory: {e}")))?;
387                let index = Index::open_or_create(directory, schema.clone())
388                    .map_err(|e| CoreError::Memory(format!("Tantivy open_or_create: {e}")))?;
389                Ok((index, true))
390            }
391            Err(e) => Err(CoreError::Memory(format!("Tantivy open_or_create: {e}"))),
392        }
393    }
394
395    /// Create or open a search engine index at the given path.
396    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
397        let path = path.as_ref();
398        let (schema, field_id, field_galaxy, field_content, field_tags, field_timestamp) =
399            Self::build_schema();
400
401        let (index, schema_migrated) = Self::open_index(path, &schema, true)?;
402
403        let reader = index
404            .reader_builder()
405            .reload_policy(ReloadPolicy::OnCommitWithDelay)
406            .try_into()
407            .map_err(|e| CoreError::Memory(format!("Tantivy reader: {e}")))?;
408
409        let writer = index
410            .writer(50_000_000)
411            .map_err(|e| CoreError::Memory(format_writer_lock_error(&e.to_string(), path)))?;
412
413        Ok(Self {
414            index,
415            reader,
416            writer: Mutex::new(Some(writer)),
417            field_id,
418            field_galaxy,
419            field_content,
420            field_tags,
421            field_timestamp,
422            health: IndexHealth::default(),
423            schema_migrated,
424        })
425    }
426
427    /// Open the index in read-only mode: no writer is created, so no
428    /// exclusive tantivy lock is taken. Multiple processes (e.g. Antigravity's
429    /// proxy and an opencode MCP client) can share the store for searches;
430    /// writes through this engine fail with a clear error.
431    pub fn open_readonly(path: impl AsRef<Path>) -> Result<Self> {
432        let path = path.as_ref();
433        // Backlog B2: a read-only open never observes later writes — searches
434        // miss fresh memories until restart. Say so at the moment it matters.
435        tracing::warn!(
436            "read-only search index opened at {} — it will not observe writes made \
437             after this point; restart the read-only server to pick up new memories",
438            path.display()
439        );
440        let (schema, field_id, field_galaxy, field_content, field_tags, field_timestamp) =
441            Self::build_schema();
442        let (index, schema_migrated) = Self::open_index(path, &schema, false)?;
443        let reader = index
444            .reader_builder()
445            .reload_policy(ReloadPolicy::OnCommitWithDelay)
446            .try_into()
447            .map_err(|e| CoreError::Memory(format!("Tantivy reader: {e}")))?;
448        Ok(Self {
449            index,
450            reader,
451            writer: Mutex::new(None),
452            field_id,
453            field_galaxy,
454            field_content,
455            field_tags,
456            field_timestamp,
457            health: IndexHealth::default(),
458            schema_migrated,
459        })
460    }
461
462    /// True when the on-disk index had an incompatible schema and was
463    /// replaced with a fresh empty index at open time. The old index was
464    /// preserved as a `.schema-mismatch.<timestamp>` sibling directory.
465    /// Callers that own the canonical LMDB store should rebuild via
466    /// `reindex::rebuild_index` when this returns true.
467    #[must_use]
468    pub const fn schema_migrated(&self) -> bool {
469        self.schema_migrated
470    }
471
472    /// Returns a snapshot of index health (success/failure counts, degraded
473    /// flag, last error).
474    #[must_use]
475    pub const fn health(&self) -> &IndexHealth {
476        &self.health
477    }
478
479    /// Count the number of indexed documents for a specific galaxy.
480    ///
481    /// Used by consistency checks to compare Tantivy doc counts against
482    /// LMDB memory counts. Returns 0 if the index is empty or the galaxy
483    /// has no documents.
484    pub fn count_docs_in_galaxy(&self, galaxy: &str) -> Result<usize> {
485        // Deterministic view: the OnCommitWithDelay background reloader can
486        // lag a just-finished commit; drift classification must never run
487        // against a stale reader (it would under-count and skip the heal).
488        self.reader
489            .reload()
490            .map_err(|e| CoreError::Memory(format!("Tantivy reader reload: {e}")))?;
491        let searcher = self.reader.searcher();
492        let term = tantivy::Term::from_field_text(self.field_galaxy, galaxy);
493        let query = tantivy::query::TermQuery::new(term, tantivy::schema::IndexRecordOption::Basic);
494        let count = searcher
495            .search(&query, &tantivy::collector::Count)
496            .map_err(|e| CoreError::Memory(format!("Tantivy count_docs: {e}")))?;
497        Ok(count)
498    }
499
500    /// Enumerate the memory IDs currently indexed for one galaxy.
501    ///
502    /// Used by the incremental drift heal to diff the index against LMDB
503    /// without rebuilding whole galaxies. Bounded by the galaxy's own
504    /// document count (a term query on the non-tokenized `galaxy` field, so
505    /// the cost is one term seek + one stored-field fetch per hit).
506    pub fn indexed_ids_in_galaxy(&self, galaxy: &str) -> Result<std::collections::HashSet<String>> {
507        // Deterministic view: the OnCommitWithDelay background reloader can
508        // lag a just-finished commit, and the drift heal must never diff
509        // against a stale reader (it would re-index what it already did).
510        self.reader
511            .reload()
512            .map_err(|e| CoreError::Memory(format!("Tantivy reader reload: {e}")))?;
513        let searcher = self.reader.searcher();
514        let term = tantivy::Term::from_field_text(self.field_galaxy, galaxy);
515        let query = tantivy::query::TermQuery::new(term, tantivy::schema::IndexRecordOption::Basic);
516        let count = self.count_docs_in_galaxy(galaxy)?;
517        let hits: std::collections::HashSet<tantivy::DocAddress> = searcher
518            .search(&query, &tantivy::collector::DocSetCollector)
519            .map_err(|e| CoreError::Memory(format!("Tantivy indexed_ids: {e}")))?;
520        let mut out = std::collections::HashSet::with_capacity(count);
521        for addr in hits {
522            let doc: TantivyDocument = searcher
523                .doc(addr)
524                .map_err(|e| CoreError::Memory(format!("Tantivy get doc: {e}")))?;
525            if let Some(id) = doc.get_first(self.field_id).and_then(|v| v.as_str()) {
526                out.insert(id.to_string());
527            }
528        }
529        Ok(out)
530    }
531
532    /// True when the engine was opened read-only (no tantivy writer).
533    pub fn is_readonly(&self) -> bool {
534        self.writer.lock().map_or(true, |g| g.is_none())
535    }
536
537    /// Lock the shared writer for adding/removing documents.
538    ///
539    /// The writer is created at `open()` time and shared across all callers
540    /// via a `Mutex`, preventing lock contention with Tantivy's single-writer model.
541    /// In read-only mode this errors.
542    pub fn writer(&self) -> Result<std::sync::MutexGuard<'_, Option<IndexWriter>>> {
543        let guard = self
544            .writer
545            .lock()
546            .map_err(|_| CoreError::Memory("Tantivy writer mutex poisoned".into()))?;
547        if guard.is_none() {
548            return Err(CoreError::Memory(
549                "Tantivy writer unavailable: index opened read-only".into(),
550            ));
551        }
552        Ok(guard)
553    }
554
555    /// Index a memory document.
556    ///
557    /// Content that is not clean text (binary garbage, low printable-char
558    /// ratio, null bytes) is **skipped** at index time — no document is added
559    /// and `Ok(())` is returned so callers can proceed. See
560    /// [`sanitize_content_for_index`].
561    pub fn add_document(
562        &self,
563        writer: &mut Option<IndexWriter>,
564        memory_id: &str,
565        galaxy: &str,
566        content: &str,
567        tags: &[String],
568        timestamp: i64,
569    ) -> Result<()> {
570        let writer = writer.as_mut().ok_or_else(|| {
571            CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
572        })?;
573        let Some(clean_content) = sanitize_content_for_index(content) else {
574            tracing::debug!("Skipping index of memory {memory_id}: content failed sanitization");
575            return Ok(());
576        };
577        let tags_str = tags.join(" ");
578        let doc = doc!(
579            self.field_id => memory_id,
580            self.field_galaxy => galaxy,
581            self.field_content => clean_content,
582            self.field_tags => tags_str,
583            self.field_timestamp => timestamp,
584        );
585        match writer.add_document(doc) {
586            Ok(_) => {
587                self.health.record_success();
588                Ok(())
589            }
590            Err(e) => {
591                let msg = format!("Tantivy add_document: {e}");
592                self.health.record_failure(&msg);
593                Err(CoreError::Memory(msg))
594            }
595        }
596    }
597
598    /// Index a complete `Memory` entry.
599    pub fn index_memory(
600        &self,
601        writer: &mut Option<IndexWriter>,
602        mem: &crate::memory::Memory,
603    ) -> Result<()> {
604        self.add_document(
605            writer,
606            &mem.metadata.id.to_string(),
607            mem.metadata.galaxy.db_name(),
608            &mem.content,
609            &mem.metadata.tags,
610            mem.metadata.created_at.timestamp(),
611        )
612    }
613
614    /// Delete documents by memory ID.
615    pub fn delete_document(&self, writer: &mut Option<IndexWriter>, memory_id: &str) -> Result<()> {
616        let writer = writer.as_mut().ok_or_else(|| {
617            CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
618        })?;
619        let term = tantivy::Term::from_field_text(self.field_id, memory_id);
620        writer.delete_term(term);
621        Ok(())
622    }
623
624    /// Delete every document belonging to a galaxy.
625    ///
626    /// Used by filtered reindexing so `--galaxy codex` removes only codex
627    /// documents instead of wiping the entire index.
628    pub fn delete_by_galaxy(&self, writer: &mut Option<IndexWriter>, galaxy: &str) -> Result<()> {
629        let writer = writer.as_mut().ok_or_else(|| {
630            CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
631        })?;
632        let term = tantivy::Term::from_field_text(self.field_galaxy, galaxy);
633        writer.delete_term(term);
634        Ok(())
635    }
636
637    /// Commit pending index changes and reload the reader.
638    pub fn commit(&self, writer: &mut Option<IndexWriter>) -> Result<()> {
639        let writer = writer.as_mut().ok_or_else(|| {
640            CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
641        })?;
642        writer
643            .commit()
644            .map_err(|e| CoreError::Memory(format!("Tantivy commit: {e}")))?;
645        self.reader
646            .reload()
647            .map_err(|e| CoreError::Memory(format!("Tantivy reload: {e}")))?;
648        Ok(())
649    }
650
651    /// Search for memories matching the query text.
652    /// Returns results sorted by BM25 score (descending).
653    ///
654    /// The query is stripped of stopwords and sanitized to prevent Tantivy
655    /// query syntax injection.
656    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>> {
657        let opts = SearchOptions {
658            limit,
659            ..SearchOptions::default()
660        };
661        self.search_opt(query, &opts)
662    }
663
664    /// Search for memories matching the query, optionally filtered by galaxy.
665    ///
666    /// The query is stripped of stopwords and sanitized to escape Tantivy
667    /// special characters (+, -, *, "", field syntax, boolean operators) that
668    /// could be used for query injection.
669    pub fn search_in_galaxy(
670        &self,
671        query: &str,
672        galaxy: Option<Galaxy>,
673        limit: usize,
674    ) -> Result<Vec<SearchResult>> {
675        let opts = SearchOptions {
676            limit,
677            galaxy,
678            ..SearchOptions::default()
679        };
680        self.search_opt(query, &opts)
681    }
682
683    /// Search with full recall-quality options (stopword stripping, score
684    /// thresholds, token-coverage filtering, galaxy filter).
685    ///
686    /// Pipeline:
687    /// 1. `strip_stopwords` — common English stopwords are removed.
688    /// 2. `sanitize_tantivy_query` — reserved query syntax is neutralized;
689    ///    plain terms (incl. hyphenated compounds) pass through so the
690    ///    tokenizer can split them into phrase matches.
691    /// 3. OR query across `content` + `tags` (broader recall than
692    ///    conjunction, filtered by token-coverage in step 5).
693    /// 4. Hits below `min_score` (absolute) or `relative_floor * top_score`
694    ///    are dropped.
695    /// 5. Token-coverage floor: for queries with ≥ 3 terms, at least 2
696    ///    must appear in the content (stemming-aware).  Documents that
697    ///    pass the floor receive a coverage-ratio score boost.
698    /// 6. Output content is scrubbed of control characters.
699    pub fn search_opt(&self, query: &str, opts: &SearchOptions) -> Result<Vec<SearchResult>> {
700        let stripped = strip_stopwords(query);
701        let sanitized = sanitize_tantivy_query(&stripped);
702        if sanitized.trim().is_empty() {
703            return Ok(Vec::new());
704        }
705
706        let searcher = self.reader.searcher();
707
708        // Always use OR semantics.  The token-coverage floor below filters
709        // single-term noise that OR would otherwise let through.
710        let query_parser =
711            QueryParser::for_index(&self.index, vec![self.field_content, self.field_tags]);
712
713        let parsed = parse_query_with_fallback(&query_parser, &sanitized);
714
715        let collector = TopDocs::with_limit(opts.limit).order_by_score();
716
717        let top_docs = searcher
718            .search(&parsed, &collector)
719            .map_err(|e| CoreError::Memory(format!("Tantivy search: {e}")))?;
720
721        let top_score = top_docs.first().map_or(0.0, |(score, _)| *score);
722        let absolute_floor = opts.min_score.unwrap_or(f32::MIN);
723        let relative_floor = opts
724            .relative_floor
725            .map_or(f32::MIN, |ratio| top_score * ratio);
726
727        // Token-coverage floor: with OR semantics a document matching any
728        // single common term would otherwise qualify.  For queries with
729        // ≥ 3 terms, require at least 2 to appear in the content.
730        let query_tokens = query_stem_tokens(&stripped);
731        let coverage_floor = if query_tokens.len() >= 3 { 2 } else { 1 };
732
733        let mut results = Vec::new();
734        for (score, doc_address) in top_docs {
735            // Score floors: reject weak matches before touching the document.
736            if score < absolute_floor || score < relative_floor {
737                continue;
738            }
739
740            let doc: TantivyDocument = searcher
741                .doc(doc_address)
742                .map_err(|e| CoreError::Memory(format!("Tantivy get doc: {e}")))?;
743
744            let memory_id = doc
745                .get_first(self.field_id)
746                .and_then(|v| v.as_str())
747                .unwrap_or("")
748                .to_string();
749
750            let doc_galaxy = doc
751                .get_first(self.field_galaxy)
752                .and_then(|v| v.as_str())
753                .unwrap_or("")
754                .to_string();
755
756            if let Some(g) = opts.galaxy {
757                if doc_galaxy != g.db_name() {
758                    continue;
759                }
760            }
761
762            let content = doc
763                .get_first(self.field_content)
764                .and_then(|v| v.as_str())
765                .unwrap_or("")
766                .to_string();
767
768            if coverage_floor > 1 {
769                let hits = count_token_hits(&content, &stripped);
770                if hits < coverage_floor {
771                    continue;
772                }
773            }
774
775            // Coverage-ratio boost: documents covering more query tokens
776            // are more relevant.  Boost = 1 + 0.1 * (hits / total).
777            let boosted_score = if query_tokens.is_empty() {
778                score
779            } else {
780                let hits = count_token_hits(&content, &stripped);
781                let ratio = hits as f32 / query_tokens.len() as f32;
782                score * 0.1f32.mul_add(ratio, 1.0)
783            };
784
785            results.push(SearchResult {
786                memory_id,
787                galaxy: doc_galaxy,
788                score: boosted_score,
789                normalized_score: 0.0, // set after re-sort
790                content: scrub_text(&content),
791            });
792        }
793
794        // Re-sort by boosted score (coverage boost may have re-ordered).
795        results.sort_by(|a, b| {
796            b.score
797                .partial_cmp(&a.score)
798                .unwrap_or(std::cmp::Ordering::Equal)
799        });
800
801        // Normalize relative to the top boosted score.
802        let top_boosted = results.first().map_or(0.0, |r| r.score);
803        for r in &mut results {
804            r.normalized_score = if top_boosted > 0.0 {
805                r.score / top_boosted
806            } else {
807                0.0
808            };
809        }
810
811        Ok(results)
812    }
813
814    /// Search and return memory IDs only (for integration with `MemoryStore`).
815    pub fn search_ids(&self, query: &str, limit: usize) -> Result<Vec<MemoryId>> {
816        let results = self.search(query, limit)?;
817        Ok(results
818            .into_iter()
819            .filter_map(|r| uuid::Uuid::parse_str(&r.memory_id).ok())
820            .collect())
821    }
822}
823
824/// Parse a sanitized query, falling back to lenient parsing when the strict
825/// parser rejects it.
826///
827/// [`sanitize_tantivy_query`] neutralizes known syntax, but the parser can
828/// still reject input it does not anticipate (for example a term whose
829/// quoting produces a dangling escape). Lenient parsing turns unparseable
830/// fragments into match-nothing clauses, so a malformed query degrades to a
831/// partial search instead of failing the request.
832fn parse_query_with_fallback(parser: &QueryParser, query: &str) -> Box<dyn tantivy::query::Query> {
833    match parser.parse_query(query) {
834        Ok(parsed) => parsed,
835        Err(_) => parser.parse_query_lenient(query).0,
836    }
837}
838
839/// Sanitize a user-provided query string for Tantivy's query parser.
840///
841/// Tantivy's query parser supports special syntax that could be abused:
842/// - `*` wildcard matches all terms (DoS)
843/// - `+`, `-`, `NOT`, `OR`, `AND` boolean operators
844/// - `"phrase"` exact phrase queries
845/// - `field:value` field-scoped queries
846/// - `(`, `)` grouping
847/// - `\` escape character
848/// - `:` field separator
849///
850/// Terms are only wrapped in double quotes when they contain reserved syntax
851/// (or are uppercase boolean operators). Plain terms — including hyphenated
852/// compounds like `antigravity-project-test` — pass through unquoted so the
853/// tokenizer can split them into phrase matches. Terms without any
854/// alphanumeric characters are dropped entirely.
855#[must_use]
856pub fn sanitize_tantivy_query(input: &str) -> String {
857    // If empty, return as-is
858    if input.trim().is_empty() {
859        return String::new();
860    }
861
862    input
863        .split_whitespace()
864        .filter(|term| term.chars().any(char::is_alphanumeric))
865        .map(|term| {
866            if term_needs_quoting(term) {
867                // Escape backslashes first, then embedded double quotes. A
868                // trailing backslash would otherwise escape the closing quote
869                // and produce an unterminated phrase (a parse error).
870                let escaped = term.replace('\\', "\\\\").replace('"', "\\\"");
871                format!("\"{escaped}\"")
872            } else {
873                term.to_string()
874            }
875        })
876        .collect::<Vec<_>>()
877        .join(" ")
878}
879
880/// Whether a query term needs quoting to neutralize Tantivy query syntax.
881#[must_use]
882fn term_needs_quoting(term: &str) -> bool {
883    if term.starts_with('+') || term.starts_with('-') || term.starts_with('!') {
884        return true;
885    }
886    if term == "AND" || term == "OR" || term == "NOT" {
887        return true;
888    }
889    if term.contains("&&") || term.contains("||") {
890        return true;
891    }
892    term.chars().any(|c| {
893        matches!(
894            c,
895            '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"' | '~' | '*' | '?' | ':' | '\\' | '/'
896        )
897    })
898}
899
900/// Strip common English stopwords from a query string.
901///
902/// Tokens are compared case-insensitively against [`STOPWORDS`].
903#[must_use]
904pub fn strip_stopwords(query: &str) -> String {
905    query
906        .split_whitespace()
907        .filter(|term| !STOPWORDS.contains(&term.to_lowercase().as_str()))
908        .collect::<Vec<_>>()
909        .join(" ")
910}
911
912/// Unique lowercase stemmed tokens of a stopword-stripped query.
913/// Uses [`simple_stem`] so that coverage matching aligns with the en_stem
914/// tokenizer used at index time.
915#[must_use]
916fn query_stem_tokens(stripped_query: &str) -> Vec<String> {
917    stem_tokens(stripped_query)
918}
919
920/// Normalize text into the same punctuation-delimited tokens on both sides
921/// of the coverage comparison. This keeps possessives and hyphenated terms
922/// from becoming query-only tokens or standalone one-character fragments.
923#[must_use]
924fn stem_tokens(text: &str) -> Vec<String> {
925    let mut tokens: Vec<String> = Vec::new();
926    for term in text
927        .split(|c: char| !c.is_alphanumeric())
928        .filter(|term| term.len() > 1)
929    {
930        let stemmed = simple_stem(&term.to_lowercase());
931        if !tokens.contains(&stemmed) {
932            tokens.push(stemmed);
933        }
934    }
935    tokens
936}
937
938/// Lightweight suffix-stripping stemmer that approximates the Porter stemmer
939/// used by Tantivy's `en_stem` tokenizer.  Handles the common English
940/// inflections (-s, -es, -ed, -ing, -ly, -ies, -ied) without pulling in a
941/// full stemming crate.  This is intentionally conservative — false
942/// negatives (under-stemming) only make coverage stricter, never looser.
943#[must_use]
944fn simple_stem(word: &str) -> String {
945    if word.len() <= 3 {
946        return word.to_string();
947    }
948    // Order matters: check longer suffixes first.
949    for suffix in ["ies", "ied", "ing", "edly", "ed", "ly", "es", "s"] {
950        if let Some(stem) = word.strip_suffix(suffix) {
951            // "ies" / "ied" → restore "y" (stories → story, carried → carry)
952            if suffix == "ies" || suffix == "ied" {
953                return format!("{stem}y");
954            }
955            // Don't produce a 1-char stem ("is" → "i")
956            if stem.len() >= 2 {
957                return stem.to_string();
958            }
959        }
960    }
961    word.to_string()
962}
963
964/// Count how many query tokens (after stemming) appear as whole words in the
965/// content.  Uses [`simple_stem`] on both sides so that "graduate" matches
966/// "graduated", mirroring the en_stem tokenizer used at index time.
967#[must_use]
968fn count_token_hits(content: &str, stripped_query: &str) -> usize {
969    let query_tokens = query_stem_tokens(stripped_query);
970    if query_tokens.is_empty() {
971        return 0;
972    }
973    let content_stems: std::collections::HashSet<String> =
974        stem_tokens(content).into_iter().collect();
975    query_tokens
976        .iter()
977        .filter(|t| content_stems.contains(*t))
978        .count()
979}
980
981/// Prepare content for indexing.
982///
983/// Returns `None` when the content is not clean text and must be skipped:
984/// - empty / whitespace-only content
985/// - contains a null byte (binary serialization artifact)
986/// - printable-char ratio below [`MIN_PRINTABLE_RATIO`]
987///
988/// Otherwise returns the content scrubbed of control characters and capped
989/// at [`MAX_INDEX_CONTENT_LEN`] chars.
990#[must_use]
991pub fn sanitize_content_for_index(content: &str) -> Option<String> {
992    if content.trim().is_empty() {
993        return None;
994    }
995    if content.as_bytes().contains(&0) {
996        return None;
997    }
998
999    let total = content.chars().count();
1000    if total == 0 {
1001        return None;
1002    }
1003    let printable = content.chars().filter(|c| !c.is_control()).count();
1004    if (printable as f32 / total as f32) < MIN_PRINTABLE_RATIO {
1005        return None;
1006    }
1007
1008    let cleaned = scrub_text(content);
1009    let capped: String = cleaned.chars().take(MAX_INDEX_CONTENT_LEN).collect();
1010    if capped.trim().is_empty() {
1011        None
1012    } else {
1013        Some(capped)
1014    }
1015}
1016
1017/// Scrub text for output: replace control characters (except newline, tab,
1018/// carriage return) with a space, and cap the length at
1019/// [`MAX_INDEX_CONTENT_LEN`].
1020#[must_use]
1021pub fn scrub_text(content: &str) -> String {
1022    let mut out = String::with_capacity(content.len().min(MAX_INDEX_CONTENT_LEN));
1023    for c in content.chars().take(MAX_INDEX_CONTENT_LEN) {
1024        if c.is_control() && c != '\n' && c != '\t' && c != '\r' {
1025            out.push(' ');
1026        } else {
1027            out.push(c);
1028        }
1029    }
1030    out
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035    use super::*;
1036    use tempfile::tempdir;
1037
1038    fn open_engine() -> (tempfile::TempDir, SearchEngine) {
1039        let tmp = tempdir().unwrap();
1040        let engine = SearchEngine::open(tmp.path()).unwrap();
1041        (tmp, engine)
1042    }
1043
1044    /// Write a legacy one-field index into `dir`, simulating a store created
1045    /// by an older WhiteMagic version with a different Tantivy schema.
1046    fn write_incompatible_index(dir: &Path) {
1047        std::fs::create_dir_all(dir).unwrap();
1048        let mut builder = Schema::builder();
1049        builder.add_text_field("legacy", STRING | STORED);
1050        let schema = builder.build();
1051        let directory = tantivy::directory::MmapDirectory::open(dir).unwrap();
1052        Index::open_or_create(directory, schema).unwrap();
1053    }
1054
1055    #[test]
1056    fn open_migrates_incompatible_schema() {
1057        let tmp = tempdir().unwrap();
1058        let dir = tmp.path().join("tantivy");
1059        write_incompatible_index(&dir);
1060
1061        let engine = SearchEngine::open(&dir).unwrap();
1062        assert!(
1063            engine.schema_migrated(),
1064            "incompatible schema must trigger migration"
1065        );
1066
1067        // The old index must be preserved as a .schema-mismatch sibling.
1068        let backups: Vec<_> = std::fs::read_dir(tmp.path())
1069            .unwrap()
1070            .filter_map(std::result::Result::ok)
1071            .filter(|e| e.file_name().to_string_lossy().contains("schema-mismatch"))
1072            .collect();
1073        assert_eq!(backups.len(), 1, "old index must be backed up exactly once");
1074
1075        // The fresh index must be writable and searchable.
1076        let mut writer = engine.writer().unwrap();
1077        engine
1078            .add_document(
1079                &mut writer,
1080                "33333333-3333-3333-3333-333333333333",
1081                "codex",
1082                "fresh index after migration",
1083                &[],
1084                1700000000,
1085            )
1086            .unwrap();
1087        engine.commit(&mut writer).unwrap();
1088        let results = engine.search("fresh index", 10).unwrap();
1089        assert_eq!(results.len(), 1);
1090    }
1091
1092    #[test]
1093    fn writer_lock_error_names_path_and_hint() {
1094        // B1: LockBusy used to surface bare ("Failed to acquire Lockfile:
1095        // LockBusy") with no path and no hint — a stray `wm serve` cost a
1096        // debug session to find.
1097        let err = format_writer_lock_error(
1098            "Failed to acquire Lockfile: LockBusy. Some(\"...\")",
1099            Path::new("/store/x/tantivy"),
1100        );
1101        assert!(
1102            err.contains("/store/x/tantivy"),
1103            "must name the index path: {err}"
1104        );
1105        assert!(
1106            err.contains("pgrep -af wm"),
1107            "must include the diagnostic hint: {err}"
1108        );
1109        assert!(
1110            err.contains("--readonly"),
1111            "must offer the readonly alternative: {err}"
1112        );
1113
1114        // Non-lock errors pass through unchanged.
1115        let other = format_writer_lock_error("disk full", Path::new("/s/t"));
1116        assert!(other.starts_with("Tantivy writer: disk full"));
1117        assert!(!other.contains("pgrep"));
1118    }
1119
1120    #[test]
1121    fn open_readonly_rejects_incompatible_schema() {
1122        let tmp = tempdir().unwrap();
1123        let dir = tmp.path().join("tantivy");
1124        write_incompatible_index(&dir);
1125
1126        let err = match SearchEngine::open_readonly(&dir) {
1127            Ok(_) => panic!("read-only open must reject an incompatible schema"),
1128            Err(e) => e,
1129        };
1130        assert!(
1131            format!("{err}").contains("wm reindex"),
1132            "read-only mismatch must point at wm reindex, got: {err}"
1133        );
1134
1135        // Nothing was moved: the incompatible index is still in place.
1136        let siblings: Vec<_> = std::fs::read_dir(tmp.path())
1137            .unwrap()
1138            .filter_map(std::result::Result::ok)
1139            .filter(|e| e.file_name().to_string_lossy().contains("schema-mismatch"))
1140            .collect();
1141        assert!(siblings.is_empty(), "read-only open must not migrate");
1142    }
1143
1144    #[test]
1145    fn open_readonly_rejects_existing_empty_directory_without_creating_files() {
1146        let tmp = tempdir().unwrap();
1147        let dir = tmp.path().join("tantivy");
1148        std::fs::create_dir_all(&dir).unwrap();
1149
1150        let err = match SearchEngine::open_readonly(&dir) {
1151            Ok(_) => panic!("readonly open unexpectedly initialized an empty index"),
1152            Err(err) => err,
1153        };
1154        assert!(format!("{err}").contains("readonly open-existing"));
1155        assert!(
1156            std::fs::read_dir(&dir).unwrap().next().is_none(),
1157            "readonly open must not materialize Tantivy metadata or segments"
1158        );
1159    }
1160
1161    #[test]
1162    fn reopen_matching_schema_not_migrated() {
1163        let tmp = tempdir().unwrap();
1164        let dir = tmp.path().join("tantivy");
1165        std::fs::create_dir_all(&dir).unwrap();
1166
1167        let first = SearchEngine::open(&dir).unwrap();
1168        assert!(!first.schema_migrated());
1169        drop(first); // release the tantivy writer lock before reopening
1170
1171        let second = SearchEngine::open(&dir).unwrap();
1172        assert!(
1173            !second.schema_migrated(),
1174            "matching schema must not migrate"
1175        );
1176        drop(second);
1177
1178        let third = SearchEngine::open_readonly(&dir).unwrap();
1179        assert!(!third.schema_migrated());
1180    }
1181
1182    #[test]
1183    fn index_and_search_basic() {
1184        let (_tmp, engine) = open_engine();
1185        let mut writer = engine.writer().unwrap();
1186
1187        engine
1188            .add_document(
1189                &mut writer,
1190                "11111111-1111-1111-1111-111111111111",
1191                "codex",
1192                "The Rust programming language is fast and safe",
1193                &["rust".into(), "programming".into()],
1194                1700000000,
1195            )
1196            .unwrap();
1197        engine
1198            .add_document(
1199                &mut writer,
1200                "22222222-2222-2222-2222-222222222222",
1201                "codex",
1202                "Python is great for data science",
1203                &["python".into(), "data".into()],
1204                1700000001,
1205            )
1206            .unwrap();
1207        engine.commit(&mut writer).unwrap();
1208
1209        let results = engine.search("rust", 10).unwrap();
1210        assert!(!results.is_empty());
1211        assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1212    }
1213
1214    #[test]
1215    fn search_by_tag() {
1216        let (_tmp, engine) = open_engine();
1217        let mut writer = engine.writer().unwrap();
1218
1219        engine
1220            .add_document(
1221                &mut writer,
1222                "11111111-1111-1111-1111-111111111111",
1223                "codex",
1224                "memory about systems",
1225                &["rust".into()],
1226                1700000000,
1227            )
1228            .unwrap();
1229        engine
1230            .add_document(
1231                &mut writer,
1232                "22222222-2222-2222-2222-222222222222",
1233                "codex",
1234                "memory about cooking",
1235                &["food".into()],
1236                1700000001,
1237            )
1238            .unwrap();
1239        engine.commit(&mut writer).unwrap();
1240
1241        let results = engine.search("rust", 10).unwrap();
1242        assert_eq!(results.len(), 1);
1243        assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1244    }
1245
1246    #[test]
1247    fn search_filtered_by_galaxy() {
1248        let (_tmp, engine) = open_engine();
1249        let mut writer = engine.writer().unwrap();
1250
1251        engine
1252            .add_document(
1253                &mut writer,
1254                "11111111-1111-1111-1111-111111111111",
1255                "codex",
1256                "important knowledge",
1257                &[],
1258                1700000000,
1259            )
1260            .unwrap();
1261        engine
1262            .add_document(
1263                &mut writer,
1264                "22222222-2222-2222-2222-222222222222",
1265                "research",
1266                "important findings",
1267                &[],
1268                1700000001,
1269            )
1270            .unwrap();
1271        engine.commit(&mut writer).unwrap();
1272
1273        let results = engine
1274            .search_in_galaxy("important", Some(Galaxy::Codex), 10)
1275            .unwrap();
1276        assert_eq!(results.len(), 1);
1277        assert_eq!(results[0].galaxy, "codex");
1278    }
1279
1280    #[test]
1281    fn delete_document_from_index() {
1282        let (_tmp, engine) = open_engine();
1283        let mut writer = engine.writer().unwrap();
1284
1285        engine
1286            .add_document(
1287                &mut writer,
1288                "11111111-1111-1111-1111-111111111111",
1289                "codex",
1290                "deletable content",
1291                &[],
1292                1700000000,
1293            )
1294            .unwrap();
1295        engine.commit(&mut writer).unwrap();
1296
1297        let results = engine.search("deletable", 10).unwrap();
1298        assert_eq!(results.len(), 1);
1299
1300        engine
1301            .delete_document(&mut writer, "11111111-1111-1111-1111-111111111111")
1302            .unwrap();
1303        engine.commit(&mut writer).unwrap();
1304
1305        let results = engine.search("deletable", 10).unwrap();
1306        assert_eq!(results.len(), 0);
1307    }
1308
1309    #[test]
1310    fn search_empty_index() {
1311        let (_tmp, engine) = open_engine();
1312        let results = engine.search("anything", 10).unwrap();
1313        assert!(results.is_empty());
1314    }
1315
1316    #[test]
1317    fn search_ids_returns_uuids() {
1318        let (_tmp, engine) = open_engine();
1319        let mut writer = engine.writer().unwrap();
1320
1321        engine
1322            .add_document(
1323                &mut writer,
1324                "11111111-1111-1111-1111-111111111111",
1325                "codex",
1326                "unique content about rust",
1327                &[],
1328                1700000000,
1329            )
1330            .unwrap();
1331        engine.commit(&mut writer).unwrap();
1332
1333        let ids = engine.search_ids("rust", 10).unwrap();
1334        assert_eq!(ids.len(), 1);
1335        assert_eq!(
1336            ids[0],
1337            uuid::Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap()
1338        );
1339    }
1340
1341    // ── Tantivy query injection tests ───────────────────────────────
1342
1343    #[test]
1344    fn sanitize_leaves_plain_terms_unquoted() {
1345        let result = sanitize_tantivy_query("hello world");
1346        assert_eq!(result, "hello world");
1347    }
1348
1349    #[test]
1350    fn sanitize_drops_punct_only_terms() {
1351        let result = sanitize_tantivy_query("*");
1352        assert_eq!(result, "");
1353        // Should not match all documents when parsed
1354    }
1355
1356    #[test]
1357    fn sanitize_escapes_boolean_operators() {
1358        let result = sanitize_tantivy_query("NOT secret");
1359        assert_eq!(result, "\"NOT\" secret");
1360    }
1361
1362    #[test]
1363    fn sanitize_escapes_field_syntax() {
1364        let result = sanitize_tantivy_query("content:secret");
1365        assert_eq!(result, "\"content:secret\"");
1366    }
1367
1368    #[test]
1369    fn sanitize_escapes_quotes() {
1370        let result = sanitize_tantivy_query("test\"injection");
1371        assert!(
1372            result.contains("\\\""),
1373            "embedded quotes should be escaped: {result}"
1374        );
1375    }
1376
1377    #[test]
1378    fn sanitize_escapes_trailing_backslash_token() {
1379        // A term ending in a backslash used to become "abc\" — the dangling
1380        // escape swallowed the closing quote and failed the query parser.
1381        let result = sanitize_tantivy_query("C:\\Users\\temp\\");
1382        assert_eq!(
1383            result, "\"C:\\\\Users\\\\temp\\\\\"",
1384            "backslashes must be doubled inside quoted terms"
1385        );
1386    }
1387
1388    #[test]
1389    fn lenient_fallback_never_fails_on_malformed_input() {
1390        let (_tmp, engine) = open_engine();
1391        let parser =
1392            QueryParser::for_index(&engine.index, vec![engine.field_content, engine.field_tags]);
1393        let searcher = engine.reader.searcher();
1394        let collector = TopDocs::with_limit(1).order_by_score();
1395        for malformed in ["\"unterminated", "field:(\"", "\\", "AND NOT OR"] {
1396            let parsed = parse_query_with_fallback(&parser, malformed);
1397            searcher
1398                .search(&parsed, &collector)
1399                .unwrap_or_else(|e| panic!("lenient query {malformed:?} must execute: {e}"));
1400        }
1401    }
1402
1403    #[test]
1404    fn sanitize_empty_returns_empty() {
1405        assert_eq!(sanitize_tantivy_query(""), "");
1406        assert_eq!(sanitize_tantivy_query("   "), "");
1407    }
1408
1409    #[test]
1410    fn sanitize_preserves_alphanumeric() {
1411        let result = sanitize_tantivy_query("rust programming 2024");
1412        assert_eq!(result, "rust programming 2024");
1413    }
1414
1415    #[test]
1416    fn sanitize_preserves_hyphenated_compounds() {
1417        let result = sanitize_tantivy_query("antigravity antigravity-project-test");
1418        assert_eq!(result, "antigravity antigravity-project-test");
1419    }
1420
1421    // ── Stopword tests ──────────────────────────────────────────────
1422
1423    #[test]
1424    fn strip_stopwords_removes_common_words() {
1425        assert_eq!(
1426            strip_stopwords("smoke test from wmClient"),
1427            "smoke test wmClient"
1428        );
1429        assert_eq!(strip_stopwords("the from and or"), "");
1430        assert_eq!(strip_stopwords("Rust ownership"), "Rust ownership");
1431        assert_eq!(strip_stopwords(""), "");
1432    }
1433
1434    #[test]
1435    fn strip_stopwords_is_case_insensitive() {
1436        assert_eq!(strip_stopwords("FROM The And"), "");
1437    }
1438
1439    // ── Index-time sanitization tests ───────────────────────────────
1440
1441    #[test]
1442    fn sanitize_content_skips_null_bytes() {
1443        let content = "binary\x00garbage\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00";
1444        assert!(sanitize_content_for_index(content).is_none());
1445    }
1446
1447    #[test]
1448    fn sanitize_content_skips_low_printable_ratio() {
1449        // 5 control chars out of 11 → ratio 0.55 < 0.9 → skip
1450        let content = "\u{01}\u{02}\u{03}\u{04}\u{05}hello";
1451        assert!(sanitize_content_for_index(content).is_none());
1452    }
1453
1454    #[test]
1455    fn sanitize_content_scrubs_and_caps() {
1456        // A stray control char does not disqualify clean text — it is scrubbed.
1457        let content = "clean text\u{01}with one control char";
1458        let cleaned = sanitize_content_for_index(content).unwrap();
1459        assert!(!cleaned.contains('\u{01}'));
1460        assert!(cleaned.starts_with("clean text with one control char"));
1461
1462        let long = "a".repeat(MAX_INDEX_CONTENT_LEN + 1000);
1463        let capped = sanitize_content_for_index(&long).unwrap();
1464        assert_eq!(capped.chars().count(), MAX_INDEX_CONTENT_LEN);
1465    }
1466
1467    #[test]
1468    fn sanitize_content_skips_empty() {
1469        assert!(sanitize_content_for_index("").is_none());
1470        assert!(sanitize_content_for_index("   ").is_none());
1471    }
1472
1473    #[test]
1474    fn scrub_text_replaces_control_chars() {
1475        let result = scrub_text("a\u{01}b\nc\td\u{7f}e");
1476        assert_eq!(result, "a b\nc\td e");
1477    }
1478
1479    #[test]
1480    fn add_document_skips_binary_content() {
1481        let (_tmp, engine) = open_engine();
1482        let mut writer = engine.writer().unwrap();
1483
1484        engine
1485            .add_document(
1486                &mut writer,
1487                "11111111-1111-1111-1111-111111111111",
1488                "codex",
1489                "\u{00}\u{01}\u{02}raw serialized bytes",
1490                &[],
1491                1700000000,
1492            )
1493            .unwrap();
1494        engine
1495            .add_document(
1496                &mut writer,
1497                "22222222-2222-2222-2222-222222222222",
1498                "codex",
1499                "clean searchable text",
1500                &[],
1501                1700000001,
1502            )
1503            .unwrap();
1504        engine.commit(&mut writer).unwrap();
1505
1506        // The binary doc must not be searchable; the clean one must be.
1507        let results = engine.search("serialized", 10).unwrap();
1508        assert!(results.is_empty(), "binary content must not be indexed");
1509
1510        let results = engine.search("clean", 10).unwrap();
1511        assert_eq!(results.len(), 1);
1512        assert_eq!(results[0].memory_id, "22222222-2222-2222-2222-222222222222");
1513    }
1514
1515    // ── Score-threshold tests ───────────────────────────────────────
1516
1517    fn index_alpha_pair(engine: &SearchEngine) {
1518        let mut writer = engine.writer().unwrap();
1519        // Two docs both containing "alpha"; the short one scores higher
1520        // (BM25 length normalization).
1521        engine
1522            .add_document(
1523                &mut writer,
1524                "11111111-1111-1111-1111-111111111111",
1525                "codex",
1526                "alpha",
1527                &[],
1528                1700000000,
1529            )
1530            .unwrap();
1531        let filler = format!("alpha {}", "zzz ".repeat(400));
1532        engine
1533            .add_document(
1534                &mut writer,
1535                "22222222-2222-2222-2222-222222222222",
1536                "codex",
1537                &filler,
1538                &[],
1539                1700000001,
1540            )
1541            .unwrap();
1542        engine.commit(&mut writer).unwrap();
1543    }
1544
1545    #[test]
1546    fn search_absolute_min_score_filters_weak_matches() {
1547        let (_tmp, engine) = open_engine();
1548        index_alpha_pair(&engine);
1549
1550        let base = engine.search("alpha", 10).unwrap();
1551        assert_eq!(base.len(), 2);
1552        let (hi, lo) = if base[0].score >= base[1].score {
1553            (base[0].score, base[1].score)
1554        } else {
1555            (base[1].score, base[0].score)
1556        };
1557        assert!(
1558            hi > lo,
1559            "short doc should outscore long doc (hi={hi}, lo={lo})"
1560        );
1561        let mid = f32::midpoint(hi, lo);
1562
1563        let opts = SearchOptions {
1564            limit: 10,
1565            min_score: Some(mid),
1566            ..SearchOptions::default()
1567        };
1568        let filtered = engine.search_opt("alpha", &opts).unwrap();
1569        assert_eq!(filtered.len(), 1);
1570        assert!((filtered[0].score - hi).abs() < 1e-3);
1571    }
1572
1573    #[test]
1574    fn search_relative_floor_filters_weak_matches() {
1575        let (_tmp, engine) = open_engine();
1576        index_alpha_pair(&engine);
1577
1578        let opts = SearchOptions {
1579            limit: 10,
1580            relative_floor: Some(0.5),
1581            ..SearchOptions::default()
1582        };
1583        let filtered = engine.search_opt("alpha", &opts).unwrap();
1584        assert_eq!(filtered.len(), 1, "weak match must fall below 50% of top");
1585        assert_eq!(
1586            filtered[0].memory_id,
1587            "11111111-1111-1111-1111-111111111111"
1588        );
1589        assert!((filtered[0].normalized_score - 1.0).abs() < 1e-3);
1590    }
1591
1592    #[test]
1593    fn search_all_results_normalized() {
1594        let (_tmp, engine) = open_engine();
1595        index_alpha_pair(&engine);
1596
1597        let results = engine.search("alpha", 10).unwrap();
1598        assert_eq!(results.len(), 2);
1599        assert!((results[0].normalized_score - 1.0).abs() < 1e-3);
1600        for r in &results[1..] {
1601            assert!(r.normalized_score <= 1.0);
1602            assert!(r.normalized_score > 0.0);
1603        }
1604    }
1605
1606    #[test]
1607    fn search_stemming_matches_morphological_variants() {
1608        // The en_stem tokenizer should match morphological variants:
1609        // "graduate" (query) ↔ "graduated" (indexed content).
1610        let (_tmp, engine) = open_engine();
1611        let mut writer = engine.writer().unwrap();
1612
1613        engine
1614            .add_document(
1615                &mut writer,
1616                "11111111-1111-1111-1111-111111111111",
1617                "codex",
1618                "I graduated with a degree in Business Administration",
1619                &[],
1620                1700000000,
1621            )
1622            .unwrap();
1623        engine.commit(&mut writer).unwrap();
1624
1625        // Query with present tense "graduate" should match past tense "graduated"
1626        let results = engine.search("graduate", 10).unwrap();
1627        assert_eq!(results.len(), 1);
1628        assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1629
1630        // Query with "degrees" should match "degree"
1631        let results = engine.search("degrees", 10).unwrap();
1632        assert_eq!(results.len(), 1);
1633    }
1634
1635    #[test]
1636    fn search_incident_query_returns_only_relevant() {
1637        // Mirrors the 2026-08-11 incident: `memory.hybrid_recall`
1638        // query "smoke test from wmClient" must NOT return unrelated memories.
1639        let (_tmp, engine) = open_engine();
1640        let mut writer = engine.writer().unwrap();
1641
1642        let smoke_id = "11111111-1111-1111-1111-111111111111";
1643        engine
1644            .add_document(
1645                &mut writer,
1646                smoke_id,
1647                "codex",
1648                "smoke test from wmClient: verify recall works",
1649                &[],
1650                1700000000,
1651            )
1652            .unwrap();
1653        let unrelated = [
1654            "NES Evolution and Impact: a history of the console wars",
1655            "Insights on The Gateless Gate: koans and zen practice",
1656            "What the tweet is really saying: a thread analysis",
1657        ];
1658        for (i, content) in (1i64..).zip(unrelated.iter()) {
1659            engine
1660                .add_document(
1661                    &mut writer,
1662                    &format!("22222222-2222-2222-2222-2222222222{i:02}"),
1663                    "codex",
1664                    content,
1665                    &[],
1666                    1700000000 + i,
1667                )
1668                .unwrap();
1669        }
1670        engine.commit(&mut writer).unwrap();
1671
1672        let results = engine.search("smoke test from wmClient", 20).unwrap();
1673        assert_eq!(
1674            results.len(),
1675            1,
1676            "only the smoke memory should match: {results:?}"
1677        );
1678        assert_eq!(results[0].memory_id, smoke_id);
1679        assert!(results[0].content.contains("smoke test"));
1680    }
1681
1682    #[test]
1683    fn search_project_compound_query() {
1684        // "antigravity antigravity-project-test" must find the project memory.
1685        let (_tmp, engine) = open_engine();
1686        let mut writer = engine.writer().unwrap();
1687
1688        engine
1689            .add_document(
1690                &mut writer,
1691                "11111111-1111-1111-1111-111111111111",
1692                "codex",
1693                "[antigravity:antigravity-project-test]\nQ: how does it work?\nA: details here",
1694                &["project_antigravity-project-test".into()],
1695                1700000000,
1696            )
1697            .unwrap();
1698        engine.commit(&mut writer).unwrap();
1699
1700        let results = engine
1701            .search("antigravity antigravity-project-test", 10)
1702            .unwrap();
1703        assert!(
1704            !results.is_empty(),
1705            "project compound query must match the antigravity memory"
1706        );
1707        assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1708    }
1709
1710    #[test]
1711    fn or_default_filters_partial_matches_via_coverage() {
1712        let (_tmp, engine) = open_engine();
1713        let mut writer = engine.writer().unwrap();
1714
1715        engine
1716            .add_document(
1717                &mut writer,
1718                "11111111-1111-1111-1111-111111111111",
1719                "codex",
1720                "alpha beta gamma delta",
1721                &[],
1722                1700000000,
1723            )
1724            .unwrap();
1725        engine
1726            .add_document(
1727                &mut writer,
1728                "22222222-2222-2222-2222-222222222222",
1729                "codex",
1730                "alpha only here",
1731                &[],
1732                1700000001,
1733            )
1734            .unwrap();
1735        engine.commit(&mut writer).unwrap();
1736
1737        // OR is now the default.  A 3-term query requires 2/3 token coverage,
1738        // so the "alpha only here" doc (1/3) is filtered out.
1739        let results = engine.search("alpha beta gamma", 10).unwrap();
1740        assert_eq!(results.len(), 1);
1741        assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1742
1743        // With a 2-term query the floor is 1/2, so partial matches return.
1744        let results = engine.search("alpha beta", 10).unwrap();
1745        assert_eq!(results.len(), 2);
1746    }
1747
1748    #[test]
1749    fn coverage_is_case_insensitive() {
1750        let (_tmp, engine) = open_engine();
1751        let mut writer = engine.writer().unwrap();
1752        engine
1753            .add_document(
1754                &mut writer,
1755                "11111111-1111-1111-1111-111111111111",
1756                "codex",
1757                "Smoke Test for wmClient integration",
1758                &[],
1759                1700000000,
1760            )
1761            .unwrap();
1762        engine
1763            .add_document(
1764                &mut writer,
1765                "22222222-2222-2222-2222-222222222222",
1766                "codex",
1767                "test only here",
1768                &[],
1769                1700000001,
1770            )
1771            .unwrap();
1772        engine.commit(&mut writer).unwrap();
1773
1774        // 3 tokens: smoke + test + wmclient (case-insensitive stemming-aware
1775        // whole-word matching) — only the first doc covers 2/3; the "test
1776        // only" doc covers 1/3 and must be dropped even though it matched
1777        // via OR.
1778        let results = engine.search("Smoke Test from wmClient", 10).unwrap();
1779        assert_eq!(results.len(), 1);
1780        assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1781    }
1782
1783    #[test]
1784    fn coverage_matches_stemmed_variants() {
1785        // Stemming-aware coverage: "graduate" should match "graduated",
1786        // "classes" should match "class", mirroring the en_stem tokenizer.
1787        let (_tmp, engine) = open_engine();
1788        let mut writer = engine.writer().unwrap();
1789        engine
1790            .add_document(
1791                &mut writer,
1792                "11111111-1111-1111-1111-111111111111",
1793                "codex",
1794                "I graduated with a degree in Business Administration",
1795                &[],
1796                1700000000,
1797            )
1798            .unwrap();
1799        engine
1800            .add_document(
1801                &mut writer,
1802                "22222222-2222-2222-2222-222222222222",
1803                "codex",
1804                "degree only here",
1805                &[],
1806                1700000001,
1807            )
1808            .unwrap();
1809        engine.commit(&mut writer).unwrap();
1810
1811        // 2-term query "graduate degree" → coverage floor 1/2.
1812        // Doc 1: "graduated" stems to "graduat", "degree" stems to "degre" → 2/2.
1813        // Doc 2: "degree" → 1/2.
1814        // Both pass the 1/2 floor, but doc 1 should rank higher due to
1815        // the coverage-ratio boost (2/2 > 1/2).
1816        let results = engine.search("graduate degree", 10).unwrap();
1817        assert_eq!(results.len(), 2);
1818        assert_eq!(results[0].memory_id, "11111111-1111-1111-1111-111111111111");
1819    }
1820
1821    #[test]
1822    fn coverage_normalizes_possessives_and_punctuation() {
1823        let query = "buy sister's birthday gift";
1824        assert_eq!(
1825            query_stem_tokens(query),
1826            ["buy", "sister", "birthday", "gift"]
1827        );
1828        assert_eq!(
1829            count_token_hits("I bought a dress for my sister birthday", query),
1830            2
1831        );
1832    }
1833
1834    #[test]
1835    fn search_stopword_only_query_returns_nothing() {
1836        let (_tmp, engine) = open_engine();
1837        let mut writer = engine.writer().unwrap();
1838        engine
1839            .add_document(
1840                &mut writer,
1841                "11111111-1111-1111-1111-111111111111",
1842                "codex",
1843                "some ordinary text",
1844                &[],
1845                1700000000,
1846            )
1847            .unwrap();
1848        engine.commit(&mut writer).unwrap();
1849
1850        let results = engine.search("the from and or", 10).unwrap();
1851        assert!(results.is_empty());
1852    }
1853
1854    #[test]
1855    fn wildcard_query_doesnt_match_all() {
1856        let (_tmp, engine) = open_engine();
1857        let mut writer = engine.writer().unwrap();
1858
1859        engine
1860            .add_document(&mut writer, "uuid-1", "codex", "first document", &[], 1000)
1861            .unwrap();
1862        engine
1863            .add_document(&mut writer, "uuid-2", "codex", "second document", &[], 2000)
1864            .unwrap();
1865        engine.commit(&mut writer).unwrap();
1866
1867        // Wildcard should not match all documents
1868        let results = engine.search("*", 10).unwrap();
1869        // With sanitization, "*" is treated as literal text, not wildcard
1870        // So it should match 0 documents (no content contains literal "*")
1871        assert!(
1872            results.is_empty(),
1873            "wildcard query should not match all documents after sanitization"
1874        );
1875    }
1876
1877    #[test]
1878    fn field_syntax_query_doesnt_access_other_fields() {
1879        let (_tmp, engine) = open_engine();
1880        let mut writer = engine.writer().unwrap();
1881
1882        // Add a doc with "secret" in galaxy field but not content
1883        engine
1884            .add_document(&mut writer, "uuid-1", "secret", "public content", &[], 1000)
1885            .unwrap();
1886        engine.commit(&mut writer).unwrap();
1887
1888        // Try to use field syntax to access galaxy field
1889        let results = engine.search("galaxy:secret", 10).unwrap();
1890        // With sanitization, "galaxy:secret" is treated as literal text
1891        // So it should not match the galaxy field
1892        assert!(
1893            results.is_empty(),
1894            "field syntax injection should not access non-searchable fields"
1895        );
1896    }
1897
1898    #[test]
1899    fn boolean_operator_doesnt_bypass_search() {
1900        let (_tmp, engine) = open_engine();
1901        let mut writer = engine.writer().unwrap();
1902
1903        engine
1904            .add_document(
1905                &mut writer,
1906                "uuid-1",
1907                "codex",
1908                "important secret data",
1909                &[],
1910                1000,
1911            )
1912            .unwrap();
1913        engine.commit(&mut writer).unwrap();
1914
1915        // Uppercase operator words are quoted (and stripped as stopwords), so
1916        // they cannot be used to exclude or require terms: "AND secret"
1917        // reduces to a plain search for "secret" and must still match.
1918        let results = engine.search("AND secret", 10).unwrap();
1919        assert_eq!(results.len(), 1);
1920
1921        // A query made only of operators/stopwords has no terms and matches
1922        // nothing — it cannot match the whole corpus.
1923        let results = engine.search("AND OR NOT", 10).unwrap();
1924        assert!(
1925            results.is_empty(),
1926            "operator-only query must not bypass search"
1927        );
1928    }
1929}