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