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