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