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