Skip to main content

coding_agent_search/search/
query.rs

1use anyhow::{Context, Result, anyhow, bail};
2use crossbeam_channel as mpsc;
3use frankensearch::lexical::{
4    BooleanQuery, CASS_SCHEMA_HASH as FS_CASS_SCHEMA_HASH, CassFields as FsCassFields,
5    CassQueryFilters as FsCassQueryFilters, CassQueryToken as FsCassQueryToken,
6    CassSourceFilter as FsCassSourceFilter, CassWildcardPattern as FsCassWildcardPattern, Count,
7    IndexReader, IndexRecordOption, LexicalDocHit as FsLexicalDocHit,
8    LexicalSearchResult as FsLexicalSearchResult, Occur, Query, ReloadPolicy, Searcher,
9    SnippetConfig as FsSnippetConfig, TantivyDocument, Term, TermQuery, TopDocs, Value,
10    cass_build_tantivy_query as fs_cass_build_tantivy_query,
11    cass_has_boolean_operators as fs_cass_has_boolean_operators,
12    cass_open_search_reader as fs_cass_open_search_reader,
13    cass_parse_boolean_query as fs_cass_parse_boolean_query,
14    cass_sanitize_query as fs_cass_sanitize_query, load_doc as fs_load_doc,
15    render_snippet_html as fs_render_snippet_html,
16    try_build_snippet_generator as fs_try_build_snippet_generator,
17};
18use frankensearch::{
19    Cx as FsCx, InMemoryTwoTierIndex as FsInMemoryTwoTierIndex,
20    InMemoryVectorIndex as FsInMemoryVectorIndex, LexicalSearch as FsLexicalSearch,
21    QueryClass as FsQueryClass, RrfConfig as FsRrfConfig, ScoreSource as FsScoreSource,
22    ScoredResult as FsScoredResult, SearchError as FsSearchError, SearchFuture as FsSearchFuture,
23    SearchPhase as FsSearchPhase, SyncEmbedderAdapter as FsSyncEmbedderAdapter,
24    SyncTwoTierSearcher as FsSyncTwoTierSearcher, TwoTierConfig as FsTwoTierConfig,
25    TwoTierIndex as FsTwoTierIndex, TwoTierSearcher as FsTwoTierSearcher, VectorHit as FsVectorHit,
26    candidate_count as fs_candidate_count,
27    core::filter::SearchFilter as FsSearchFilter,
28    index::{
29        HNSW_DEFAULT_EF_SEARCH as FS_HNSW_DEFAULT_EF_SEARCH, HnswIndex as FsHnswIndex,
30        VectorIndex as FsVectorIndex,
31    },
32    rrf_fuse as fs_rrf_fuse,
33};
34use lru::LruCache;
35use once_cell::sync::Lazy;
36use parking_lot::RwLock;
37use std::cell::RefCell;
38use std::cmp::Ordering as CmpOrdering;
39use std::collections::{HashMap, HashSet, VecDeque};
40use std::hash::{Hash, Hasher};
41use std::num::NonZeroUsize;
42use std::path::{Path, PathBuf};
43use std::sync::atomic::{AtomicU64, Ordering};
44use std::sync::{Arc, Mutex};
45use std::time::{Duration, Instant};
46
47use frankensqlite::Connection;
48#[cfg(test)]
49use frankensqlite::compat::OptionalExtension;
50use frankensqlite::compat::{ConnectionExt, ParamValue, RowExt};
51#[cfg(test)]
52use frankensqlite::params;
53
54/// Wrapper around `frankensqlite::Connection` that implements `Send`.
55///
56/// `frankensqlite::Connection` is `!Send` because it uses `Rc` internally.
57/// However, the `Rc` values are entirely self-contained within the Connection
58/// and are not shared with any external references.  When wrapped in a `Mutex`
59/// (as in `SearchClient`), exclusive access is guaranteed, making cross-thread
60/// transfer safe.
61struct SendConnection(Connection);
62
63type TantivyContentExactKey = (i64, i64);
64type TantivyContentFallbackKey = (String, String, i64);
65type TantivyHydratedContentMaps = (
66    HashMap<TantivyContentExactKey, String>,
67    HashMap<TantivyContentFallbackKey, String>,
68);
69type SqliteFtsHydratedRow = (
70    i64,
71    Option<i64>,
72    Option<String>,
73    Option<String>,
74    Option<String>,
75    Option<String>,
76    Option<String>,
77    Option<i64>,
78);
79type SqliteFtsMessageRow = (
80    i64,
81    String,
82    String,
83    String,
84    String,
85    String,
86    Option<i64>,
87    Option<i64>,
88    Option<i64>,
89    Option<String>,
90    Option<String>,
91    Option<String>,
92);
93type SqliteMessageScanAlternative = Vec<String>;
94type SqliteMessageScanGroup = Vec<SqliteMessageScanAlternative>;
95struct SqliteMessageScanQuery {
96    include_groups: Vec<SqliteMessageScanGroup>,
97    exclude_terms: Vec<String>,
98}
99
100#[derive(Clone, Copy)]
101struct SqliteMessageScanRequest<'a> {
102    raw_query: &'a str,
103    filters: &'a SearchFilters,
104    limit: usize,
105    offset: usize,
106    field_mask: FieldMask,
107    query_match_type: MatchType,
108}
109
110#[derive(Clone, Copy, Debug, PartialEq, Eq)]
111enum SqliteFtsMatchMode {
112    Table,
113    IndexedColumns,
114}
115
116// Frankensqlite follows SQLite's bind-variable ceiling. Keep fallback
117// hydration IN-lists below that ceiling so large pages do not turn into
118// empty fallback result sets.
119const SQLITE_FTS5_HYDRATE_PARAM_CHUNK: usize = 30_000;
120const SQLITE_MAX_VARIABLE_NUMBER: usize = 32_766;
121const SQLITE_FTS5_POST_FILTER_SCAN_CHUNK: usize = 1_024;
122const SQLITE_FTS5_POST_FILTER_SCAN_LIMIT: usize = 30_000;
123const SQLITE_MESSAGE_SCAN_FALLBACK_LIMIT: usize = 30_000;
124const SEARCH_SQLITE_HYDRATION_CACHE_KIB: i64 = 4_096;
125const SEMANTIC_EXACT_CHUNK_OVERFETCH_MULTIPLIER: usize = 4;
126
127// Safety: Rc fields inside Connection are not cloned or shared externally.
128// The Mutex<Option<SendConnection>> in SearchClient ensures exclusive access.
129unsafe impl Send for SendConnection {}
130
131impl std::ops::Deref for SendConnection {
132    type Target = Connection;
133    fn deref(&self) -> &Connection {
134        &self.0
135    }
136}
137
138fn open_search_hydration_sqlite(path: &Path, timeout: Duration) -> Result<Connection> {
139    let conn =
140        crate::storage::sqlite::open_franken_raw_readonly_connection_with_timeout(path, timeout)?;
141    conn.execute("PRAGMA query_only = 1;")
142        .with_context(|| "setting search hydration query_only")?;
143    conn.execute("PRAGMA busy_timeout = 5000;")
144        .with_context(|| "setting search hydration busy_timeout")?;
145    conn.execute(&format!(
146        "PRAGMA cache_size = -{SEARCH_SQLITE_HYDRATION_CACHE_KIB};"
147    ))
148    .with_context(|| "setting search hydration cache_size")?;
149    Ok(conn)
150}
151
152/// NFC-normalize a query string before sanitization so that decomposed
153/// Unicode (NFD — common on macOS keyboard input) matches NFC-indexed content
154/// produced by `DefaultCanonicalizer`.
155fn nfc_sanitize_query(raw: &str) -> String {
156    use unicode_normalization::UnicodeNormalization;
157    let nfc: String = raw.nfc().collect();
158    fs_cass_sanitize_query(&nfc)
159}
160
161fn franken_query_map_collect_retry<T, F>(
162    conn: &Connection,
163    sql: &str,
164    params: &[ParamValue],
165    map: F,
166) -> Result<Vec<T>, frankensqlite::FrankenError>
167where
168    F: Copy + Fn(&frankensqlite::Row) -> Result<T, frankensqlite::FrankenError>,
169{
170    let deadline = Instant::now() + Duration::from_secs(2);
171    let mut backoff = Duration::from_millis(4);
172    loop {
173        match conn.query_map_collect(sql, params, |row| map(row)) {
174            Ok(values) => return Ok(values),
175            Err(err) if crate::storage::sqlite::retryable_franken_error(&err) => {
176                let now = Instant::now();
177                if now >= deadline {
178                    return Err(err);
179                }
180                let remaining = deadline.saturating_duration_since(now);
181                crate::storage::sqlite::sleep_with_franken_retry_backoff(
182                    &mut backoff,
183                    remaining,
184                    Duration::from_millis(64),
185                );
186            }
187            Err(err) => return Err(err),
188        }
189    }
190}
191
192fn hydrate_message_content_by_conversation(
193    conn: &Connection,
194    requests: &[TantivyContentExactKey],
195) -> Result<HashMap<TantivyContentExactKey, String>> {
196    if requests.is_empty() {
197        return Ok(HashMap::new());
198    }
199
200    let mut wanted_by_conversation: HashMap<i64, HashSet<i64>> = HashMap::new();
201    for &(conversation_id, line_idx) in requests {
202        wanted_by_conversation
203            .entry(conversation_id)
204            .or_default()
205            .insert(line_idx);
206    }
207
208    let mut conversation_ids = wanted_by_conversation.keys().copied().collect::<Vec<_>>();
209    conversation_ids.sort_unstable();
210    let mut hydrated = HashMap::with_capacity(requests.len());
211
212    for conversation_id in conversation_ids {
213        let Some(wanted_indices) = wanted_by_conversation.get(&conversation_id) else {
214            continue;
215        };
216        let mut wanted_indices = wanted_indices.iter().copied().collect::<Vec<_>>();
217        wanted_indices.sort_unstable();
218        let placeholders = sql_placeholders(wanted_indices.len());
219        let sql = format!(
220            "SELECT m.conversation_id, m.idx, m.content
221             FROM messages m INDEXED BY sqlite_autoindex_messages_1
222             WHERE m.conversation_id = ? AND m.idx IN ({placeholders})
223             ORDER BY m.idx"
224        );
225        let mut params = Vec::with_capacity(wanted_indices.len() + 1);
226        params.push(ParamValue::from(conversation_id));
227        params.extend(wanted_indices.iter().copied().map(ParamValue::from));
228        let rows: Vec<(i64, i64, String)> =
229            franken_query_map_collect_retry(conn, &sql, &params, |row| {
230                Ok((row.get_typed(0)?, row.get_typed(1)?, row.get_typed(2)?))
231            })?;
232        for (conversation_id, line_idx, content) in rows {
233            hydrated.insert((conversation_id, line_idx), content);
234        }
235    }
236
237    Ok(hydrated)
238}
239
240fn semantic_message_id_from_db(message_id: i64) -> std::io::Result<u64> {
241    u64::try_from(message_id).map_err(|_| std::io::Error::other("negative message_id"))
242}
243
244fn semantic_doc_component_id_from_db(raw: Option<i64>) -> u32 {
245    raw.map(|value| u32::try_from(value.max(0)).unwrap_or(u32::MAX))
246        .unwrap_or(0)
247}
248
249use crate::search::canonicalize::{canonicalize_for_embedding, content_hash, is_search_noise_text};
250use crate::search::embedder::Embedder;
251use crate::search::vector_index::{
252    ROLE_USER, SemanticDocId, SemanticFilter, SemanticFilterMaps, VectorIndex, VectorSearchResult,
253    parse_semantic_doc_id, role_code_from_str,
254};
255use crate::sources::provenance::SourceFilter;
256
257// ============================================================================
258// String Interner for Cache Keys (Opt 2.3)
259// ============================================================================
260//
261// Reduces memory usage and allocation overhead for repeated cache key patterns.
262// Uses LRU eviction to bound memory, Arc<str> for cheap cloning.
263
264/// Thread-safe string interner with bounded memory via LRU eviction.
265/// Uses LruCache<Arc<str>, Arc<str>> where key and value are the same Arc,
266/// enabling O(1) lookup via Borrow<str> trait while preserving LRU semantics.
267pub struct StringInterner {
268    cache: RwLock<LruCache<Arc<str>, Arc<str>>>,
269}
270
271impl StringInterner {
272    /// Create a new interner with the given capacity.
273    pub fn new(capacity: usize) -> Self {
274        Self {
275            cache: RwLock::new(LruCache::new(
276                NonZeroUsize::new(capacity).expect("capacity must be > 0"),
277            )),
278        }
279    }
280
281    /// Intern a string, returning a shared Arc<str>.
282    /// If the string is already interned, returns the existing Arc.
283    /// Otherwise, creates a new Arc and caches it.
284    ///
285    /// Performance: O(1) lookup via LruCache's internal HashMap.
286    pub fn intern(&self, s: &str) -> Arc<str> {
287        // Fast path: read-only check for existing entry (O(1) lookup)
288        {
289            let cache = self.cache.read();
290            // LruCache::peek allows O(1) lookup without updating LRU order
291            // Arc<str>: Borrow<str> enables lookup by &str
292            if let Some(arc) = cache.peek(s) {
293                return Arc::clone(arc);
294            }
295        }
296
297        // Slow path: acquire write lock and insert
298        let mut cache = self.cache.write();
299
300        // Double-check after acquiring write lock (another thread may have inserted)
301        // Use get() here to update LRU order since we're about to use this entry
302        if let Some(arc) = cache.get(s) {
303            return Arc::clone(arc);
304        }
305
306        // Create new Arc<str> and insert (same Arc as key and value)
307        let arc: Arc<str> = Arc::from(s);
308        cache.put(Arc::clone(&arc), Arc::clone(&arc));
309        arc
310    }
311
312    /// Get the current number of interned strings.
313    #[allow(dead_code)]
314    pub fn len(&self) -> usize {
315        self.cache.read().len()
316    }
317
318    /// Check if the interner is empty.
319    #[allow(dead_code)]
320    pub fn is_empty(&self) -> bool {
321        self.cache.read().is_empty()
322    }
323}
324
325/// Global cache key interner with 10K entry limit (~1MB for typical keys).
326/// Uses Lazy initialization for thread-safe singleton.
327static CACHE_KEY_INTERNER: Lazy<StringInterner> = Lazy::new(|| StringInterner::new(10_000));
328
329/// Intern a cache key string, returning a shared Arc<str>.
330#[inline]
331fn intern_cache_key(s: &str) -> Arc<str> {
332    CACHE_KEY_INTERNER.intern(s)
333}
334
335// ============================================================================
336// SQL Placeholder Builder (Opt 4.5: Pre-sized String Buffers)
337// ============================================================================
338
339/// Build a comma-separated list of SQL placeholders with pre-allocated capacity.
340///
341/// For `n` items, produces "?,?,?..." (n "?" with n-1 ",").
342/// Uses pre-sized String to avoid reallocations.
343///
344/// # Examples
345/// ```ignore
346/// assert_eq!(sql_placeholders(0), "");
347/// assert_eq!(sql_placeholders(1), "?");
348/// assert_eq!(sql_placeholders(3), "?,?,?");
349/// ```
350#[inline]
351pub fn sql_placeholders(count: usize) -> String {
352    if count == 0 {
353        return String::new();
354    }
355    // Capacity: n "?" + (n-1) "," = 2n - 1
356    let capacity = count.saturating_mul(2).saturating_sub(1);
357    let mut result = String::with_capacity(capacity);
358    for i in 0..count {
359        if i > 0 {
360            result.push(',');
361        }
362        result.push('?');
363    }
364    result
365}
366
367#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
368pub struct SearchFilters {
369    pub agents: HashSet<String>,
370    pub workspaces: HashSet<String>,
371    pub created_from: Option<i64>,
372    pub created_to: Option<i64>,
373    /// Filter by conversation source (local, remote, or specific source ID)
374    #[serde(skip_serializing_if = "SourceFilter::is_all")]
375    pub source_filter: SourceFilter,
376    /// Filter to specific session source paths (for chained searches)
377    #[serde(skip_serializing_if = "HashSet::is_empty")]
378    pub session_paths: HashSet<String>,
379}
380
381#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, clap::ValueEnum)]
382#[serde(rename_all = "snake_case")]
383pub enum SearchMode {
384    /// Lexical (BM25) search - keyword matching
385    Lexical,
386    /// Semantic search - embedding similarity
387    Semantic,
388    /// Hybrid-preferred search - RRF fusion of lexical and semantic when available
389    #[default]
390    Hybrid,
391}
392
393impl SearchMode {
394    pub fn next(self) -> Self {
395        match self {
396            SearchMode::Lexical => SearchMode::Semantic,
397            SearchMode::Semantic => SearchMode::Hybrid,
398            SearchMode::Hybrid => SearchMode::Lexical,
399        }
400    }
401}
402
403/// Execution strategy for semantic search.
404///
405/// `Single` preserves existing exact vector behavior.
406/// Other modes attempt to use frankensearch's sync two-tier searcher when a
407/// compatible in-memory two-tier index is available; otherwise they fall back
408/// to `Single`.
409#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
410#[serde(rename_all = "snake_case")]
411pub enum SemanticTierMode {
412    #[default]
413    Single,
414    Progressive,
415    FastOnly,
416    QualityOnly,
417}
418
419impl SemanticTierMode {
420    const fn wants_two_tier(self) -> bool {
421        !matches!(self, Self::Single)
422    }
423
424    fn to_frankensearch_config(self) -> FsTwoTierConfig {
425        let mut config = frankensearch_two_tier_config();
426        match self {
427            Self::Single | Self::Progressive => {}
428            Self::FastOnly => {
429                config.fast_only = true;
430            }
431            Self::QualityOnly => {
432                config.fast_only = false;
433                config.quality_weight = 1.0;
434            }
435        }
436        config
437    }
438}
439
440const PROGRESSIVE_EMBEDDING_CACHE_CAPACITY: usize = 64;
441const ANN_CANDIDATE_MULTIPLIER: usize = 4;
442const HYBRID_NO_LIMIT_PLANNING_WINDOW: usize = 64;
443const HYBRID_NO_LIMIT_SEMANTIC_CAP: usize = 2048;
444const AUTOMATIC_WILDCARD_FALLBACK_MAX_TOKEN_CHARS: usize = 16;
445
446/// Upper bound on how many documents a `limit == 0` ("no limit") search is
447/// allowed to materialize. Each `SearchHit` carries the full message
448/// `content` string (roughly 80 KB p99 in real corpora), so an unlimited
449/// search on a ~500k-row user history can easily allocate tens of
450/// gigabytes of heap AND drive sustained multi-GB/s reads off the Tantivy
451/// `.store` file and SQLite rows, crushing the whole machine.
452///
453/// The cap is computed dynamically from `/proc/meminfo` `MemAvailable`
454/// (Linux) so a dev box with 512 GB of RAM is allowed to return ~200k
455/// rows while a 2 GB laptop stops at the floor. The cap translates
456/// directly into an upper bound on disk-I/O per query because the
457/// per-hit hydration loop in `fs_load_doc()` / `hydrate_tantivy_hit_contents`
458/// does ~11 `.store` field reads per hit plus up to one SQLite row
459/// fetch — bounding hits bounds bytes read.
460///
461/// Override with `CASS_SEARCH_NO_LIMIT_CAP=<hits>` or
462/// `CASS_SEARCH_NO_LIMIT_BYTES=<bytes>`. Both overrides are still
463/// clamped to `[NO_LIMIT_RESULT_MIN, NO_LIMIT_RESULT_MAX]` on the way
464/// out — an unclamped override would re-open the same "crush the
465/// machine" hole this cap exists to close.
466pub const NO_LIMIT_RESULT_MIN: usize = 1_000;
467pub const NO_LIMIT_RESULT_MAX: usize = 1_000_000;
468
469/// Approximate on-heap size per `SearchHit` used to translate a
470/// memory budget into a hit-count cap. Kept conservatively high
471/// (p99-ish message content + metadata strings) so real workloads
472/// stay well under the computed bytes budget.
473const AVG_HIT_BYTES: u64 = 80 * 1024;
474
475/// Absolute ceiling on the memory budget for a single "no limit"
476/// search, regardless of how much RAM is free. 16 GiB keeps sustained
477/// disk reads on a single query bounded to <10 s on a 2 GB/s NVMe —
478/// long enough for a power user to wait, short enough not to block
479/// other workloads on a shared box.
480const NO_LIMIT_BYTES_CEILING: u64 = 16 * 1024 * 1024 * 1024;
481
482/// Floor on the memory budget. On a 2 GB laptop we still let a
483/// single "no limit" query use ~256 MiB — small enough to survive,
484/// large enough to be useful.
485const NO_LIMIT_BYTES_FLOOR: u64 = 256 * 1024 * 1024;
486
487/// Fraction of `MemAvailable` we're willing to spend on a single
488/// "no limit" search response. 1/16 leaves 93% of RAM for everything
489/// else on the box.
490const NO_LIMIT_RAM_DIVISOR: u64 = 16;
491
492fn available_memory_bytes() -> Option<u64> {
493    let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
494    for line in meminfo.lines() {
495        if let Some(rest) = line.strip_prefix("MemAvailable:") {
496            let kb: u64 = rest.split_whitespace().next()?.parse().ok()?;
497            return Some(kb.saturating_mul(1024));
498        }
499    }
500    None
501}
502
503fn no_limit_result_cap() -> usize {
504    static CAP: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
505    *CAP.get_or_init(|| {
506        compute_no_limit_result_cap_from(
507            std::env::var("CASS_SEARCH_NO_LIMIT_CAP").ok(),
508            std::env::var("CASS_SEARCH_NO_LIMIT_BYTES").ok(),
509            available_memory_bytes(),
510        )
511    })
512}
513
514/// Pure version of the cap-computation, with env + `/proc/meminfo`
515/// passed in as arguments. Kept pure so unit tests can drive it
516/// deterministically without mutating the process-global env (which
517/// would race with every other parallel test that reads env, including
518/// the search-query pipeline tests that transitively hit
519/// `no_limit_result_cap()`).
520fn compute_no_limit_result_cap_from(
521    cap_env: Option<String>,
522    bytes_env: Option<String>,
523    available_bytes: Option<u64>,
524) -> usize {
525    // Explicit hit-count override takes priority, but is still clamped
526    // to `[MIN, MAX]` so a typo like `CASS_SEARCH_NO_LIMIT_CAP=10000000000`
527    // can't reopen the unbounded-result bug this cap closes.
528    if let Some(hits) = cap_env
529        .and_then(|v| v.parse::<usize>().ok())
530        .filter(|v| *v > 0)
531    {
532        return hits.clamp(NO_LIMIT_RESULT_MIN, NO_LIMIT_RESULT_MAX);
533    }
534
535    let budget_bytes = no_limit_budget_bytes(bytes_env, available_bytes);
536    let hits = (budget_bytes / AVG_HIT_BYTES) as usize;
537    hits.clamp(NO_LIMIT_RESULT_MIN, NO_LIMIT_RESULT_MAX)
538}
539
540fn no_limit_budget_bytes(bytes_env: Option<String>, available_bytes: Option<u64>) -> u64 {
541    bytes_env
542        .and_then(|v| v.parse::<u64>().ok())
543        .filter(|v| *v > 0)
544        .or_else(|| no_limit_available_memory_budget(available_bytes))
545        .unwrap_or(NO_LIMIT_BYTES_FLOOR)
546}
547
548fn no_limit_available_memory_budget(available_bytes: Option<u64>) -> Option<u64> {
549    available_bytes.map(|avail| {
550        (avail / NO_LIMIT_RAM_DIVISOR).clamp(NO_LIMIT_BYTES_FLOOR, NO_LIMIT_BYTES_CEILING)
551    })
552}
553
554static FRANKENSEARCH_TWO_TIER_CONFIG: Lazy<FsTwoTierConfig> =
555    Lazy::new(|| FsTwoTierConfig::optimized().with_env_overrides());
556
557fn frankensearch_two_tier_config() -> FsTwoTierConfig {
558    FRANKENSEARCH_TWO_TIER_CONFIG.clone()
559}
560
561#[inline]
562const fn progressive_phase_fetch_limit(limit: usize) -> usize {
563    let limit = if limit == 0 { 1 } else { limit };
564    limit.saturating_mul(3)
565}
566
567#[derive(Debug, Clone, Copy, PartialEq, Eq)]
568struct HybridCandidateBudget {
569    lexical_candidates: usize,
570    semantic_candidates: usize,
571}
572
573#[inline]
574const fn hybrid_stage_multipliers(query_class: FsQueryClass) -> (usize, usize) {
575    match query_class {
576        // Identifier-heavy queries: prioritize lexical precision.
577        FsQueryClass::Identifier => (6, 2),
578        // Keyword queries: balanced lexical/semantic retrieval.
579        FsQueryClass::ShortKeyword => (4, 4),
580        // Natural language queries: prioritize semantic retrieval.
581        FsQueryClass::NaturalLanguage => (2, 8),
582        // Empty query should short-circuit before budgeting.
583        FsQueryClass::Empty => (0, 0),
584    }
585}
586
587#[inline]
588fn hybrid_candidate_budget(
589    query: &str,
590    requested_limit: usize,
591    effective_limit: usize,
592    offset: usize,
593    total_docs: usize,
594) -> HybridCandidateBudget {
595    let query_class = FsQueryClass::classify(query);
596    let (lex_mult, sem_mult) = hybrid_stage_multipliers(query_class);
597    let total_docs = total_docs.max(1);
598
599    // When no explicit limit is requested, keep "no limit" output semantics,
600    // but bound semantic fanout so hybrid doesn't try to score the entire corpus.
601    if requested_limit == 0 {
602        let planning_window = HYBRID_NO_LIMIT_PLANNING_WINDOW.max(offset.saturating_add(1));
603        // Cap the lexical fanout — without a ceiling a "no limit" hybrid
604        // query on a ~500k-row corpus asks Tantivy to materialize a
605        // `Vec<SearchHit>` the size of the entire index, which is the
606        // unboundedness fixed by `no_limit_result_cap()`.
607        let lexical = effective_limit.min(total_docs).min(no_limit_result_cap());
608        // Semantic fan-out can be wide in principle, but must never
609        // exceed the lexical cap — the pipeline fuses lexical+semantic
610        // candidates and returning more semantic candidates than
611        // lexical is both wasteful (semantic is the expensive tier)
612        // and breaks the pre-cap invariant that `semantic ≤ lexical`.
613        // On tiny boxes where `no_limit_result_cap()` hits the floor,
614        // this pulls semantic down with it.
615        let semantic = fs_candidate_count(planning_window, 0, sem_mult)
616            .max(planning_window)
617            .min(HYBRID_NO_LIMIT_SEMANTIC_CAP.max(offset.saturating_add(planning_window)))
618            .min(total_docs)
619            .min(lexical);
620        return HybridCandidateBudget {
621            lexical_candidates: lexical,
622            semantic_candidates: semantic,
623        };
624    }
625
626    let lexical = fs_candidate_count(requested_limit, offset, lex_mult.max(1))
627        .max(requested_limit.saturating_add(offset))
628        .min(total_docs);
629    let semantic = fs_candidate_count(requested_limit, offset, sem_mult.max(1))
630        .max(requested_limit.saturating_add(offset))
631        .min(total_docs);
632
633    HybridCandidateBudget {
634        lexical_candidates: lexical,
635        semantic_candidates: semantic,
636    }
637}
638
639// ============================================================================
640// Query Explanation types (--explain flag support)
641// ============================================================================
642
643/// Classification of query type for explanation purposes
644#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
645#[serde(rename_all = "snake_case")]
646pub enum QueryType {
647    /// Single term without operators
648    Simple,
649    /// Quoted phrase ("exact match")
650    Phrase,
651    /// Contains AND/OR/NOT operators
652    Boolean,
653    /// Contains wildcards (* prefix/suffix)
654    Wildcard,
655    /// Has time/agent/workspace filters
656    Filtered,
657    /// Empty query
658    Empty,
659}
660
661/// How the index will execute this query
662#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
663#[serde(rename_all = "snake_case")]
664pub enum IndexStrategy {
665    /// Fast path: edge n-gram prefix matching
666    EdgeNgram,
667    /// Regex scan for leading wildcards (*foo)
668    RegexScan,
669    /// Combined boolean query execution
670    BooleanCombination,
671    /// Range scan for time filters
672    RangeScan,
673    /// All documents (empty query)
674    FullScan,
675}
676
677/// Rough complexity indicator for query execution
678#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
679#[serde(rename_all = "snake_case")]
680pub enum QueryCost {
681    /// Very fast (under 10ms typical)
682    Low,
683    /// Moderate (10-100ms typical)
684    Medium,
685    /// Expensive (100ms+ typical, may scan many documents)
686    High,
687}
688
689/// Sub-component of a parsed term
690#[derive(Debug, Clone, serde::Serialize)]
691pub struct ParsedSubTerm {
692    pub text: String,
693    pub pattern: String,
694}
695
696/// Parsed term from the query
697#[derive(Debug, Clone, serde::Serialize)]
698pub struct ParsedTerm {
699    /// Original term text
700    pub text: String,
701    /// Whether this is negated (NOT/-)
702    pub negated: bool,
703    /// Sub-terms if split (implicit AND)
704    pub subterms: Vec<ParsedSubTerm>,
705}
706
707/// Parsed structure of the query
708#[derive(Debug, Clone, Default, serde::Serialize)]
709pub struct ParsedQuery {
710    /// Individual terms extracted
711    pub terms: Vec<ParsedTerm>,
712    /// Phrases (quoted strings)
713    pub phrases: Vec<String>,
714    /// Boolean operators used
715    pub operators: Vec<String>,
716    /// Whether implicit AND is used between terms
717    pub implicit_and: bool,
718}
719
720/// Comprehensive query explanation for debugging and understanding search behavior
721#[derive(Debug, Clone, serde::Serialize)]
722pub struct QueryExplanation {
723    /// Exact input string
724    pub original_query: String,
725    /// Sanitized query after normalization
726    pub sanitized_query: String,
727    /// Structured breakdown of query components
728    pub parsed: ParsedQuery,
729    /// High-level classification
730    pub query_type: QueryType,
731    /// How the index will execute this query
732    pub index_strategy: IndexStrategy,
733    /// Whether wildcard fallback was/will be applied
734    pub wildcard_applied: bool,
735    /// Rough complexity indicator
736    pub estimated_cost: QueryCost,
737    /// Active filters summary
738    pub filters_summary: FiltersSummary,
739    /// Any issues or suggestions
740    pub warnings: Vec<String>,
741}
742
743/// Summary of active filters for explanation
744#[derive(Debug, Clone, Default, serde::Serialize)]
745pub struct FiltersSummary {
746    /// Number of agent filters
747    pub agent_count: usize,
748    /// Number of workspace filters
749    pub workspace_count: usize,
750    /// Whether time range is applied
751    pub has_time_filter: bool,
752    /// Human-readable filter description
753    pub description: Option<String>,
754}
755
756impl QueryExplanation {
757    /// Build explanation from query string and filters
758    pub fn analyze(query: &str, filters: &SearchFilters) -> Self {
759        let sanitized = nfc_sanitize_query(query);
760        // Parse original query to preserve quotes for phrases
761        let tokens = fs_cass_parse_boolean_query(query);
762
763        // Extract terms, phrases, and operators
764        let mut parsed = ParsedQuery::default();
765        let mut has_explicit_operator = false;
766        let mut next_negated = false;
767
768        for token in &tokens {
769            match token {
770                FsCassQueryToken::Term(t) => {
771                    let parts: Vec<String> = nfc_sanitize_query(t)
772                        .split_whitespace()
773                        .map(|s| s.to_string())
774                        .collect();
775                    if parts.is_empty() {
776                        next_negated = false;
777                        continue;
778                    }
779                    let mut subterms = Vec::new();
780                    for part in parts {
781                        let pattern = FsCassWildcardPattern::parse(&part);
782                        let pattern_str = match &pattern {
783                            FsCassWildcardPattern::Exact(_) => "exact",
784                            FsCassWildcardPattern::Prefix(_) => "prefix (*)",
785                            FsCassWildcardPattern::Suffix(_) => "suffix (*)",
786                            FsCassWildcardPattern::Substring(_) => "substring (*)",
787                            FsCassWildcardPattern::Complex(_) => "complex (*)",
788                        };
789                        subterms.push(ParsedSubTerm {
790                            text: part,
791                            pattern: pattern_str.to_string(),
792                        });
793                    }
794                    parsed.terms.push(ParsedTerm {
795                        text: t.clone(),
796                        negated: next_negated,
797                        subterms,
798                    });
799                    next_negated = false;
800                }
801                FsCassQueryToken::Phrase(p) => {
802                    let parts: Vec<String> = nfc_sanitize_query(p)
803                        .split_whitespace()
804                        .map(|s| s.trim_matches('*').to_lowercase())
805                        .filter(|s| !s.is_empty())
806                        .collect();
807                    if !parts.is_empty() {
808                        parsed.phrases.push(parts.join(" "));
809                    }
810                    next_negated = false;
811                }
812                FsCassQueryToken::And => {
813                    parsed.operators.push("AND".to_string());
814                    has_explicit_operator = true;
815                }
816                FsCassQueryToken::Or => {
817                    parsed.operators.push("OR".to_string());
818                    has_explicit_operator = true;
819                }
820                FsCassQueryToken::Not => {
821                    parsed.operators.push("NOT".to_string());
822                    has_explicit_operator = true;
823                    next_negated = true;
824                }
825            }
826        }
827
828        // Implicit AND between terms if no explicit operators
829        parsed.implicit_and = !has_explicit_operator && parsed.terms.len() > 1;
830
831        // Determine query type
832        let query_type = Self::classify_query(&parsed, filters, &sanitized);
833
834        // Determine index strategy
835        let index_strategy = Self::determine_strategy(&parsed, &sanitized);
836
837        // Estimate cost
838        let estimated_cost = Self::estimate_cost(&parsed, &index_strategy, filters);
839
840        // Build filters summary
841        let filters_summary = Self::summarize_filters(filters);
842
843        // Generate warnings
844        let warnings = Self::generate_warnings(&parsed, &sanitized, filters);
845
846        Self {
847            original_query: query.to_string(),
848            sanitized_query: sanitized,
849            parsed,
850            query_type,
851            index_strategy,
852            wildcard_applied: false, // Set later by search_with_fallback
853            estimated_cost,
854            filters_summary,
855            warnings,
856        }
857    }
858
859    fn classify_query(parsed: &ParsedQuery, filters: &SearchFilters, sanitized: &str) -> QueryType {
860        if sanitized.trim().is_empty() {
861            return QueryType::Empty;
862        }
863
864        // Check for filters first (they modify everything)
865        let has_filters = !filters.agents.is_empty()
866            || !filters.workspaces.is_empty()
867            || filters.created_from.is_some()
868            || filters.created_to.is_some()
869            || !filters.source_filter.is_all();
870
871        if has_filters {
872            return QueryType::Filtered;
873        }
874
875        // Check for boolean operators
876        if !parsed.operators.is_empty() {
877            return QueryType::Boolean;
878        }
879
880        // Check for phrases
881        if !parsed.phrases.is_empty() {
882            return QueryType::Phrase;
883        }
884
885        // Check for wildcards
886        let has_wildcards = parsed
887            .terms
888            .iter()
889            .flat_map(|t| &t.subterms)
890            .any(|t| t.pattern != "exact");
891        if has_wildcards {
892            return QueryType::Wildcard;
893        }
894
895        QueryType::Simple
896    }
897
898    fn determine_strategy(parsed: &ParsedQuery, sanitized: &str) -> IndexStrategy {
899        if sanitized.trim().is_empty() {
900            return IndexStrategy::FullScan;
901        }
902
903        // Check for leading wildcards (requires regex)
904        let has_leading_wildcard = parsed
905            .terms
906            .iter()
907            .flat_map(|t| &t.subterms)
908            .any(|t| t.pattern == "suffix (*)" || t.pattern == "substring (*)");
909
910        if has_leading_wildcard {
911            return IndexStrategy::RegexScan;
912        }
913
914        // Boolean queries use combination strategy
915        // Also if any single term is split into multiple subterms (e.g. "foo.bar" -> "foo", "bar")
916        let has_compound_terms = parsed.terms.iter().any(|t| t.subterms.len() > 1);
917
918        if !parsed.operators.is_empty()
919            || parsed.terms.len() > 1
920            || !parsed.phrases.is_empty()
921            || has_compound_terms
922        {
923            return IndexStrategy::BooleanCombination;
924        }
925
926        // Single term uses edge n-gram
927        IndexStrategy::EdgeNgram
928    }
929
930    fn estimate_cost(
931        parsed: &ParsedQuery,
932        strategy: &IndexStrategy,
933        filters: &SearchFilters,
934    ) -> QueryCost {
935        // Regex scans are always expensive
936        if matches!(strategy, IndexStrategy::RegexScan) {
937            return QueryCost::High;
938        }
939
940        // Full scans are expensive
941        if matches!(strategy, IndexStrategy::FullScan) {
942            return QueryCost::High;
943        }
944
945        // Time range filters add cost
946        let has_time_filter = filters.created_from.is_some() || filters.created_to.is_some();
947
948        // Count complexity factors
949        let term_count: usize = parsed.terms.iter().map(|t| t.subterms.len()).sum();
950        let operator_count = parsed.operators.len();
951        let phrase_count = parsed.phrases.len();
952
953        let complexity = term_count + operator_count * 2 + phrase_count * 2;
954
955        if complexity > 6 || has_time_filter {
956            QueryCost::High
957        } else if complexity > 2 {
958            QueryCost::Medium
959        } else {
960            QueryCost::Low
961        }
962    }
963
964    fn summarize_filters(filters: &SearchFilters) -> FiltersSummary {
965        let agent_count = filters.agents.len();
966        let workspace_count = filters.workspaces.len();
967        let has_time_filter = filters.created_from.is_some() || filters.created_to.is_some();
968
969        let mut parts = Vec::new();
970        if agent_count > 0 {
971            parts.push(format!(
972                "{} agent{}",
973                agent_count,
974                if agent_count > 1 { "s" } else { "" }
975            ));
976        }
977        if workspace_count > 0 {
978            parts.push(format!(
979                "{} workspace{}",
980                workspace_count,
981                if workspace_count > 1 { "s" } else { "" }
982            ));
983        }
984        if has_time_filter {
985            parts.push("time range".to_string());
986        }
987
988        let description = if parts.is_empty() {
989            None
990        } else {
991            Some(format!("Filtering by: {}", parts.join(", ")))
992        };
993
994        FiltersSummary {
995            agent_count,
996            workspace_count,
997            has_time_filter,
998            description,
999        }
1000    }
1001
1002    fn generate_warnings(
1003        parsed: &ParsedQuery,
1004        sanitized: &str,
1005        filters: &SearchFilters,
1006    ) -> Vec<String> {
1007        let mut warnings = Vec::new();
1008
1009        // Warn about leading wildcards
1010        let has_leading_wildcard = parsed
1011            .terms
1012            .iter()
1013            .flat_map(|t| &t.subterms)
1014            .any(|t| t.pattern == "suffix (*)" || t.pattern == "substring (*)");
1015        if has_leading_wildcard {
1016            warnings.push(
1017                "Leading wildcards (*foo) require regex scan and may be slow on large indexes"
1018                    .to_string(),
1019            );
1020        }
1021
1022        // Warn about very short terms
1023        for term in &parsed.terms {
1024            for sub in &term.subterms {
1025                if sub.text.trim_matches('*').len() < 2 {
1026                    warnings.push(format!(
1027                        "Very short term '{}' may match many documents",
1028                        sub.text
1029                    ));
1030                }
1031            }
1032        }
1033
1034        // Warn about empty query
1035        if sanitized.trim().is_empty() {
1036            warnings.push("Empty query will return all documents (expensive)".to_string());
1037        }
1038
1039        // Warn about complex boolean queries
1040        if parsed.operators.len() > 3 {
1041            warnings.push("Complex boolean query may have unexpected precedence".to_string());
1042        }
1043
1044        // Warn about narrow filters that might miss results
1045        if let Some(agent) = filters.agents.iter().next()
1046            && filters.agents.len() == 1
1047            && filters.workspaces.is_empty()
1048        {
1049            warnings.push(format!(
1050                "Searching only in agent '{}' - results from other agents will be excluded",
1051                agent
1052            ));
1053        }
1054
1055        warnings
1056    }
1057
1058    /// Update `wildcard_applied` flag (called after `search_with_fallback`)
1059    pub fn with_wildcard_fallback(mut self, applied: bool) -> Self {
1060        self.wildcard_applied = applied;
1061        if applied
1062            && !self
1063                .warnings
1064                .iter()
1065                .any(|w| w.contains("wildcard fallback"))
1066        {
1067            self.warnings.push(
1068                "Wildcard fallback was applied automatically due to sparse exact matches"
1069                    .to_string(),
1070            );
1071        }
1072        self
1073    }
1074}
1075
1076/// Indicates how a search result matched the query.
1077/// Used for ranking: exact matches rank higher than wildcard matches.
1078#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
1079#[serde(rename_all = "snake_case")]
1080pub enum MatchType {
1081    /// No wildcards - matched via exact term or edge n-gram prefix
1082    #[default]
1083    Exact,
1084    /// Matched via trailing wildcard (foo*)
1085    Prefix,
1086    /// Matched via leading wildcard (*foo) - uses regex
1087    Suffix,
1088    /// Matched via both wildcards (*foo*) - uses regex
1089    Substring,
1090    /// Matched via complex wildcard (e.g. f*o) - uses regex
1091    Wildcard,
1092    /// Matched via automatic wildcard fallback when exact search was sparse
1093    ImplicitWildcard,
1094}
1095
1096impl MatchType {
1097    /// Returns a quality factor for ranking (1.0 = best, lower = less precise match)
1098    pub fn quality_factor(self) -> f32 {
1099        match self {
1100            MatchType::Exact => 1.0,
1101            MatchType::Prefix => 0.9,
1102            MatchType::Suffix => 0.8,
1103            MatchType::Substring => 0.7,
1104            MatchType::Wildcard => 0.65,
1105            MatchType::ImplicitWildcard => 0.6,
1106        }
1107    }
1108}
1109
1110/// Type of suggestion for did-you-mean
1111#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
1112#[serde(rename_all = "snake_case")]
1113pub enum SuggestionKind {
1114    /// Typo correction (Levenshtein distance)
1115    SpellingFix,
1116    /// Try with wildcard prefix/suffix
1117    WildcardQuery,
1118    /// Remove restrictive filter
1119    RemoveFilter,
1120    /// Try different agent
1121    AlternateAgent,
1122    /// Broaden date range
1123    BroaderDateRange,
1124}
1125
1126/// A "did-you-mean" suggestion when search returns zero hits.
1127#[derive(Debug, Clone, serde::Serialize)]
1128pub struct QuerySuggestion {
1129    /// What kind of suggestion this is
1130    pub kind: SuggestionKind,
1131    /// Human-readable description (e.g., "Did you mean: 'codex'?")
1132    pub message: String,
1133    /// The suggested query string (if query change)
1134    pub suggested_query: Option<String>,
1135    /// Suggested filters to apply (replaces current filters if Some)
1136    pub suggested_filters: Option<SearchFilters>,
1137    /// Shortcut key (1, 2, or 3) for quick apply in TUI
1138    pub shortcut: Option<u8>,
1139}
1140
1141impl QuerySuggestion {
1142    fn spelling(_query: &str, corrected: &str) -> Self {
1143        Self {
1144            kind: SuggestionKind::SpellingFix,
1145            message: format!("Did you mean: \"{corrected}\"?"),
1146            suggested_query: Some(corrected.to_string()),
1147            suggested_filters: None,
1148            shortcut: None,
1149        }
1150    }
1151
1152    fn wildcard(query: &str) -> Self {
1153        let wildcard_query = format!("*{}*", query.trim_matches('*'));
1154        Self {
1155            kind: SuggestionKind::WildcardQuery,
1156            message: format!("Try broader search: \"{wildcard_query}\""),
1157            suggested_query: Some(wildcard_query),
1158            suggested_filters: None,
1159            shortcut: None,
1160        }
1161    }
1162
1163    fn remove_agent_filter(current_agent: &str, current_filters: &SearchFilters) -> Self {
1164        // Clone current filters and only clear the agent filter, preserving
1165        // workspace and date range filters
1166        let mut filters = current_filters.clone();
1167        filters.agents.clear();
1168        Self {
1169            kind: SuggestionKind::RemoveFilter,
1170            message: format!("Remove agent filter (currently: {current_agent})"),
1171            suggested_query: None,
1172            suggested_filters: Some(filters),
1173            shortcut: None,
1174        }
1175    }
1176
1177    fn try_agent(agent_slug: &str) -> Self {
1178        let mut filters = SearchFilters::default();
1179        filters.agents.insert(agent_slug.to_string());
1180        Self {
1181            kind: SuggestionKind::AlternateAgent,
1182            message: format!("Try searching in: {agent_slug}"),
1183            suggested_query: None,
1184            suggested_filters: Some(filters),
1185            shortcut: None,
1186        }
1187    }
1188
1189    fn with_shortcut(mut self, key: u8) -> Self {
1190        self.shortcut = Some(key);
1191        self
1192    }
1193}
1194
1195#[derive(Debug, Clone, Copy)]
1196pub struct FieldMask {
1197    flags: u8,
1198    preview_content_chars: Option<usize>,
1199}
1200
1201impl FieldMask {
1202    const CONTENT: u8 = 1 << 0;
1203    const SNIPPET: u8 = 1 << 1;
1204    const TITLE: u8 = 1 << 2;
1205    const CACHE: u8 = 1 << 3;
1206
1207    pub const FULL: Self = Self {
1208        flags: Self::CONTENT | Self::SNIPPET | Self::TITLE | Self::CACHE,
1209        preview_content_chars: None,
1210    };
1211
1212    pub fn new(
1213        wants_content: bool,
1214        wants_snippet: bool,
1215        wants_title: bool,
1216        allows_cache: bool,
1217    ) -> Self {
1218        let mut flags = 0;
1219        if wants_content {
1220            flags |= Self::CONTENT;
1221        }
1222        if wants_snippet {
1223            flags |= Self::SNIPPET;
1224        }
1225        if wants_title {
1226            flags |= Self::TITLE;
1227        }
1228        if allows_cache {
1229            flags |= Self::CACHE;
1230        }
1231        Self {
1232            flags,
1233            preview_content_chars: None,
1234        }
1235    }
1236
1237    pub fn with_preview_content_limit(mut self, max_chars: Option<usize>) -> Self {
1238        self.preview_content_chars = max_chars;
1239        if max_chars.is_some() {
1240            self.flags &= !Self::CACHE;
1241        }
1242        self
1243    }
1244
1245    pub fn needs_content(self) -> bool {
1246        self.flags & Self::CONTENT != 0
1247    }
1248
1249    pub fn wants_snippet(self) -> bool {
1250        self.flags & Self::SNIPPET != 0
1251    }
1252
1253    pub fn wants_title(self) -> bool {
1254        self.flags & Self::TITLE != 0
1255    }
1256
1257    pub fn allows_cache(self) -> bool {
1258        self.flags & Self::CACHE != 0
1259    }
1260
1261    pub fn preview_content_limit(self) -> Option<usize> {
1262        self.preview_content_chars
1263    }
1264}
1265
1266#[derive(Debug, Clone, serde::Serialize)]
1267pub struct SearchHit {
1268    pub title: String,
1269    pub snippet: String,
1270    pub content: String,
1271    #[serde(skip_serializing)]
1272    pub content_hash: u64,
1273    #[serde(skip_serializing)]
1274    pub conversation_id: Option<i64>,
1275    pub score: f32,
1276    pub source_path: String,
1277    pub agent: String,
1278    pub workspace: String,
1279    /// Original workspace path before rewriting (P6.2)
1280    #[serde(skip_serializing_if = "Option::is_none")]
1281    pub workspace_original: Option<String>,
1282    pub created_at: Option<i64>,
1283    /// Line number in the source file where the matched message starts (1-indexed)
1284    pub line_number: Option<usize>,
1285    /// How this result matched the query (exact, prefix wildcard, etc.)
1286    #[serde(default)]
1287    pub match_type: MatchType,
1288    // Provenance fields (P3.3)
1289    /// Source identifier (e.g., "local", "work-laptop")
1290    #[serde(default = "default_source_id")]
1291    pub source_id: String,
1292    /// Origin kind ("local" or "ssh")
1293    #[serde(default = "default_source_id")]
1294    pub origin_kind: String,
1295    /// Origin host label for remote sources
1296    #[serde(skip_serializing_if = "Option::is_none")]
1297    pub origin_host: Option<String>,
1298}
1299
1300static LAZY_FIELDS_ENABLED: Lazy<bool> = Lazy::new(|| {
1301    dotenvy::var("CASS_LAZY_FIELDS")
1302        .ok()
1303        .map(|v| !(v == "0" || v.eq_ignore_ascii_case("false")))
1304        .unwrap_or(true)
1305});
1306
1307fn default_source_id() -> String {
1308    "local".to_string()
1309}
1310
1311fn effective_field_mask(field_mask: FieldMask) -> FieldMask {
1312    if *LAZY_FIELDS_ENABLED {
1313        field_mask
1314    } else {
1315        FieldMask::FULL
1316    }
1317}
1318
1319fn execute_query_with_lazy_exact_count(
1320    searcher: &Searcher,
1321    query: &dyn Query,
1322    limit: usize,
1323    offset: usize,
1324) -> Result<FsLexicalSearchResult> {
1325    let top_docs = searcher.search(
1326        query,
1327        &TopDocs::with_limit(limit)
1328            .and_offset(offset)
1329            .order_by_score(),
1330    )?;
1331    let page_saturated = top_docs.len() == limit;
1332    let total_count = if page_saturated {
1333        searcher.search(query, &Count)?
1334    } else {
1335        offset.saturating_add(top_docs.len())
1336    };
1337    let hits = top_docs
1338        .into_iter()
1339        .enumerate()
1340        .map(|(rank, (bm25_score, doc_address))| FsLexicalDocHit {
1341            bm25_score,
1342            rank,
1343            doc_address,
1344        })
1345        .collect();
1346
1347    Ok(FsLexicalSearchResult { hits, total_count })
1348}
1349
1350/// Result of a search operation with metadata about how matches were found
1351#[derive(Debug, Clone)]
1352pub struct SearchResult {
1353    /// The search results
1354    pub hits: Vec<SearchHit>,
1355    /// Whether wildcard fallback was used (query had no/few exact matches)
1356    pub wildcard_fallback: bool,
1357    /// Cache metrics snapshot for observability/debug
1358    pub cache_stats: CacheStats,
1359    /// Did-you-mean suggestions when hits are empty or sparse
1360    pub suggestions: Vec<QuerySuggestion>,
1361    /// ANN search statistics (present when --approximate was used)
1362    pub ann_stats: Option<crate::search::ann_index::AnnSearchStats>,
1363    /// True total matching documents from the search engine (when available).
1364    /// For lexical searches this comes from Tantivy's `Count` collector and
1365    /// reflects the total number of documents matching the query, independent
1366    /// of limit/offset pagination. `None` for semantic/hybrid/cached paths
1367    /// where the true total is unknown.
1368    pub total_count: Option<usize>,
1369}
1370
1371#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1372pub enum ProgressivePhaseKind {
1373    Initial,
1374    Refined,
1375}
1376
1377// Phase events intentionally carry a complete SearchResult so consumers can
1378// react without reloading auxiliary state or keeping cross-event caches.
1379#[allow(clippy::large_enum_variant)]
1380#[derive(Debug, Clone)]
1381pub enum ProgressiveSearchEvent {
1382    Phase {
1383        kind: ProgressivePhaseKind,
1384        result: SearchResult,
1385        elapsed_ms: u128,
1386    },
1387    RefinementFailed {
1388        latency_ms: u128,
1389        error: String,
1390    },
1391}
1392
1393#[derive(Debug, Clone)]
1394pub(crate) struct ProgressiveSearchRequest<'a> {
1395    pub(crate) cx: &'a FsCx,
1396    pub(crate) query: &'a str,
1397    pub(crate) filters: SearchFilters,
1398    pub(crate) limit: usize,
1399    pub(crate) sparse_threshold: usize,
1400    pub(crate) field_mask: FieldMask,
1401    pub(crate) mode: SearchMode,
1402}
1403
1404#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1405struct SearchHitKey {
1406    source_id: String,
1407    source_path: String,
1408    conversation_id: Option<i64>,
1409    title: String,
1410    line_number: Option<usize>,
1411    created_at: Option<i64>,
1412    content_hash: u64,
1413}
1414
1415fn normalized_search_source_id_sql_expr(
1416    source_id_column: &str,
1417    origin_kind_column: &str,
1418    origin_host_column: &str,
1419) -> String {
1420    format!(
1421        "CASE \
1422            WHEN TRIM(COALESCE({source_id_column}, '')) != '' THEN \
1423                CASE \
1424                    WHEN LOWER(TRIM(COALESCE({source_id_column}, ''))) = '{local}' THEN '{local}' \
1425                    ELSE TRIM(COALESCE({source_id_column}, '')) \
1426                END \
1427            WHEN LOWER(TRIM(COALESCE({origin_kind_column}, ''))) IN ('ssh', 'remote') THEN \
1428                CASE \
1429                    WHEN TRIM(COALESCE({origin_host_column}, '')) = '' THEN 'remote' \
1430                    ELSE TRIM(COALESCE({origin_host_column}, '')) \
1431                END \
1432            WHEN LOWER(TRIM(COALESCE({origin_kind_column}, ''))) = '{local}' THEN '{local}' \
1433            WHEN TRIM(COALESCE({origin_host_column}, '')) != '' THEN TRIM(COALESCE({origin_host_column}, '')) \
1434            ELSE '{local}' \
1435         END",
1436        local = crate::sources::provenance::LOCAL_SOURCE_ID,
1437    )
1438}
1439
1440fn normalize_search_source_filter_value(source_id: &str) -> String {
1441    let trimmed = source_id.trim();
1442    if trimmed.eq_ignore_ascii_case(crate::sources::provenance::LOCAL_SOURCE_ID) {
1443        crate::sources::provenance::LOCAL_SOURCE_ID.to_string()
1444    } else {
1445        trimmed.to_string()
1446    }
1447}
1448
1449fn normalized_search_hit_source_id_parts(
1450    source_id: &str,
1451    origin_kind: &str,
1452    origin_host: Option<&str>,
1453) -> String {
1454    let trimmed_source_id = source_id.trim();
1455    if !trimmed_source_id.is_empty() {
1456        if trimmed_source_id.eq_ignore_ascii_case(crate::sources::provenance::LOCAL_SOURCE_ID) {
1457            return crate::sources::provenance::LOCAL_SOURCE_ID.to_string();
1458        }
1459        return trimmed_source_id.to_string();
1460    }
1461
1462    let trimmed_origin_host = origin_host.map(str::trim).filter(|value| !value.is_empty());
1463    let trimmed_origin_kind = origin_kind.trim();
1464    if trimmed_origin_kind.eq_ignore_ascii_case("ssh")
1465        || trimmed_origin_kind.eq_ignore_ascii_case("remote")
1466    {
1467        return trimmed_origin_host.unwrap_or("remote").to_string();
1468    }
1469    if let Some(origin_host) = trimmed_origin_host {
1470        return origin_host.to_string();
1471    }
1472
1473    crate::sources::provenance::LOCAL_SOURCE_ID.to_string()
1474}
1475
1476fn normalized_search_hit_origin_kind(source_id: &str, origin_kind: Option<&str>) -> String {
1477    if let Some(kind) = origin_kind.map(str::trim).filter(|value| !value.is_empty()) {
1478        if kind.eq_ignore_ascii_case("local") {
1479            return crate::sources::provenance::LOCAL_SOURCE_ID.to_string();
1480        }
1481        if kind.eq_ignore_ascii_case("ssh") || kind.eq_ignore_ascii_case("remote") {
1482            return "remote".to_string();
1483        }
1484        return kind.to_ascii_lowercase();
1485    }
1486
1487    if source_id == crate::sources::provenance::LOCAL_SOURCE_ID {
1488        crate::sources::provenance::LOCAL_SOURCE_ID.to_string()
1489    } else {
1490        "remote".to_string()
1491    }
1492}
1493
1494fn normalized_search_hit_source_id(hit: &SearchHit) -> String {
1495    normalized_search_hit_source_id_parts(
1496        hit.source_id.as_str(),
1497        hit.origin_kind.as_str(),
1498        hit.origin_host.as_deref(),
1499    )
1500}
1501
1502impl SearchHitKey {
1503    fn from_hit(hit: &SearchHit) -> Self {
1504        Self {
1505            source_id: normalized_search_hit_source_id(hit),
1506            source_path: hit.source_path.clone(),
1507            conversation_id: hit.conversation_id,
1508            title: if hit.conversation_id.is_some() {
1509                String::new()
1510            } else {
1511                hit.title.trim().to_string()
1512            },
1513            line_number: hit.line_number,
1514            created_at: hit.created_at,
1515            content_hash: hit.content_hash,
1516        }
1517    }
1518}
1519
1520impl Ord for SearchHitKey {
1521    fn cmp(&self, other: &Self) -> CmpOrdering {
1522        self.source_id
1523            .cmp(&other.source_id)
1524            .then_with(|| self.source_path.cmp(&other.source_path))
1525            .then_with(|| self.conversation_id.cmp(&other.conversation_id))
1526            .then_with(|| self.title.cmp(&other.title))
1527            .then_with(|| self.line_number.cmp(&other.line_number))
1528            .then_with(|| self.created_at.cmp(&other.created_at))
1529            .then_with(|| self.content_hash.cmp(&other.content_hash))
1530    }
1531}
1532
1533impl PartialOrd for SearchHitKey {
1534    fn partial_cmp(&self, other: &Self) -> Option<CmpOrdering> {
1535        Some(self.cmp(other))
1536    }
1537}
1538
1539const FEDERATED_RRF_K: f32 = 60.0;
1540
1541#[derive(Debug)]
1542struct FederatedRankedHit {
1543    hit: SearchHit,
1544    shard_index: usize,
1545    shard_rank: usize,
1546    fused_score: f32,
1547}
1548
1549fn federated_rrf_score(shard_rank: usize) -> f32 {
1550    1.0 / (FEDERATED_RRF_K + shard_rank as f32 + 1.0)
1551}
1552
1553fn merge_federated_ranked_hits(mut ranked_hits: Vec<FederatedRankedHit>) -> Vec<SearchHit> {
1554    ranked_hits.sort_by(|a, b| {
1555        b.fused_score
1556            .total_cmp(&a.fused_score)
1557            .then_with(|| a.shard_rank.cmp(&b.shard_rank))
1558            .then_with(|| SearchHitKey::from_hit(&a.hit).cmp(&SearchHitKey::from_hit(&b.hit)))
1559            .then_with(|| a.shard_index.cmp(&b.shard_index))
1560    });
1561    ranked_hits
1562        .into_iter()
1563        .map(|mut ranked| {
1564            ranked.hit.score = ranked.fused_score;
1565            ranked.hit
1566        })
1567        .collect()
1568}
1569
1570#[cfg(test)]
1571#[allow(dead_code)]
1572#[derive(Debug, Default, Clone)]
1573struct HybridScore {
1574    rrf: f32,
1575    lexical_rank: Option<usize>,
1576    semantic_rank: Option<usize>,
1577    lexical_score: Option<f32>,
1578    semantic_score: Option<f32>,
1579}
1580
1581#[cfg(test)]
1582#[allow(dead_code)]
1583#[derive(Debug, Clone)]
1584struct FusedHit {
1585    key: SearchHitKey,
1586    score: HybridScore,
1587    hit: SearchHit,
1588}
1589
1590/// Whitespace-invariant content hash used for search-hit dedup.
1591///
1592/// Uses xxhash3-64 (via `xxhash-rust`) for ~4-10x throughput over the prior
1593/// hand-rolled FNV-1a byte loop on the 1-2 KB tool-output bodies that
1594/// dominate the corpus. The hash value is in-memory only (dedup keys), never
1595/// persisted, so switching algorithms requires no migration. The canonical
1596/// byte stream fed to the hasher is: each whitespace-separated token
1597/// followed by a single 0x20 space between tokens — identical tokenization
1598/// rules as the former FNV implementation, so dedup semantics are preserved.
1599pub(crate) fn stable_content_hash(content: &str) -> u64 {
1600    use xxhash_rust::xxh3::Xxh3;
1601    let mut hasher = Xxh3::new();
1602    let mut first = true;
1603    for token in content.split_whitespace() {
1604        if !first {
1605            hasher.update(b" ");
1606        }
1607        hasher.update(token.as_bytes());
1608        first = false;
1609    }
1610    hasher.digest()
1611}
1612
1613fn stable_hit_hash(
1614    content: &str,
1615    source_path: &str,
1616    line_number: Option<usize>,
1617    created_at: Option<i64>,
1618) -> u64 {
1619    use xxhash_rust::xxh3::Xxh3;
1620    let mut hasher = Xxh3::new();
1621    // Seed with the whitespace-normalized content hash for empty-body
1622    // stability (matches the former FNV_OFFSET fallback).
1623    if !content.is_empty() {
1624        hasher.update(&stable_content_hash(content).to_le_bytes());
1625    }
1626    hasher.update(b"|");
1627    hasher.update(source_path.as_bytes());
1628    hasher.update(b"|");
1629    if let Some(line) = line_number {
1630        let mut buf = itoa::Buffer::new();
1631        hasher.update(buf.format(line).as_bytes());
1632    }
1633    hasher.update(b"|");
1634    if let Some(ts) = created_at {
1635        let mut buf = itoa::Buffer::new();
1636        hasher.update(buf.format(ts).as_bytes());
1637    }
1638    hasher.digest()
1639}
1640
1641fn search_hit_key_doc_id(key: &SearchHitKey) -> String {
1642    // Unit Separator (0x1F) is extremely unlikely in filesystem paths/ids.
1643    // Bead num7z: build the stable dedup key directly into a pre-sized
1644    // String, branching on each Option instead of allocating throwaway
1645    // per-field Strings via `.map(|v| v.to_string())`. Output must stay
1646    // byte-identical to the prior `format!`-based implementation: empty
1647    // string for `None` optional fields, the integer's `Display` rendering
1648    // otherwise, all joined by 0x1F.
1649    use std::fmt::Write as _;
1650    const SEP: char = '\u{1f}';
1651    // 20 bytes covers the decimal rendering of any i64/usize/u64.
1652    let capacity = key.source_id.len()
1653        + key.source_path.len()
1654        + key.title.len()
1655        + 6 // six separators
1656        + 3 * 20 // three possibly-empty i64/usize fields
1657        + 20; // content_hash u64
1658    let mut out = String::with_capacity(capacity);
1659    out.push_str(&key.source_id);
1660    out.push(SEP);
1661    out.push_str(&key.source_path);
1662    out.push(SEP);
1663    if let Some(v) = key.conversation_id {
1664        let _ = write!(out, "{v}");
1665    }
1666    out.push(SEP);
1667    out.push_str(&key.title);
1668    out.push(SEP);
1669    if let Some(v) = key.line_number {
1670        let _ = write!(out, "{v}");
1671    }
1672    out.push(SEP);
1673    if let Some(v) = key.created_at {
1674        let _ = write!(out, "{v}");
1675    }
1676    out.push(SEP);
1677    let _ = write!(out, "{}", key.content_hash);
1678    out
1679}
1680
1681fn search_hit_doc_id(hit: &SearchHit) -> String {
1682    search_hit_key_doc_id(&SearchHitKey::from_hit(hit))
1683}
1684
1685/// Comparator for FusedHit: descending RRF score, prefer dual-source, then key for determinism.
1686#[cfg(test)]
1687fn cmp_fused_hit_desc(a: &FusedHit, b: &FusedHit) -> CmpOrdering {
1688    b.score
1689        .rrf
1690        .total_cmp(&a.score.rrf)
1691        .then_with(|| {
1692            let a_both = a.score.lexical_rank.is_some() && a.score.semantic_rank.is_some();
1693            let b_both = b.score.lexical_rank.is_some() && b.score.semantic_rank.is_some();
1694            match (b_both, a_both) {
1695                (true, false) => CmpOrdering::Greater,
1696                (false, true) => CmpOrdering::Less,
1697                _ => CmpOrdering::Equal,
1698            }
1699        })
1700        .then_with(|| a.key.cmp(&b.key))
1701}
1702
1703/// Threshold below which full sort is faster than quickselect + partial sort.
1704#[cfg(test)]
1705#[allow(dead_code)]
1706const QUICKSELECT_THRESHOLD: usize = 64;
1707
1708/// Partition fused hits to get top-k in O(N + k log k) instead of O(N log N).
1709///
1710/// For k << N, this is significantly faster than sorting all N elements.
1711/// Uses `select_nth_unstable_by` for O(N) average-case partitioning,
1712/// then sorts only the top-k elements.
1713///
1714/// Note: Currently only used for tests. Production code uses full sort for
1715/// content deduplication which requires seeing all elements.
1716#[cfg(test)]
1717#[allow(dead_code)]
1718fn top_k_fused(mut hits: Vec<FusedHit>, k: usize) -> Vec<FusedHit> {
1719    let n = hits.len();
1720
1721    // Edge cases: nothing to do or k >= n
1722    if n == 0 || k == 0 {
1723        return Vec::new();
1724    }
1725    if k >= n {
1726        hits.sort_by(cmp_fused_hit_desc);
1727        return hits;
1728    }
1729
1730    // For small N, full sort has less overhead than quickselect
1731    if n < QUICKSELECT_THRESHOLD {
1732        hits.sort_by(cmp_fused_hit_desc);
1733        hits.truncate(k);
1734        return hits;
1735    }
1736
1737    // Partition: move top-k elements to the front (unordered) in O(N)
1738    hits.select_nth_unstable_by(k - 1, cmp_fused_hit_desc);
1739
1740    // Truncate to just the top-k elements
1741    hits.truncate(k);
1742
1743    // Sort just the top-k in O(k log k)
1744    hits.sort_by(cmp_fused_hit_desc);
1745
1746    hits
1747}
1748
1749/// Fuse lexical + semantic hits using Reciprocal Rank Fusion (RRF).
1750/// Applies deterministic tie-breaking and returns the requested page slice.
1751pub fn rrf_fuse_hits(
1752    lexical: &[SearchHit],
1753    semantic: &[SearchHit],
1754    query: &str,
1755    limit: usize,
1756    offset: usize,
1757) -> Vec<SearchHit> {
1758    if limit == 0 {
1759        return Vec::new();
1760    }
1761    let total_candidates = lexical.len().saturating_add(semantic.len());
1762    if total_candidates == 0 {
1763        return Vec::new();
1764    }
1765
1766    let mut lexical_scored = Vec::with_capacity(lexical.len());
1767    let mut semantic_scored = Vec::with_capacity(semantic.len());
1768    let mut hit_by_doc_id: HashMap<String, SearchHit> = HashMap::with_capacity(total_candidates);
1769
1770    for hit in lexical {
1771        let doc_id = search_hit_doc_id(hit);
1772        // Prefer lexical hit details (snippets highlight query terms).
1773        hit_by_doc_id.insert(doc_id.clone(), hit.clone());
1774        lexical_scored.push(FsScoredResult {
1775            doc_id,
1776            score: hit.score,
1777            source: FsScoreSource::Lexical,
1778            index: None,
1779            fast_score: None,
1780            quality_score: None,
1781            lexical_score: Some(hit.score),
1782            rerank_score: None,
1783            explanation: None,
1784            metadata: None,
1785        });
1786    }
1787
1788    for (idx, hit) in semantic.iter().enumerate() {
1789        let doc_id = search_hit_doc_id(hit);
1790        hit_by_doc_id
1791            .entry(doc_id.clone())
1792            .or_insert_with(|| hit.clone());
1793        semantic_scored.push(FsVectorHit {
1794            index: u32::try_from(idx).unwrap_or(u32::MAX),
1795            score: hit.score,
1796            doc_id,
1797        });
1798    }
1799
1800    // Ask frankensearch for full fused ordering so we can preserve cass's
1801    // content-level deduplication/pagination semantics afterward.
1802    let fused = fs_rrf_fuse(
1803        &lexical_scored,
1804        &semantic_scored,
1805        total_candidates,
1806        0,
1807        &FsRrfConfig::default(),
1808    );
1809
1810    // Dedup by (source_id, source_path, conversation_id-or-title, line_number,
1811    // created_at, content_hash) while preserving RRF order. When a real
1812    // conversation_id is present, it is the authoritative session key and title
1813    // drift must not split the same conversation.
1814    #[derive(Clone, Copy)]
1815    struct CompatSlot {
1816        index: usize,
1817        conversation_id: Option<i64>,
1818        ambiguous: bool,
1819    }
1820
1821    let mut source_ids: HashMap<String, u32> = HashMap::new();
1822    let mut path_ids: HashMap<String, u32> = HashMap::new();
1823    let mut title_ids: HashMap<String, u32> = HashMap::new();
1824    let mut next_source_id: u32 = 0;
1825    let mut next_path_id: u32 = 0;
1826    let mut next_title_id: u32 = 0;
1827    type CompatExactKey = (
1828        u32,
1829        u32,
1830        Option<i64>,
1831        Option<u32>,
1832        Option<usize>,
1833        Option<i64>,
1834        u64,
1835    );
1836    type CompatFallbackKey = (u32, u32, u32, Option<usize>, Option<i64>, u64);
1837
1838    let mut exact_seen: HashMap<CompatExactKey, usize> = HashMap::with_capacity(fused.len());
1839    let mut fallback_seen: HashMap<CompatFallbackKey, CompatSlot> =
1840        HashMap::with_capacity(fused.len());
1841    let mut unique_hits: Vec<SearchHit> = Vec::with_capacity(fused.len());
1842
1843    let update_slot = |slot: &mut CompatSlot, conversation_id: Option<i64>| {
1844        if slot.ambiguous {
1845            return;
1846        }
1847        match (slot.conversation_id, conversation_id) {
1848            (Some(existing), Some(current)) if existing != current => slot.ambiguous = true,
1849            (None, Some(current)) => slot.conversation_id = Some(current),
1850            _ => {}
1851        }
1852    };
1853
1854    for fused_hit in fused {
1855        let mut hit = match hit_by_doc_id.remove(&fused_hit.doc_id) {
1856            Some(hit) => hit,
1857            None => continue,
1858        };
1859        if hit_is_noise(&hit, query) {
1860            continue;
1861        }
1862
1863        let normalized_source_id = normalized_search_hit_source_id(&hit);
1864        let source_key = if let Some(id) = source_ids.get(normalized_source_id.as_str()) {
1865            *id
1866        } else {
1867            let id = next_source_id;
1868            next_source_id = next_source_id.saturating_add(1);
1869            source_ids.insert(normalized_source_id, id);
1870            id
1871        };
1872        let path_key = if let Some(id) = path_ids.get(hit.source_path.as_str()) {
1873            *id
1874        } else {
1875            let id = next_path_id;
1876            next_path_id = next_path_id.saturating_add(1);
1877            path_ids.insert(hit.source_path.clone(), id);
1878            id
1879        };
1880        let normalized_title = hit.title.trim();
1881        let fallback_title_key = if let Some(id) = title_ids.get(normalized_title) {
1882            *id
1883        } else {
1884            let id = next_title_id;
1885            next_title_id = next_title_id.saturating_add(1);
1886            title_ids.insert(normalized_title.to_string(), id);
1887            id
1888        };
1889        let exact_title_key = if hit.conversation_id.is_some() {
1890            None
1891        } else {
1892            Some(fallback_title_key)
1893        };
1894        let exact_key = (
1895            source_key,
1896            path_key,
1897            hit.conversation_id,
1898            exact_title_key,
1899            hit.line_number,
1900            hit.created_at,
1901            hit.content_hash,
1902        );
1903        let fallback_key = (
1904            source_key,
1905            path_key,
1906            fallback_title_key,
1907            hit.line_number,
1908            hit.created_at,
1909            hit.content_hash,
1910        );
1911
1912        let merged_idx = exact_seen.get(&exact_key).copied().or_else(|| {
1913            fallback_seen.get(&fallback_key).and_then(|slot| {
1914                if slot.ambiguous {
1915                    return None;
1916                }
1917                match (slot.conversation_id, hit.conversation_id) {
1918                    (Some(existing), Some(current)) if existing != current => None,
1919                    _ => Some(slot.index),
1920                }
1921            })
1922        });
1923
1924        if let Some(existing_idx) = merged_idx {
1925            exact_seen.insert(exact_key, existing_idx);
1926            let slot = fallback_seen.entry(fallback_key).or_insert(CompatSlot {
1927                index: existing_idx,
1928                conversation_id: hit.conversation_id,
1929                ambiguous: false,
1930            });
1931            update_slot(slot, hit.conversation_id);
1932            if unique_hits[existing_idx].conversation_id.is_none() && hit.conversation_id.is_some()
1933            {
1934                unique_hits[existing_idx].conversation_id = hit.conversation_id;
1935            }
1936            unique_hits[existing_idx].score += fused_hit.rrf_score as f32;
1937            continue;
1938        }
1939
1940        hit.score = fused_hit.rrf_score as f32;
1941        let index = unique_hits.len();
1942        unique_hits.push(hit);
1943        exact_seen.insert(exact_key, index);
1944        match fallback_seen.get_mut(&fallback_key) {
1945            Some(slot) => update_slot(slot, unique_hits[index].conversation_id),
1946            None => {
1947                fallback_seen.insert(
1948                    fallback_key,
1949                    CompatSlot {
1950                        index,
1951                        conversation_id: unique_hits[index].conversation_id,
1952                        ambiguous: false,
1953                    },
1954                );
1955            }
1956        }
1957    }
1958
1959    unique_hits.sort_by(|a, b| {
1960        b.score
1961            .total_cmp(&a.score)
1962            .then_with(|| SearchHitKey::from_hit(a).cmp(&SearchHitKey::from_hit(b)))
1963    });
1964
1965    let start = offset.min(unique_hits.len());
1966    unique_hits.into_iter().skip(start).take(limit).collect()
1967}
1968
1969struct QueryCache {
1970    embedder_id: String,
1971    embeddings: LruCache<String, Vec<f32>>,
1972}
1973
1974impl QueryCache {
1975    fn new(embedder_id: &str, capacity: NonZeroUsize) -> Self {
1976        Self {
1977            embedder_id: embedder_id.to_string(),
1978            embeddings: LruCache::new(capacity),
1979        }
1980    }
1981
1982    fn align_embedder(&mut self, embedder: &dyn Embedder) {
1983        if self.embedder_id != embedder.id() {
1984            self.embedder_id = embedder.id().to_string();
1985            self.embeddings.clear();
1986        }
1987    }
1988
1989    fn get_cached(&mut self, embedder: &dyn Embedder, canonical: &str) -> Option<Vec<f32>> {
1990        self.align_embedder(embedder);
1991        self.embeddings.get(canonical).cloned()
1992    }
1993
1994    fn store(&mut self, embedder: &dyn Embedder, canonical: &str, embedding: Vec<f32>) {
1995        self.align_embedder(embedder);
1996        self.embeddings.put(canonical.to_string(), embedding);
1997    }
1998}
1999
2000/// Returns `Some(&filter)` when the filter has at least one active constraint,
2001/// `None` when unrestricted (skip filtering for performance).
2002fn semantic_filter_as_search_filter(filter: &SemanticFilter) -> Option<&dyn FsSearchFilter> {
2003    let unrestricted = filter.agents.is_none()
2004        && filter.workspaces.is_none()
2005        && filter.sources.is_none()
2006        && filter.roles.is_none()
2007        && filter.created_from.is_none()
2008        && filter.created_to.is_none();
2009    if unrestricted { None } else { Some(filter) }
2010}
2011
2012fn open_fs_semantic_ann_index(fs_index: &FsVectorIndex, ann_path: &Path) -> Result<FsHnswIndex> {
2013    if !ann_path.is_file() {
2014        bail!(
2015            "approximate search unavailable: HNSW index not found at {}",
2016            ann_path.display()
2017        );
2018    }
2019
2020    let ann = FsHnswIndex::load(ann_path, fs_index)
2021        .map_err(|err| anyhow!("open HNSW index failed: {err}"))?;
2022    let matches = ann
2023        .matches_vector_index(fs_index)
2024        .map_err(|err| anyhow!("validate HNSW index failed: {err}"))?;
2025    if !matches {
2026        bail!(
2027            "approximate search unavailable: HNSW index at {} is stale for current semantic index (run 'cass index --semantic --build-hnsw')",
2028            ann_path.display()
2029        );
2030    }
2031
2032    Ok(ann)
2033}
2034
2035struct SemanticSearchState {
2036    context_token: Arc<()>,
2037    embedder: Arc<dyn Embedder>,
2038    fs_semantic_index: Arc<FsVectorIndex>,
2039    fs_semantic_indexes: Arc<Vec<Arc<FsVectorIndex>>>,
2040    fs_ann_index: Option<Arc<FsHnswIndex>>,
2041    ann_path: Option<PathBuf>,
2042    fs_in_memory_two_tier_index: Option<Arc<FsInMemoryTwoTierIndex>>,
2043    in_memory_two_tier_unavailable: InMemoryTwoTierUnavailable,
2044    progressive_context: Option<Arc<ProgressiveTwoTierContext>>,
2045    progressive_context_unavailable: bool,
2046    filter_maps: SemanticFilterMaps,
2047    roles: Option<HashSet<u8>>,
2048    query_cache: QueryCache,
2049}
2050
2051#[derive(Debug, Clone, Copy, Default)]
2052struct InMemoryTwoTierUnavailable {
2053    fast_only: bool,
2054    quality: bool,
2055}
2056
2057impl InMemoryTwoTierUnavailable {
2058    fn is_known_unavailable(self, tier_mode: SemanticTierMode) -> bool {
2059        match tier_mode {
2060            SemanticTierMode::Single => false,
2061            SemanticTierMode::FastOnly => self.fast_only,
2062            SemanticTierMode::Progressive | SemanticTierMode::QualityOnly => self.quality,
2063        }
2064    }
2065
2066    fn mark_unavailable(&mut self, tier_mode: SemanticTierMode) {
2067        match tier_mode {
2068            SemanticTierMode::Single => {}
2069            SemanticTierMode::FastOnly => {
2070                self.fast_only = true;
2071            }
2072            SemanticTierMode::Progressive | SemanticTierMode::QualityOnly => {
2073                self.quality = true;
2074            }
2075        }
2076    }
2077}
2078
2079struct ProgressiveTwoTierContext {
2080    context_token: Arc<()>,
2081    index: Arc<FsTwoTierIndex>,
2082    fast_embedder: Arc<dyn frankensearch::Embedder>,
2083    quality_embedder: Option<Arc<dyn frankensearch::Embedder>>,
2084}
2085
2086#[derive(Clone)]
2087struct SemanticCandidateContext {
2088    fs_semantic_index: Arc<FsVectorIndex>,
2089    fs_semantic_indexes: Arc<Vec<Arc<FsVectorIndex>>>,
2090    filter_maps: SemanticFilterMaps,
2091    roles: Option<HashSet<u8>>,
2092}
2093
2094struct SemanticCandidateSearchRequest<'a> {
2095    fetch_limit: usize,
2096    approximate: bool,
2097    tier_mode: SemanticTierMode,
2098    in_memory_two_tier_index: Option<&'a Arc<FsInMemoryTwoTierIndex>>,
2099    ann_index: Option<&'a Arc<FsHnswIndex>>,
2100}
2101
2102#[derive(Debug, Clone, Copy, Default)]
2103struct SemanticCandidateRetryState {
2104    has_more_candidates: bool,
2105    exact_window_may_omit_competitor: bool,
2106}
2107
2108struct SemanticQueryEmbedding {
2109    context_token: Arc<()>,
2110    vector: Vec<f32>,
2111}
2112
2113struct SharedCassSyncEmbedder {
2114    inner: Arc<dyn Embedder>,
2115    cache: Mutex<LruCache<String, Vec<f32>>>,
2116}
2117
2118impl SharedCassSyncEmbedder {
2119    fn new(inner: Arc<dyn Embedder>) -> Self {
2120        let cache_capacity =
2121            NonZeroUsize::new(PROGRESSIVE_EMBEDDING_CACHE_CAPACITY).expect("cache capacity > 0");
2122        Self {
2123            inner,
2124            cache: Mutex::new(LruCache::new(cache_capacity)),
2125        }
2126    }
2127}
2128
2129impl Embedder for SharedCassSyncEmbedder {
2130    fn embed_sync(&self, text: &str) -> crate::search::embedder::EmbedderResult<Vec<f32>> {
2131        if let Ok(mut cache) = self.cache.lock()
2132            && let Some(embedding) = cache.get(text).cloned()
2133        {
2134            return Ok(embedding);
2135        }
2136
2137        let embedding = self.inner.embed_sync(text)?;
2138        if let Ok(mut cache) = self.cache.lock() {
2139            cache.put(text.to_owned(), embedding.clone());
2140        }
2141        Ok(embedding)
2142    }
2143
2144    fn embed_batch_sync(
2145        &self,
2146        texts: &[&str],
2147    ) -> crate::search::embedder::EmbedderResult<Vec<Vec<f32>>> {
2148        self.inner.embed_batch_sync(texts)
2149    }
2150
2151    fn dimension(&self) -> usize {
2152        self.inner.dimension()
2153    }
2154
2155    fn id(&self) -> &str {
2156        self.inner.id()
2157    }
2158
2159    fn model_name(&self) -> &str {
2160        self.inner.model_name()
2161    }
2162
2163    fn is_ready(&self) -> bool {
2164        self.inner.is_ready()
2165    }
2166
2167    fn is_semantic(&self) -> bool {
2168        self.inner.is_semantic()
2169    }
2170
2171    fn category(&self) -> frankensearch::ModelCategory {
2172        self.inner.category()
2173    }
2174
2175    fn tier(&self) -> frankensearch::ModelTier {
2176        self.inner.tier()
2177    }
2178
2179    fn supports_mrl(&self) -> bool {
2180        self.inner.supports_mrl()
2181    }
2182}
2183
2184fn build_in_memory_two_tier_index(
2185    ann_path: Option<PathBuf>,
2186    embedder_id: &str,
2187    tier_mode: SemanticTierMode,
2188) -> Option<Arc<FsInMemoryTwoTierIndex>> {
2189    let index_dir = ann_path
2190        .as_ref()
2191        .and_then(|path| path.parent().map(Path::to_path_buf));
2192    let Some(index_dir) = index_dir else {
2193        tracing::debug!("two-tier semantic unavailable: ann/index directory path missing");
2194        return None;
2195    };
2196
2197    match FsInMemoryTwoTierIndex::from_dir(&index_dir) {
2198        Ok(index) => return Some(Arc::new(index)),
2199        Err(err) => {
2200            tracing::debug!(
2201                dir = %index_dir.display(),
2202                error = %err,
2203                "two-tier semantic index load failed; considering fallback"
2204            );
2205        }
2206    }
2207
2208    if !matches!(tier_mode, SemanticTierMode::FastOnly) {
2209        return None;
2210    }
2211
2212    let fallback_fast = index_dir.join(format!("index-{embedder_id}.fsvi"));
2213    if !fallback_fast.is_file() {
2214        return None;
2215    }
2216
2217    match FsInMemoryVectorIndex::from_fsvi(&fallback_fast) {
2218        Ok(fast) => Some(Arc::new(FsInMemoryTwoTierIndex::new(fast, None))),
2219        Err(err) => {
2220            tracing::debug!(
2221                path = %fallback_fast.display(),
2222                error = %err,
2223                "fast-only semantic fallback index load failed"
2224            );
2225            None
2226        }
2227    }
2228}
2229
2230fn two_tier_index_supports_mode(
2231    index: &FsInMemoryTwoTierIndex,
2232    tier_mode: SemanticTierMode,
2233) -> bool {
2234    !matches!(
2235        tier_mode,
2236        SemanticTierMode::Progressive | SemanticTierMode::QualityOnly
2237    ) || index.has_quality_index()
2238}
2239
2240#[derive(Debug, Clone)]
2241struct ResolvedSemanticDocId {
2242    message_id: u64,
2243    doc_id: String,
2244}
2245
2246type ProgressiveLookupKey = (String, String, Option<i64>, String, i64, Option<i64>, u64);
2247type ProgressiveExactQueryKey = (i64, i64);
2248type ProgressiveFallbackQueryKey = (String, String, i64);
2249type ResolvedSemanticLookupRow = Option<(ProgressiveLookupKey, ResolvedSemanticDocId)>;
2250
2251#[derive(Debug, Clone)]
2252struct ProgressiveLexicalHit {
2253    title: String,
2254    snippet: String,
2255    content: String,
2256    content_hash: u64,
2257    conversation_id: Option<i64>,
2258    source_path: String,
2259    agent: String,
2260    workspace: String,
2261    workspace_original: Option<String>,
2262    created_at: Option<i64>,
2263    match_type: MatchType,
2264    line_number: Option<usize>,
2265    source_id: String,
2266    origin_kind: String,
2267    origin_host: Option<String>,
2268}
2269
2270impl ProgressiveLexicalHit {
2271    fn from_search_hit(hit: &SearchHit, field_mask: FieldMask) -> Self {
2272        Self {
2273            title: if field_mask.wants_title() {
2274                hit.title.clone()
2275            } else {
2276                String::new()
2277            },
2278            snippet: if field_mask.wants_snippet() {
2279                hit.snippet.clone()
2280            } else {
2281                String::new()
2282            },
2283            content: if field_mask.needs_content() {
2284                hit.content.clone()
2285            } else {
2286                String::new()
2287            },
2288            content_hash: hit.content_hash,
2289            conversation_id: hit.conversation_id,
2290            source_path: hit.source_path.clone(),
2291            agent: hit.agent.clone(),
2292            workspace: hit.workspace.clone(),
2293            workspace_original: hit.workspace_original.clone(),
2294            created_at: hit.created_at,
2295            match_type: hit.match_type,
2296            line_number: hit.line_number,
2297            source_id: hit.source_id.clone(),
2298            origin_kind: hit.origin_kind.clone(),
2299            origin_host: hit.origin_host.clone(),
2300        }
2301    }
2302
2303    fn to_search_hit(&self, score: f32) -> SearchHit {
2304        SearchHit {
2305            title: self.title.clone(),
2306            snippet: self.snippet.clone(),
2307            content: self.content.clone(),
2308            content_hash: self.content_hash,
2309            conversation_id: self.conversation_id,
2310            score,
2311            source_path: self.source_path.clone(),
2312            agent: self.agent.clone(),
2313            workspace: self.workspace.clone(),
2314            workspace_original: self.workspace_original.clone(),
2315            created_at: self.created_at,
2316            line_number: self.line_number,
2317            match_type: self.match_type,
2318            source_id: self.source_id.clone(),
2319            origin_kind: self.origin_kind.clone(),
2320            origin_host: self.origin_host.clone(),
2321        }
2322    }
2323}
2324
2325#[derive(Debug, Default)]
2326struct ProgressiveLexicalCache {
2327    hits_by_message: HashMap<u64, ProgressiveLexicalHit>,
2328    wildcard_fallback: bool,
2329    suggestions: Vec<QuerySuggestion>,
2330}
2331
2332#[derive(Clone, Copy)]
2333struct ProgressivePhaseContext<'a> {
2334    query: &'a str,
2335    filters: &'a SearchFilters,
2336    field_mask: FieldMask,
2337    lexical_cache: Option<&'a ProgressiveLexicalCache>,
2338    limit: usize,
2339    fetch_limit: usize,
2340}
2341
2342type ProgressiveLexicalSnapshot = Arc<ProgressiveLexicalCache>;
2343
2344struct CassProgressiveLexicalAdapter {
2345    client: Arc<SearchClient>,
2346    filters: SearchFilters,
2347    field_mask: FieldMask,
2348    sparse_threshold: usize,
2349    shared: Arc<Mutex<ProgressiveLexicalSnapshot>>,
2350}
2351
2352impl CassProgressiveLexicalAdapter {
2353    fn new(
2354        client: Arc<SearchClient>,
2355        filters: SearchFilters,
2356        field_mask: FieldMask,
2357        sparse_threshold: usize,
2358        shared: Arc<Mutex<ProgressiveLexicalSnapshot>>,
2359    ) -> Self {
2360        Self {
2361            client,
2362            filters,
2363            field_mask,
2364            sparse_threshold,
2365            shared,
2366        }
2367    }
2368}
2369
2370impl FsLexicalSearch for CassProgressiveLexicalAdapter {
2371    fn search<'a>(
2372        &'a self,
2373        cx: &'a FsCx,
2374        query: &'a str,
2375        limit: usize,
2376    ) -> FsSearchFuture<'a, Vec<FsScoredResult>> {
2377        Box::pin(async move {
2378            if cx.is_cancel_requested() {
2379                return Err(FsSearchError::Cancelled {
2380                    phase: "lexical".to_string(),
2381                    reason: "cancel requested".to_string(),
2382                });
2383            }
2384
2385            let result = self
2386                .client
2387                .search_with_fallback(
2388                    query,
2389                    self.filters.clone(),
2390                    limit,
2391                    0,
2392                    self.sparse_threshold,
2393                    self.field_mask,
2394                )
2395                .map_err(|err| FsSearchError::SubsystemError {
2396                    subsystem: "cass_lexical_adapter",
2397                    source: Box::new(std::io::Error::other(err.to_string())),
2398                })?;
2399
2400            let resolved = self
2401                .client
2402                .resolve_semantic_doc_ids_for_hits(&result.hits)
2403                .map_err(|err| FsSearchError::SubsystemError {
2404                    subsystem: "cass_lexical_adapter",
2405                    source: Box::new(std::io::Error::other(err.to_string())),
2406                })?;
2407
2408            let mut scored = Vec::with_capacity(result.hits.len());
2409            let mut hits_by_message = HashMap::with_capacity(result.hits.len());
2410
2411            for (hit, resolved_doc) in result.hits.iter().zip(resolved) {
2412                let Some(resolved_doc) = resolved_doc else {
2413                    continue;
2414                };
2415                hits_by_message
2416                    .entry(resolved_doc.message_id)
2417                    .or_insert_with(|| {
2418                        ProgressiveLexicalHit::from_search_hit(hit, self.field_mask)
2419                    });
2420                scored.push(FsScoredResult {
2421                    doc_id: resolved_doc.doc_id,
2422                    score: hit.score,
2423                    source: FsScoreSource::Lexical,
2424                    index: None,
2425                    fast_score: None,
2426                    quality_score: None,
2427                    lexical_score: Some(hit.score),
2428                    rerank_score: None,
2429                    explanation: None,
2430                    metadata: None,
2431                });
2432            }
2433
2434            if let Ok(mut guard) = self.shared.lock() {
2435                *guard = Arc::new(ProgressiveLexicalCache {
2436                    hits_by_message,
2437                    wildcard_fallback: result.wildcard_fallback,
2438                    suggestions: result.suggestions,
2439                });
2440            }
2441
2442            Ok(scored)
2443        })
2444    }
2445
2446    fn index_document<'a>(
2447        &'a self,
2448        _cx: &'a FsCx,
2449        _doc: &'a frankensearch::IndexableDocument,
2450    ) -> FsSearchFuture<'a, ()> {
2451        Box::pin(async move {
2452            Err(FsSearchError::SubsystemError {
2453                subsystem: "cass_lexical_adapter",
2454                source: Box::new(std::io::Error::other("cass lexical adapter is read-only")),
2455            })
2456        })
2457    }
2458
2459    fn commit<'a>(&'a self, _cx: &'a FsCx) -> FsSearchFuture<'a, ()> {
2460        Box::pin(async move { Ok(()) })
2461    }
2462
2463    fn doc_count(&self) -> usize {
2464        self.client.total_docs()
2465    }
2466}
2467
2468pub struct SearchClient {
2469    reader: Option<(IndexReader, FsCassFields)>,
2470    sqlite: Mutex<Option<SendConnection>>,
2471    sqlite_path: Option<PathBuf>,
2472    prefix_cache: Mutex<CacheShards>,
2473    reload_on_search: bool,
2474    last_reload: Mutex<Option<Instant>>,
2475    last_generation: Mutex<Option<u64>>,
2476    reload_epoch: Arc<AtomicU64>,
2477    warm_tx: Option<mpsc::Sender<WarmJob>>,
2478    _warm_handle: Option<std::thread::JoinHandle<()>>,
2479    metrics: Metrics,
2480    cache_namespace: String,
2481    semantic: Mutex<Option<SemanticSearchState>>,
2482    /// Total count from the most recent Tantivy query (via `Count` collector).
2483    /// Populated by `search_tantivy`, read by `search_with_fallback` to report
2484    /// the true total matching documents for `total_matches` in JSON output.
2485    last_tantivy_total_count: Mutex<Option<usize>>,
2486}
2487
2488#[derive(Debug, Clone, Copy)]
2489pub struct SearchClientOptions {
2490    pub enable_reload: bool,
2491    pub enable_warm: bool,
2492}
2493
2494impl Default for SearchClientOptions {
2495    fn default() -> Self {
2496        Self {
2497            enable_reload: true,
2498            enable_warm: true,
2499        }
2500    }
2501}
2502
2503impl Drop for SearchClient {
2504    fn drop(&mut self) {
2505        FEDERATED_SEARCH_READERS
2506            .write()
2507            .remove(&self.cache_namespace);
2508    }
2509}
2510
2511#[derive(Debug, Clone, PartialEq, Eq)]
2512pub struct CacheStats {
2513    pub cache_hits: u64,
2514    pub cache_miss: u64,
2515    pub cache_shortfall: u64,
2516    pub reloads: u64,
2517    pub reload_ms_total: u128,
2518    pub total_cap: usize,
2519    pub total_cost: usize,
2520    /// Total evictions since client creation
2521    pub eviction_count: u64,
2522    /// Approximate bytes used by cache (rough estimate)
2523    pub approx_bytes: usize,
2524    /// Effective byte cap for cached hits (0 = disabled by explicit operator override)
2525    pub byte_cap: usize,
2526    /// Active eviction/admission policy for prefix result cache
2527    pub eviction_policy: &'static str,
2528    /// Number of S3-FIFO ghost entries retained for adaptive admission
2529    pub ghost_entries: usize,
2530    /// Number of cache insertions rejected by adaptive admission
2531    pub admission_rejects: u64,
2532    /// Number of adaptive query prewarm jobs scheduled from hot prefix-cache state.
2533    pub prewarm_scheduled: u64,
2534    /// Number of adaptive query prewarm jobs skipped because cache pressure was high.
2535    pub prewarm_skipped_pressure: u64,
2536    /// Last observed Tantivy reader generation signature for cursor continuity metadata.
2537    pub reader_generation: Option<u64>,
2538}
2539
2540impl Default for CacheStats {
2541    fn default() -> Self {
2542        Self {
2543            cache_hits: 0,
2544            cache_miss: 0,
2545            cache_shortfall: 0,
2546            reloads: 0,
2547            reload_ms_total: 0,
2548            total_cap: 0,
2549            total_cost: 0,
2550            eviction_count: 0,
2551            approx_bytes: 0,
2552            byte_cap: 0,
2553            eviction_policy: "unknown",
2554            ghost_entries: 0,
2555            admission_rejects: 0,
2556            prewarm_scheduled: 0,
2557            prewarm_skipped_pressure: 0,
2558            reader_generation: None,
2559        }
2560    }
2561}
2562
2563// Cache tuning: read from env to allow runtime override without recompiling.
2564// CASS_CACHE_SHARD_CAP controls per-shard entries; default 256.
2565static CACHE_SHARD_CAP: Lazy<usize> = Lazy::new(|| {
2566    dotenvy::var("CASS_CACHE_SHARD_CAP")
2567        .ok()
2568        .and_then(|v| v.parse::<usize>().ok())
2569        .filter(|v| *v > 0)
2570        .unwrap_or(256)
2571});
2572
2573// Total cache cost across all shards; approximate "~2k entries" default.
2574static CACHE_TOTAL_CAP: Lazy<usize> = Lazy::new(|| {
2575    dotenvy::var("CASS_CACHE_TOTAL_CAP")
2576        .ok()
2577        .and_then(|v| v.parse::<usize>().ok())
2578        .filter(|v| *v > 0)
2579        .unwrap_or(2048)
2580});
2581
2582static CACHE_DEBUG_ENABLED: Lazy<bool> = Lazy::new(|| {
2583    dotenvy::var("CASS_DEBUG_CACHE_METRICS")
2584        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
2585        .unwrap_or(false)
2586});
2587
2588// Byte-based cap for cache memory. Unset defaults to a memory-proportional cap;
2589// explicit CASS_CACHE_BYTE_CAP=0 disables the byte guard.
2590static CACHE_BYTE_CAP: Lazy<usize> = Lazy::new(|| match dotenvy::var("CASS_CACHE_BYTE_CAP") {
2591    Ok(value) => cache_byte_cap_from_env_value(Some(&value), available_memory_bytes()),
2592    Err(_) => default_cache_byte_cap(),
2593});
2594
2595static CACHE_EVICTION_POLICY: Lazy<CacheEvictionPolicy> = Lazy::new(|| {
2596    cache_eviction_policy_from_env_value(dotenvy::var("CASS_CACHE_EVICTION_POLICY").ok().as_deref())
2597});
2598
2599const DEFAULT_CACHE_BYTE_CAP_FALLBACK: usize = 64 * 1024 * 1024;
2600const DEFAULT_CACHE_BYTE_CAP_MEMORY_FRACTION_DENOMINATOR: u64 = 128;
2601const DEFAULT_CACHE_BYTE_CAP_CEILING: u64 = 2 * 1024 * 1024 * 1024;
2602const S3_FIFO_GHOST_CAP_MULTIPLIER: usize = 2;
2603const S3_FIFO_LARGE_ENTRY_FRACTION_DENOMINATOR: usize = 4;
2604const PREWARM_ENTRY_PRESSURE_NUMERATOR: usize = 9;
2605const PREWARM_ENTRY_PRESSURE_DENOMINATOR: usize = 10;
2606const PREWARM_BYTE_PRESSURE_NUMERATOR: usize = 4;
2607const PREWARM_BYTE_PRESSURE_DENOMINATOR: usize = 5;
2608
2609const CACHE_KEY_VERSION: &str = "1";
2610
2611// Warm debounce (ms) for background reload/warm jobs; default 120ms.
2612static WARM_DEBOUNCE_MS: Lazy<u64> = Lazy::new(|| {
2613    dotenvy::var("CASS_WARM_DEBOUNCE_MS")
2614        .ok()
2615        .and_then(|v| v.parse::<u64>().ok())
2616        .filter(|v| *v > 0)
2617        .unwrap_or(120)
2618});
2619
2620fn default_cache_byte_cap() -> usize {
2621    default_cache_byte_cap_for_available(available_memory_bytes())
2622}
2623
2624fn cache_byte_cap_from_env_value(value: Option<&str>, available_bytes: Option<u64>) -> usize {
2625    let Some(raw) = value else {
2626        return default_cache_byte_cap_for_available(available_bytes);
2627    };
2628    raw.parse::<usize>()
2629        .unwrap_or_else(|_| default_cache_byte_cap_for_available(available_bytes))
2630}
2631
2632fn default_cache_byte_cap_for_available(available_bytes: Option<u64>) -> usize {
2633    let Some(available_bytes) = available_bytes else {
2634        return DEFAULT_CACHE_BYTE_CAP_FALLBACK;
2635    };
2636    let ceiling = usize::try_from(DEFAULT_CACHE_BYTE_CAP_CEILING).unwrap_or(usize::MAX);
2637    let budget = available_bytes / DEFAULT_CACHE_BYTE_CAP_MEMORY_FRACTION_DENOMINATOR;
2638    let budget = budget.min(DEFAULT_CACHE_BYTE_CAP_CEILING);
2639    let budget = usize::try_from(budget).unwrap_or(ceiling);
2640    budget.clamp(DEFAULT_CACHE_BYTE_CAP_FALLBACK, ceiling)
2641}
2642
2643#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2644enum CacheEvictionPolicy {
2645    Lru,
2646    S3Fifo,
2647}
2648
2649impl CacheEvictionPolicy {
2650    fn label(self) -> &'static str {
2651        match self {
2652            CacheEvictionPolicy::Lru => "lru",
2653            CacheEvictionPolicy::S3Fifo => "s3-fifo",
2654        }
2655    }
2656}
2657
2658fn cache_eviction_policy_from_env_value(value: Option<&str>) -> CacheEvictionPolicy {
2659    match value.map(str::trim).filter(|value| !value.is_empty()) {
2660        Some(value) if value.eq_ignore_ascii_case("s3-fifo") => CacheEvictionPolicy::S3Fifo,
2661        Some(value) if value.eq_ignore_ascii_case("s3fifo") => CacheEvictionPolicy::S3Fifo,
2662        Some(value) if value.eq_ignore_ascii_case("s3_fifo") => CacheEvictionPolicy::S3Fifo,
2663        _ => CacheEvictionPolicy::Lru,
2664    }
2665}
2666
2667#[derive(Clone)]
2668struct CachedHit {
2669    hit: SearchHit,
2670    lc_content: String,
2671    lc_title: Option<String>,
2672    bloom64: u64,
2673}
2674
2675impl CachedHit {
2676    /// Approximate byte size of this cached hit (rough estimate for memory guardrails).
2677    /// Includes `SearchHit` strings + lowercase copies + bloom filter.
2678    fn approx_bytes(&self) -> usize {
2679        // Base struct overhead
2680        let base = std::mem::size_of::<Self>();
2681        // SearchHit string fields (title, snippet, content, source_path, agent, workspace)
2682        let hit_strings = self.hit.title.len()
2683            + self.hit.snippet.len()
2684            + self.hit.content.len()
2685            + self.hit.source_path.len()
2686            + self.hit.agent.len()
2687            + self.hit.workspace.len()
2688            + self
2689                .hit
2690                .workspace_original
2691                .as_ref()
2692                .map_or(0, std::string::String::len)
2693            + self.hit.source_id.len()
2694            + self.hit.origin_kind.len()
2695            + self
2696                .hit
2697                .origin_host
2698                .as_ref()
2699                .map_or(0, std::string::String::len);
2700        // Lowercase cache copies
2701        let lc_strings =
2702            self.lc_content.len() + self.lc_title.as_ref().map_or(0, std::string::String::len);
2703        base + hit_strings + lc_strings
2704    }
2705}
2706
2707struct CacheShards {
2708    // Optimization 2.3: Use Arc<str> for cache keys to reduce memory via interning
2709    shards: HashMap<Arc<str>, LruCache<Arc<str>, Vec<CachedHit>>>,
2710    total_cap: usize,
2711    total_cost: usize,
2712    /// Running count of evictions (for diagnostics)
2713    eviction_count: u64,
2714    /// Approximate bytes used by all cached hits
2715    total_bytes: usize,
2716    /// Byte cap (0 = disabled)
2717    byte_cap: usize,
2718    /// Active cache admission/eviction policy.
2719    policy: CacheEvictionPolicy,
2720    /// Ghost queue used by S3-FIFO-style adaptive admission.
2721    ghost_keys: VecDeque<Arc<str>>,
2722    ghost_set: HashSet<Arc<str>>,
2723    admission_rejects: u64,
2724}
2725
2726impl CacheShards {
2727    fn new(total_cap: usize, byte_cap: usize) -> Self {
2728        Self::new_with_policy(total_cap, byte_cap, *CACHE_EVICTION_POLICY)
2729    }
2730
2731    fn new_with_policy(total_cap: usize, byte_cap: usize, policy: CacheEvictionPolicy) -> Self {
2732        Self {
2733            shards: HashMap::new(),
2734            total_cap: total_cap.max(1),
2735            total_cost: 0,
2736            eviction_count: 0,
2737            total_bytes: 0,
2738            byte_cap,
2739            policy,
2740            ghost_keys: VecDeque::new(),
2741            ghost_set: HashSet::new(),
2742            admission_rejects: 0,
2743        }
2744    }
2745
2746    fn shard_mut(&mut self, name: &str) -> &mut LruCache<Arc<str>, Vec<CachedHit>> {
2747        // Use interned shard names to reduce memory for repeated lookups
2748        let interned_name = intern_cache_key(name);
2749        self.shards
2750            .entry(interned_name)
2751            .or_insert_with(|| LruCache::new(NonZeroUsize::new(*CACHE_SHARD_CAP).unwrap()))
2752    }
2753
2754    fn shard_opt(&self, name: &str) -> Option<&LruCache<Arc<str>, Vec<CachedHit>>> {
2755        // HashMap<Arc<str>, _> can be queried with &str via Borrow trait
2756        self.shards.get(name)
2757    }
2758
2759    fn put(&mut self, shard_name: &str, key: Arc<str>, value: Vec<CachedHit>) {
2760        let new_cost = value.len();
2761        let new_bytes: usize = value.iter().map(CachedHit::approx_bytes).sum();
2762        let replacing = self
2763            .shard_opt(shard_name)
2764            .is_some_and(|shard| shard.contains(&key));
2765
2766        if !replacing && !self.should_admit(&key, new_cost, new_bytes) {
2767            self.admission_rejects += 1;
2768            self.record_ghost(key);
2769            return;
2770        }
2771
2772        self.remove_ghost(&key);
2773
2774        let shard = self.shard_mut(shard_name);
2775        let old_val = shard.put(key, value);
2776        let (old_cost, old_bytes) = old_val.as_ref().map_or((0, 0), |v| {
2777            (v.len(), v.iter().map(CachedHit::approx_bytes).sum())
2778        });
2779
2780        self.total_cost = self
2781            .total_cost
2782            .saturating_add(new_cost)
2783            .saturating_sub(old_cost);
2784        self.total_bytes = self
2785            .total_bytes
2786            .saturating_add(new_bytes)
2787            .saturating_sub(old_bytes);
2788        self.evict_until_within_cap();
2789    }
2790
2791    fn evict_until_within_cap(&mut self) {
2792        // Evict if over entry cap OR over byte cap (when byte_cap > 0)
2793        while self.total_cost > self.total_cap
2794            || (self.byte_cap > 0 && self.total_bytes > self.byte_cap)
2795        {
2796            // Under byte pressure, target the byte-heaviest shard. Otherwise,
2797            // target the shard with the most cached items. This avoids
2798            // evicting many small useful entries before a single oversized
2799            // result set is finally removed.
2800            let byte_pressure = self.byte_cap > 0 && self.total_bytes > self.byte_cap;
2801            let mut largest_shard_key = None;
2802            let mut max_score = 0usize;
2803            for (k, v) in self.shards.iter() {
2804                let score = if byte_pressure {
2805                    shard_cached_bytes(v)
2806                } else {
2807                    v.len()
2808                };
2809                if score > max_score {
2810                    max_score = score;
2811                    largest_shard_key = Some(k.clone());
2812                }
2813            }
2814
2815            if let Some(key) = largest_shard_key {
2816                if let Some(shard) = self.shards.get_mut(&key)
2817                    && let Some((evicted_key, v)) = shard.pop_lru()
2818                {
2819                    let evicted_bytes: usize = v.iter().map(CachedHit::approx_bytes).sum();
2820                    self.total_cost = self.total_cost.saturating_sub(v.len());
2821                    self.total_bytes = self.total_bytes.saturating_sub(evicted_bytes);
2822                    self.eviction_count += 1;
2823                    self.record_ghost(evicted_key);
2824                }
2825            } else {
2826                break; // All shards are empty
2827            }
2828        }
2829    }
2830
2831    fn should_admit(&self, key: &Arc<str>, cost: usize, bytes: usize) -> bool {
2832        if self.policy == CacheEvictionPolicy::Lru || self.ghost_set.contains(key) {
2833            return true;
2834        }
2835        !self.is_s3_fifo_large_candidate(cost, bytes)
2836    }
2837
2838    fn is_s3_fifo_large_candidate(&self, cost: usize, bytes: usize) -> bool {
2839        let entry_heavy = cost
2840            > self
2841                .total_cap
2842                .div_ceil(S3_FIFO_LARGE_ENTRY_FRACTION_DENOMINATOR);
2843        let byte_heavy = self.byte_cap > 0
2844            && bytes
2845                > self
2846                    .byte_cap
2847                    .div_ceil(S3_FIFO_LARGE_ENTRY_FRACTION_DENOMINATOR);
2848        entry_heavy || byte_heavy
2849    }
2850
2851    fn record_ghost(&mut self, key: Arc<str>) {
2852        if self.policy != CacheEvictionPolicy::S3Fifo {
2853            return;
2854        }
2855        if self.ghost_set.insert(key.clone()) {
2856            self.ghost_keys.push_back(key);
2857        }
2858        let cap = self
2859            .total_cap
2860            .saturating_mul(S3_FIFO_GHOST_CAP_MULTIPLIER)
2861            .max(1);
2862        while self.ghost_set.len() > cap {
2863            if let Some(old) = self.ghost_keys.pop_front() {
2864                self.ghost_set.remove(&old);
2865            } else {
2866                break;
2867            }
2868        }
2869    }
2870
2871    fn remove_ghost(&mut self, key: &Arc<str>) {
2872        self.ghost_set.remove(key);
2873        self.ghost_keys.retain(|candidate| candidate != key);
2874    }
2875
2876    fn clear(&mut self) {
2877        self.shards.clear();
2878        self.total_cost = 0;
2879        self.total_bytes = 0;
2880        self.ghost_keys.clear();
2881        self.ghost_set.clear();
2882        // Note: eviction_count preserved for lifetime stats
2883    }
2884
2885    fn total_cost(&self) -> usize {
2886        self.total_cost
2887    }
2888
2889    fn total_cap(&self) -> usize {
2890        self.total_cap
2891    }
2892
2893    fn eviction_count(&self) -> u64 {
2894        self.eviction_count
2895    }
2896
2897    fn total_bytes(&self) -> usize {
2898        self.total_bytes
2899    }
2900
2901    fn byte_cap(&self) -> usize {
2902        self.byte_cap
2903    }
2904
2905    fn policy_label(&self) -> &'static str {
2906        self.policy.label()
2907    }
2908
2909    fn ghost_entries(&self) -> usize {
2910        self.ghost_set.len()
2911    }
2912
2913    fn admission_rejects(&self) -> u64 {
2914        self.admission_rejects
2915    }
2916
2917    fn prewarm_pressure(&self) -> bool {
2918        let entry_pressure = self
2919            .total_cost
2920            .saturating_mul(PREWARM_ENTRY_PRESSURE_DENOMINATOR)
2921            >= self
2922                .total_cap
2923                .saturating_mul(PREWARM_ENTRY_PRESSURE_NUMERATOR);
2924        let byte_pressure = self.byte_cap > 0
2925            && self
2926                .total_bytes
2927                .saturating_mul(PREWARM_BYTE_PRESSURE_DENOMINATOR)
2928                >= self
2929                    .byte_cap
2930                    .saturating_mul(PREWARM_BYTE_PRESSURE_NUMERATOR);
2931        entry_pressure || byte_pressure
2932    }
2933}
2934
2935fn shard_cached_bytes(shard: &LruCache<Arc<str>, Vec<CachedHit>>) -> usize {
2936    shard
2937        .iter()
2938        .map(|(_key, hits)| hits.iter().map(CachedHit::approx_bytes).sum::<usize>())
2939        .sum()
2940}
2941
2942#[derive(Clone)]
2943struct WarmJob {
2944    query: String,
2945    filters_fingerprint: String,
2946    shard_name: String,
2947}
2948
2949#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2950enum AdaptivePrewarmDecision {
2951    Schedule,
2952    SkipCold,
2953    SkipPressure,
2954}
2955
2956#[derive(Clone)]
2957struct SearcherCacheEntry {
2958    epoch: u64,
2959    reader_key: usize,
2960    searcher: Searcher,
2961}
2962
2963thread_local! {
2964    static THREAD_SEARCHER: RefCell<Option<SearcherCacheEntry>> = const { RefCell::new(None) };
2965}
2966
2967#[derive(Clone)]
2968struct FederatedIndexReader {
2969    reader: IndexReader,
2970    fields: FsCassFields,
2971}
2972
2973static FEDERATED_SEARCH_READERS: Lazy<RwLock<HashMap<String, Arc<Vec<FederatedIndexReader>>>>> =
2974    Lazy::new(|| RwLock::new(HashMap::new()));
2975static SEARCH_CLIENT_INSTANCE_COUNTER: AtomicU64 = AtomicU64::new(1);
2976
2977/// Calculate Levenshtein edit distance between two strings.
2978/// Used for typo detection in did-you-mean suggestions.
2979fn levenshtein_distance(a: &str, b: &str) -> usize {
2980    let a_chars: Vec<char> = a.chars().collect();
2981    let b_chars: Vec<char> = b.chars().collect();
2982    let a_len = a_chars.len();
2983    let b_len = b_chars.len();
2984
2985    if a_len == 0 {
2986        return b_len;
2987    }
2988    if b_len == 0 {
2989        return a_len;
2990    }
2991
2992    // Use two rows for space efficiency
2993    let mut prev_row: Vec<usize> = (0..=b_len).collect();
2994    let mut curr_row: Vec<usize> = vec![0; b_len + 1];
2995
2996    for (i, a_char) in a_chars.iter().enumerate() {
2997        curr_row[0] = i + 1;
2998        for (j, b_char) in b_chars.iter().enumerate() {
2999            let cost = usize::from(a_char != b_char);
3000            curr_row[j + 1] = (prev_row[j + 1] + 1) // deletion
3001                .min(curr_row[j] + 1) // insertion
3002                .min(prev_row[j] + cost); // substitution
3003        }
3004        std::mem::swap(&mut prev_row, &mut curr_row);
3005    }
3006
3007    prev_row[b_len]
3008}
3009
3010/// Normalize a term into FTS5-porter-aligned parts.
3011/// Splits punctuation into separate fragments while preserving a trailing `*`
3012/// on the final fragment so fallback queries match how SQLite tokenizes indexed
3013/// text in `fts_messages`.
3014fn normalize_term_parts(raw: &str) -> Vec<String> {
3015    let mut parts = Vec::new();
3016    for token in nfc_sanitize_query(raw).split_whitespace() {
3017        let mut current = String::new();
3018        let mut chars = token.chars().peekable();
3019        while let Some(ch) = chars.next() {
3020            let trailing_wildcard = ch == '*' && chars.peek().is_none() && !current.is_empty();
3021            if ch.is_alphanumeric() || ch == '_' || trailing_wildcard {
3022                current.push(ch);
3023                continue;
3024            }
3025
3026            if !current.is_empty() {
3027                parts.push(std::mem::take(&mut current));
3028            }
3029        }
3030
3031        if !current.is_empty() {
3032            parts.push(current);
3033        }
3034    }
3035    parts
3036}
3037
3038/// Normalize phrase text into tokenizer-aligned terms (lowercased, no wildcards).
3039fn normalize_phrase_terms(raw: &str) -> Vec<String> {
3040    normalize_term_parts(raw)
3041        .into_iter()
3042        .map(|s| s.trim_matches('*').to_lowercase())
3043        .filter(|s| !s.is_empty())
3044        .collect()
3045}
3046
3047fn render_fts5_term_part(part: &str) -> Option<String> {
3048    let pattern = FsCassWildcardPattern::parse(part);
3049    if matches!(
3050        pattern,
3051        FsCassWildcardPattern::Suffix(_)
3052            | FsCassWildcardPattern::Substring(_)
3053            | FsCassWildcardPattern::Complex(_)
3054    ) {
3055        return None;
3056    }
3057
3058    Some(part.to_string())
3059}
3060
3061/// Determine the dominant match type from a query string.
3062/// Returns the "loosest" pattern used (Substring > Suffix > Prefix > Exact).
3063fn dominant_match_type(query: &str) -> MatchType {
3064    let mut worst = MatchType::Exact;
3065    for term in query.split_whitespace() {
3066        let pattern = FsCassWildcardPattern::parse(term);
3067        let mt = match pattern {
3068            FsCassWildcardPattern::Exact(_) => MatchType::Exact,
3069            FsCassWildcardPattern::Prefix(_) => MatchType::Prefix,
3070            FsCassWildcardPattern::Suffix(_) => MatchType::Suffix,
3071            FsCassWildcardPattern::Substring(_) => MatchType::Substring,
3072            FsCassWildcardPattern::Complex(_) => MatchType::Wildcard,
3073        };
3074        // Lower quality factor = "looser" match = dominant
3075        if mt.quality_factor() < worst.quality_factor() {
3076            worst = mt;
3077        }
3078    }
3079    worst
3080}
3081
3082/// Check if content is primarily a tool invocation (noise that shouldn't appear in search results).
3083/// Tool invocations like "[Tool: Bash - Check status]" are not informative search results.
3084pub(crate) fn is_tool_invocation_noise(content: &str) -> bool {
3085    let trimmed = content.trim();
3086
3087    // Direct tool invocations that are just "[Tool: X - description]" or "[Tool: X] args"
3088    if trimmed.starts_with("[Tool:") {
3089        // Find closing bracket
3090        if let Some(close_idx) = trimmed.find(']') {
3091            // Check for content after closing bracket (Pi-Agent style: "[Tool: name] args")
3092            let after = &trimmed[close_idx + 1..];
3093            if !after.trim().is_empty() {
3094                return false; // Has args/content after -> Keep
3095            }
3096
3097            // No content after bracket. Check for description inside.
3098            // Format: "[Tool: Name - Desc]" (useful) vs "[Tool: Name]" (previously noise, now kept)
3099            // We now keep "[Tool: Name]" because users might search for "Tool: Bash" to find usage.
3100            // Only "[Tool:]" or "[Tool: ]" (empty name) is considered noise.
3101            let inner = &trimmed[6..close_idx]; // Skip "[Tool:"
3102            return inner.trim().is_empty();
3103        }
3104        // No closing bracket? Malformed, treat as noise
3105        return true;
3106    }
3107
3108    // Also filter very short content that's just tool names or markers
3109    if trimmed.len() < 20 {
3110        let lower = trimmed.to_lowercase();
3111        if lower.starts_with("[tool") || lower.starts_with("tool:") {
3112            return true;
3113        }
3114    }
3115
3116    false
3117}
3118
3119fn hit_content_for_noise_check(hit: &SearchHit) -> &str {
3120    if hit.content.is_empty() {
3121        &hit.snippet
3122    } else {
3123        &hit.content
3124    }
3125}
3126
3127fn hit_is_noise(hit: &SearchHit, query: &str) -> bool {
3128    let content_to_check = hit_content_for_noise_check(hit);
3129    // When both `content` and `snippet` are empty, it usually means the caller
3130    // explicitly asked for a projection (`--fields minimal` / `summary`) that
3131    // excludes both fields — NOT that the underlying row was empty. Treating
3132    // the hit as noise in that case silently drops every real match and makes
3133    // `cass search --fields minimal` return zero results even when matches
3134    // exist (reality-check bead q6xf9). The noise classifier cannot make a
3135    // correctness-preserving decision without text to inspect, so default to
3136    // "not noise" in that case and let the hit through; downstream projection
3137    // will apply the requested field subset.
3138    if content_to_check.is_empty() {
3139        return false;
3140    }
3141    is_search_noise_text(content_to_check, query) || is_tool_invocation_noise(content_to_check)
3142}
3143
3144fn snippet_from_content(content: &str) -> String {
3145    let trimmed = content.trim();
3146    let mut chars = trimmed.chars();
3147    let preview: String = chars.by_ref().take(200).collect();
3148    if chars.next().is_some() {
3149        format!("{preview}...")
3150    } else {
3151        preview
3152    }
3153}
3154
3155/// Deduplicate search hits by message-level provenance and content, keeping
3156/// only the highest-scored hit for each unique matched message.
3157///
3158/// This respects source boundaries (P2.3): the same content from different sources
3159/// appears as separate results, since they represent distinct conversations.
3160///
3161/// Also filters out tool invocation noise that isn't useful for search results.
3162#[cfg(test)]
3163pub(crate) fn deduplicate_hits(hits: Vec<SearchHit>) -> Vec<SearchHit> {
3164    deduplicate_hits_with_query(hits, "")
3165}
3166
3167pub(crate) fn deduplicate_hits_with_query(hits: Vec<SearchHit>, query: &str) -> Vec<SearchHit> {
3168    // Key: (source_numeric_id, source_path_numeric_id, conversation_id-or-title,
3169    //       line_number, created_at, content_hash) -> index in deduped.
3170    // Include message-level identity so repeated identical content in the same
3171    // session remains visible as distinct hits when it came from different messages.
3172    // When conversation_id exists, it is authoritative and title drift must not
3173    // split or merge hits incorrectly.
3174    let mut source_ids: HashMap<String, u32> = HashMap::new();
3175    let mut path_ids: HashMap<String, u32> = HashMap::new();
3176    let mut title_ids: HashMap<String, u32> = HashMap::new();
3177    let mut next_source_id: u32 = 0;
3178    let mut next_path_id: u32 = 0;
3179    let mut next_title_id: u32 = 0;
3180    type DedupKey = (
3181        u32,
3182        u32,
3183        Option<i64>,
3184        Option<u32>,
3185        Option<usize>,
3186        Option<i64>,
3187        u64,
3188    );
3189
3190    let mut seen: HashMap<DedupKey, usize> = HashMap::new();
3191    let mut deduped: Vec<SearchHit> = Vec::new();
3192
3193    for hit in hits {
3194        if hit_is_noise(&hit, query) {
3195            continue;
3196        }
3197
3198        // Include normalized source identity AND source_path in the key so different
3199        // sessions keep their results while local provenance drift still coalesces.
3200        let normalized_source_id = normalized_search_hit_source_id(&hit);
3201        let source_key = if let Some(id) = source_ids.get(normalized_source_id.as_str()) {
3202            *id
3203        } else {
3204            let id = next_source_id;
3205            next_source_id = next_source_id.saturating_add(1);
3206            source_ids.insert(normalized_source_id, id);
3207            id
3208        };
3209        let path_key = if let Some(id) = path_ids.get(hit.source_path.as_str()) {
3210            *id
3211        } else {
3212            let id = next_path_id;
3213            next_path_id = next_path_id.saturating_add(1);
3214            path_ids.insert(hit.source_path.clone(), id);
3215            id
3216        };
3217        let title_key = if hit.conversation_id.is_some() {
3218            None
3219        } else {
3220            let normalized_title = hit.title.trim();
3221            Some(if let Some(id) = title_ids.get(normalized_title) {
3222                *id
3223            } else {
3224                let id = next_title_id;
3225                next_title_id = next_title_id.saturating_add(1);
3226                title_ids.insert(normalized_title.to_string(), id);
3227                id
3228            })
3229        };
3230        let key = (
3231            source_key,
3232            path_key,
3233            hit.conversation_id,
3234            title_key,
3235            hit.line_number,
3236            hit.created_at,
3237            hit.content_hash,
3238        );
3239
3240        if let Some(&existing_idx) = seen.get(&key) {
3241            // If existing hit has lower score, replace it
3242            if deduped[existing_idx].score < hit.score {
3243                deduped[existing_idx] = hit;
3244            }
3245            // Otherwise keep existing (higher score)
3246        } else {
3247            seen.insert(key, deduped.len());
3248            deduped.push(hit);
3249        }
3250    }
3251
3252    deduped
3253}
3254
3255fn should_try_wildcard_fallback(
3256    returned_hits: usize,
3257    limit: usize,
3258    offset: usize,
3259    sparse_threshold: usize,
3260) -> bool {
3261    if offset != 0 {
3262        return false;
3263    }
3264
3265    let effective_sparse_threshold = if limit == 0 {
3266        sparse_threshold
3267    } else {
3268        sparse_threshold.min(limit)
3269    };
3270
3271    returned_hits < effective_sparse_threshold
3272}
3273
3274fn should_skip_automatic_wildcard_fallback_for_long_zero_hit_query(
3275    query: &str,
3276    returned_hits: usize,
3277) -> bool {
3278    if returned_hits != 0 {
3279        return false;
3280    }
3281
3282    for token in normalize_phrase_terms(query) {
3283        if token.chars().count() > AUTOMATIC_WILDCARD_FALLBACK_MAX_TOKEN_CHARS {
3284            return true;
3285        }
3286    }
3287
3288    false
3289}
3290
3291fn snippet_from_preview_without_full_content(
3292    field_mask: FieldMask,
3293    stored_preview: &str,
3294    query: &str,
3295) -> Option<String> {
3296    if field_mask.needs_content() || !field_mask.wants_snippet() || stored_preview.is_empty() {
3297        return None;
3298    }
3299
3300    cached_prefix_snippet(stored_preview, query, 160)
3301}
3302
3303fn stored_preview_is_complete_content(stored_preview: &str) -> bool {
3304    // The preview builder appends U+2026 only when truncating. A real message
3305    // ending with that character becomes a conservative false negative here.
3306    !stored_preview.is_empty() && !stored_preview.ends_with('…')
3307}
3308
3309impl SearchClient {
3310    pub fn open(index_path: &Path, db_path: Option<&Path>) -> Result<Option<Self>> {
3311        Self::open_with_options(index_path, db_path, SearchClientOptions::default())
3312    }
3313
3314    pub fn open_with_options(
3315        index_path: &Path,
3316        db_path: Option<&Path>,
3317        options: SearchClientOptions,
3318    ) -> Result<Option<Self>> {
3319        let tantivy = fs_cass_open_search_reader(index_path, ReloadPolicy::Manual).ok();
3320        let client_id = SEARCH_CLIENT_INSTANCE_COUNTER.fetch_add(1, Ordering::Relaxed);
3321        let cache_namespace = format!(
3322            "v{}|schema:{}|client:{}|index:{}",
3323            CACHE_KEY_VERSION,
3324            FS_CASS_SCHEMA_HASH,
3325            client_id,
3326            index_path.display()
3327        );
3328        let federated_readers = if tantivy.is_none() {
3329            crate::search::tantivy::open_federated_search_readers(index_path, ReloadPolicy::Manual)
3330                .ok()
3331                .flatten()
3332                .filter(|readers| !readers.is_empty())
3333                .map(|readers| {
3334                    Arc::new(
3335                        readers
3336                            .into_iter()
3337                            .map(|(reader, fields)| FederatedIndexReader { reader, fields })
3338                            .collect::<Vec<_>>(),
3339                    )
3340                })
3341        } else {
3342            None
3343        };
3344
3345        let sqlite_path = db_path.map(Path::to_path_buf).filter(|path| path.exists());
3346
3347        if tantivy.is_none() && federated_readers.is_none() && sqlite_path.is_some() {
3348            tracing::warn!(
3349                index_path = %index_path.display(),
3350                "Tantivy search index not found or incompatible. \
3351                 Search results will be degraded. \
3352                 Run `cass index --full` to rebuild the index."
3353            );
3354        }
3355
3356        if tantivy.is_none() && federated_readers.is_none() && sqlite_path.is_none() {
3357            return Ok(None);
3358        }
3359
3360        let reload_epoch = Arc::new(AtomicU64::new(0));
3361        let metrics = Metrics::default();
3362
3363        let warm_pair = if options.enable_warm
3364            && let Some((reader, fields)) = &tantivy
3365        {
3366            maybe_spawn_warm_worker(
3367                reader.clone(),
3368                *fields,
3369                reload_epoch.clone(),
3370                metrics.clone(),
3371            )
3372        } else {
3373            None
3374        };
3375
3376        if let Some(readers) = &federated_readers {
3377            FEDERATED_SEARCH_READERS
3378                .write()
3379                .insert(cache_namespace.clone(), Arc::clone(readers));
3380        } else {
3381            FEDERATED_SEARCH_READERS.write().remove(&cache_namespace);
3382        }
3383
3384        Ok(Some(Self {
3385            reader: tantivy,
3386            sqlite: Mutex::new(None),
3387            sqlite_path,
3388            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
3389            reload_on_search: options.enable_reload,
3390            last_reload: Mutex::new(None),
3391            last_generation: Mutex::new(None),
3392            reload_epoch,
3393            warm_tx: warm_pair.as_ref().map(|(tx, _)| tx.clone()),
3394            _warm_handle: warm_pair.map(|(_, h)| h),
3395            metrics,
3396            cache_namespace,
3397            semantic: Mutex::new(None),
3398            last_tantivy_total_count: Mutex::new(None),
3399        }))
3400    }
3401
3402    fn sqlite_guard(&self) -> Result<std::sync::MutexGuard<'_, Option<SendConnection>>> {
3403        let mut guard = self
3404            .sqlite
3405            .lock()
3406            .map_err(|_| anyhow!("sqlite lock poisoned"))?;
3407
3408        if guard.is_none()
3409            && let Some(path) = &self.sqlite_path
3410        {
3411            match open_search_hydration_sqlite(path, std::time::Duration::from_secs(1)) {
3412                Ok(conn) => {
3413                    *guard = Some(SendConnection(conn));
3414                }
3415                Err(err) => {
3416                    tracing::debug!(
3417                        error = %err,
3418                        path = %path.display(),
3419                        "readonly sqlite open failed for search client"
3420                    );
3421                }
3422            }
3423        }
3424
3425        Ok(guard)
3426    }
3427
3428    pub fn search(
3429        &self,
3430        query: &str,
3431        filters: SearchFilters,
3432        limit: usize,
3433        offset: usize,
3434        field_mask: FieldMask,
3435    ) -> Result<Vec<SearchHit>> {
3436        // NFC-normalize early so every downstream consumer (Tantivy query
3437        // builder, sanitizer, FTS5 fallback) sees consistent Unicode form
3438        // matching the NFC-indexed content.
3439        use unicode_normalization::UnicodeNormalization;
3440        let query: String = query.nfc().collect();
3441        let query: &str = &query;
3442        let sanitized = nfc_sanitize_query(query);
3443        let field_mask = effective_field_mask(field_mask);
3444        let limit = if limit == 0 {
3445            self.total_docs().min(no_limit_result_cap()).max(1)
3446        } else {
3447            limit
3448        };
3449        let can_use_cache =
3450            field_mask.allows_cache() && (field_mask.needs_content() || field_mask.wants_snippet());
3451
3452        // Invalidate prefix cache if the index has been updated since last search.
3453        // This must happen BEFORE the cache check below to avoid serving stale results.
3454        if let Some((reader, _)) = &self.reader {
3455            self.maybe_reload_reader(reader)?;
3456            let searcher = self.searcher_for_thread(reader);
3457            self.track_generation(searcher.generation().generation_id());
3458        } else if let Some(readers) = self.federated_readers()
3459            && let Some(signature) = self.maybe_reload_federated_readers(readers.as_ref())?
3460        {
3461            self.track_generation(signature);
3462        }
3463
3464        // Fast path: reuse cached prefix when user is typing forward (offset 0 only).
3465        // Only use cache for simple queries (no wildcards, no boolean operators) because
3466        // the cache matching logic enforces strict prefix AND semantics which is incorrect
3467        // for suffixes, substrings, OR, NOT, or phrases.
3468        if can_use_cache
3469            && offset == 0
3470            && !query.contains('*')
3471            && !fs_cass_has_boolean_operators(query)
3472        {
3473            self.maybe_schedule_adaptive_query_prewarm(&sanitized, &filters);
3474            if let Some(cached) = self.cached_prefix_hits(&sanitized, &filters) {
3475                // Opt 2.4: Pre-compute lowercase query terms once, reuse for all hits
3476                let query_terms = QueryTermsLower::from_query(&sanitized);
3477                let mut filtered: Vec<SearchHit> = cached
3478                    .into_iter()
3479                    .filter(|h| hit_matches_query_cached_precomputed(h, &query_terms))
3480                    .map(|c| c.hit.clone())
3481                    .collect();
3482                if filtered.len() >= limit {
3483                    filtered.truncate(limit);
3484                    self.metrics.inc_cache_hits();
3485                    self.maybe_log_cache_metrics("hit");
3486                    return Ok(filtered);
3487                }
3488                // Cache had entries but not enough to satisfy limit - shortfall, not miss
3489                self.metrics.inc_cache_shortfall();
3490                self.maybe_log_cache_metrics("shortfall");
3491            } else {
3492                // No cached prefix at all - this is the actual miss
3493                self.metrics.inc_cache_miss();
3494                self.maybe_log_cache_metrics("miss");
3495            }
3496        }
3497
3498        // Adaptive fetch sizing: start at 2x target to reduce common-case work,
3499        // retry at 3x only when deduplication causes shortfall.
3500        // We always fetch from 0 to preserve global deduplication correctness.
3501        let target_hits = offset.saturating_add(limit);
3502        let initial_fetch_limit = if target_hits <= 16 {
3503            target_hits.saturating_mul(2)
3504        } else {
3505            // Larger pages benefit from a lower first-pass over-fetch.
3506            // Retry logic below preserves correctness on duplicate-heavy corpora.
3507            target_hits.saturating_mul(3).div_ceil(2)
3508        };
3509        let session_path_filter_active = !filters.session_paths.is_empty();
3510        let fallback_fetch_limit = if session_path_filter_active {
3511            self.total_docs()
3512                .min(no_limit_result_cap())
3513                .max(target_hits.saturating_mul(3))
3514                .max(1)
3515        } else {
3516            target_hits.saturating_mul(3)
3517        };
3518
3519        // Tantivy is the primary high-performance engine.
3520        if let Some((reader, fields)) = &self.reader {
3521            tracing::info!(
3522                backend = "tantivy",
3523                query = sanitized,
3524                limit = initial_fetch_limit,
3525                offset = 0,
3526                "search_start"
3527            );
3528            let (hits, tantivy_total_count) = self.search_tantivy(
3529                reader,
3530                fields,
3531                query,
3532                &sanitized,
3533                filters.clone(),
3534                initial_fetch_limit,
3535                0, // Always fetch from 0 for global dedup
3536                field_mask,
3537            )?;
3538            if let Ok(mut tc) = self.last_tantivy_total_count.lock() {
3539                *tc = Some(tantivy_total_count);
3540            }
3541            if !hits.is_empty() {
3542                let initial_hit_count = hits.len();
3543                let page_hits = |raw_hits: Vec<SearchHit>| {
3544                    self.postprocess_hits_page(raw_hits, &sanitized, &filters, limit, offset)
3545                };
3546
3547                let (mut deduped_len, mut paged_hits) = page_hits(hits);
3548
3549                let needs_retry = deduped_len < target_hits
3550                    && initial_hit_count == initial_fetch_limit
3551                    && initial_fetch_limit < fallback_fetch_limit;
3552
3553                if needs_retry {
3554                    tracing::debug!(
3555                        query = sanitized,
3556                        target_hits,
3557                        deduped_len,
3558                        initial_fetch_limit,
3559                        fallback_fetch_limit,
3560                        session_path_filter_active,
3561                        "retrying lexical fetch due to dedup or session-path shortfall"
3562                    );
3563                    let (retry_hits, retry_total_count) = self.search_tantivy(
3564                        reader,
3565                        fields,
3566                        query,
3567                        &sanitized,
3568                        filters.clone(),
3569                        fallback_fetch_limit,
3570                        0,
3571                        field_mask,
3572                    )?;
3573                    if let Ok(mut tc) = self.last_tantivy_total_count.lock() {
3574                        *tc = Some(retry_total_count);
3575                    }
3576                    if !retry_hits.is_empty() {
3577                        (deduped_len, paged_hits) = page_hits(retry_hits);
3578                    }
3579                }
3580
3581                tracing::trace!(
3582                    query = sanitized,
3583                    target_hits,
3584                    deduped_len,
3585                    returned = paged_hits.len(),
3586                    "lexical fetch complete"
3587                );
3588
3589                if can_use_cache && offset == 0 {
3590                    self.put_cache(&sanitized, &filters, &paged_hits);
3591                }
3592                return Ok(paged_hits);
3593            }
3594            tracing::debug!(
3595                query = sanitized,
3596                "tantivy returned zero hits; skipping sqlite fallback because tantivy is authoritative when available"
3597            );
3598            return Ok(Vec::new());
3599        } else if let Some(readers) = self.federated_readers() {
3600            tracing::info!(
3601                backend = "tantivy-federated",
3602                query = sanitized,
3603                limit = initial_fetch_limit,
3604                offset = 0,
3605                shards = readers.len(),
3606                "search_start"
3607            );
3608            let (hits, tantivy_total_count) = self.search_tantivy_federated(
3609                readers.as_ref(),
3610                query,
3611                &sanitized,
3612                filters.clone(),
3613                initial_fetch_limit,
3614                field_mask,
3615            )?;
3616            if let Ok(mut tc) = self.last_tantivy_total_count.lock() {
3617                *tc = Some(tantivy_total_count);
3618            }
3619            if !hits.is_empty() {
3620                let initial_hit_count = hits.len();
3621                let page_hits = |raw_hits: Vec<SearchHit>| {
3622                    self.postprocess_hits_page(raw_hits, &sanitized, &filters, limit, offset)
3623                };
3624
3625                let (mut deduped_len, mut paged_hits) = page_hits(hits);
3626                let expected_federated_capacity = initial_fetch_limit.saturating_mul(readers.len());
3627                let federated_initial_capacity_reached = if session_path_filter_active {
3628                    initial_hit_count >= initial_fetch_limit.min(expected_federated_capacity)
3629                } else {
3630                    initial_hit_count == expected_federated_capacity
3631                };
3632                let needs_retry = deduped_len < target_hits
3633                    && federated_initial_capacity_reached
3634                    && initial_fetch_limit < fallback_fetch_limit;
3635
3636                if needs_retry {
3637                    tracing::debug!(
3638                        query = sanitized,
3639                        target_hits,
3640                        deduped_len,
3641                        initial_fetch_limit,
3642                        fallback_fetch_limit,
3643                        shards = readers.len(),
3644                        session_path_filter_active,
3645                        "retrying federated lexical fetch due to dedup or session-path shortfall"
3646                    );
3647                    let (retry_hits, retry_total_count) = self.search_tantivy_federated(
3648                        readers.as_ref(),
3649                        query,
3650                        &sanitized,
3651                        filters.clone(),
3652                        fallback_fetch_limit,
3653                        field_mask,
3654                    )?;
3655                    if let Ok(mut tc) = self.last_tantivy_total_count.lock() {
3656                        *tc = Some(retry_total_count);
3657                    }
3658                    if !retry_hits.is_empty() {
3659                        (deduped_len, paged_hits) = page_hits(retry_hits);
3660                    }
3661                }
3662
3663                tracing::trace!(
3664                    query = sanitized,
3665                    target_hits,
3666                    deduped_len,
3667                    returned = paged_hits.len(),
3668                    shards = readers.len(),
3669                    "federated lexical fetch complete"
3670                );
3671
3672                if can_use_cache && offset == 0 {
3673                    self.put_cache(&sanitized, &filters, &paged_hits);
3674                }
3675                return Ok(paged_hits);
3676            }
3677            tracing::debug!(
3678                query = sanitized,
3679                shards = readers.len(),
3680                "federated tantivy returned zero hits; skipping sqlite fallback because tantivy is authoritative when available"
3681            );
3682            return Ok(Vec::new());
3683        }
3684
3685        // Skip SQLite fallback when the query contains leading/internal wildcards that
3686        // FTS5 cannot parse (e.g., "*handler" or "f*o").
3687        // We ALLOW trailing wildcards ("foo*") as FTS5 supports prefix matching.
3688        let unsupported_wildcards = sanitized.split_whitespace().any(|t| {
3689            let core = t.trim_end_matches('*');
3690            core.contains('*') // Any star remaining after trimming end is unsupported (leading or internal)
3691        });
3692
3693        if unsupported_wildcards {
3694            return Ok(Vec::new());
3695        }
3696
3697        let has_sqlite_backend = {
3698            let sqlite_guard = self
3699                .sqlite
3700                .lock()
3701                .map_err(|_| anyhow!("sqlite lock poisoned"))?;
3702            sqlite_guard.is_some() || self.sqlite_path.is_some()
3703        };
3704
3705        if has_sqlite_backend {
3706            tracing::info!(
3707                backend = "sqlite-fts5",
3708                query = sanitized,
3709                limit = fallback_fetch_limit,
3710                offset = 0,
3711                "search_start"
3712            );
3713            let hits = self.search_sqlite_fts5(
3714                self.sqlite_path
3715                    .as_deref()
3716                    .unwrap_or_else(|| Path::new(":memory:")),
3717                query,
3718                filters.clone(),
3719                fallback_fetch_limit,
3720                0, // Always fetch from 0 for global dedup
3721                field_mask,
3722            )?;
3723            let (_, paged_hits) =
3724                self.postprocess_hits_page(hits, &sanitized, &filters, limit, offset);
3725
3726            if can_use_cache && offset == 0 {
3727                self.put_cache(&sanitized, &filters, &paged_hits);
3728            }
3729            return Ok(paged_hits);
3730        }
3731
3732        tracing::info!(backend = "none", query = query, "search_start");
3733        Ok(Vec::new())
3734    }
3735
3736    pub fn set_semantic_context(
3737        &self,
3738        embedder: Arc<dyn Embedder>,
3739        fs_semantic_index: VectorIndex,
3740        filter_maps: SemanticFilterMaps,
3741        roles: Option<HashSet<u8>>,
3742        ann_path: Option<PathBuf>,
3743    ) -> Result<()> {
3744        self.set_semantic_indexes_context(
3745            embedder,
3746            vec![fs_semantic_index],
3747            filter_maps,
3748            roles,
3749            ann_path,
3750        )
3751    }
3752
3753    pub fn set_semantic_indexes_context(
3754        &self,
3755        embedder: Arc<dyn Embedder>,
3756        fs_semantic_indexes: Vec<VectorIndex>,
3757        filter_maps: SemanticFilterMaps,
3758        roles: Option<HashSet<u8>>,
3759        ann_path: Option<PathBuf>,
3760    ) -> Result<()> {
3761        if fs_semantic_indexes.is_empty() {
3762            bail!("semantic context requires at least one vector index");
3763        }
3764
3765        let fs_semantic_indexes = fs_semantic_indexes
3766            .into_iter()
3767            .map(|index| {
3768                let embedder_id = index.embedder_id().to_string();
3769                let dimension = index.dimension();
3770                if embedder_id != embedder.id() {
3771                    bail!(
3772                        "embedder mismatch: index uses {}, embedder is {}",
3773                        embedder_id,
3774                        embedder.id()
3775                    );
3776                }
3777                if dimension != embedder.dimension() {
3778                    bail!(
3779                        "embedder dimension mismatch: index uses {}, embedder is {}",
3780                        dimension,
3781                        embedder.dimension()
3782                    );
3783                }
3784                Ok(Arc::new(index))
3785            })
3786            .collect::<Result<Vec<_>>>()?;
3787        let fs_semantic_index = Arc::clone(&fs_semantic_indexes[0]);
3788        let shard_count = fs_semantic_indexes.len();
3789        let ann_path = if shard_count == 1 { ann_path } else { None };
3790        let embedder_id = fs_semantic_index.embedder_id().to_string();
3791        let dimension = fs_semantic_index.dimension();
3792        let fs_semantic_indexes = Arc::new(fs_semantic_indexes);
3793
3794        let capacity = NonZeroUsize::new(100).ok_or_else(|| anyhow!("invalid cache size"))?;
3795        let context_token = Arc::new(());
3796        let mut state_guard = self
3797            .semantic
3798            .lock()
3799            .map_err(|_| anyhow!("semantic lock poisoned"))?;
3800        *state_guard = Some(SemanticSearchState {
3801            context_token,
3802            embedder,
3803            fs_semantic_index,
3804            fs_semantic_indexes,
3805            fs_ann_index: None,
3806            ann_path,
3807            fs_in_memory_two_tier_index: None,
3808            in_memory_two_tier_unavailable: InMemoryTwoTierUnavailable::default(),
3809            progressive_context: None,
3810            progressive_context_unavailable: false,
3811            filter_maps,
3812            roles,
3813            query_cache: QueryCache::new(embedder_id.as_str(), capacity),
3814        });
3815        if shard_count > 1 {
3816            tracing::info!(
3817                shard_count,
3818                dimension,
3819                embedder = embedder_id,
3820                "semantic search context loaded sharded vector generation"
3821            );
3822        }
3823        Ok(())
3824    }
3825
3826    pub fn clear_semantic_context(&self) -> Result<()> {
3827        let mut guard = self
3828            .semantic
3829            .lock()
3830            .map_err(|_| anyhow!("semantic lock poisoned"))?;
3831        *guard = None;
3832        Ok(())
3833    }
3834
3835    fn semantic_context_matches(&self, context_token: &Arc<()>) -> Result<bool> {
3836        let guard = self
3837            .semantic
3838            .lock()
3839            .map_err(|_| anyhow!("semantic lock poisoned"))?;
3840        Ok(guard
3841            .as_ref()
3842            .is_some_and(|state| Arc::ptr_eq(&state.context_token, context_token)))
3843    }
3844
3845    fn semantic_query_embedding(&self, canonical: &str) -> Result<SemanticQueryEmbedding> {
3846        loop {
3847            let (embedder, context_token) = {
3848                let mut guard = self
3849                    .semantic
3850                    .lock()
3851                    .map_err(|_| anyhow!("semantic lock poisoned"))?;
3852                let state = guard.as_mut().ok_or_else(|| {
3853                    anyhow!("semantic search unavailable (no embedder or vector index)")
3854                })?;
3855                if let Some(hit) = state
3856                    .query_cache
3857                    .get_cached(state.embedder.as_ref(), canonical)
3858                {
3859                    return Ok(SemanticQueryEmbedding {
3860                        context_token: Arc::clone(&state.context_token),
3861                        vector: hit,
3862                    });
3863                }
3864                (
3865                    Arc::clone(&state.embedder),
3866                    Arc::clone(&state.context_token),
3867                )
3868            };
3869
3870            let embedding = embedder
3871                .embed_sync(canonical)
3872                .map_err(|e| anyhow!("embedding failed: {e}"))?;
3873
3874            let mut guard = self
3875                .semantic
3876                .lock()
3877                .map_err(|_| anyhow!("semantic lock poisoned"))?;
3878            let state = guard.as_mut().ok_or_else(|| {
3879                anyhow!("semantic search unavailable (no embedder or vector index)")
3880            })?;
3881            if !Arc::ptr_eq(&state.context_token, &context_token) {
3882                continue;
3883            }
3884            if let Some(hit) = state
3885                .query_cache
3886                .get_cached(state.embedder.as_ref(), canonical)
3887            {
3888                return Ok(SemanticQueryEmbedding {
3889                    context_token,
3890                    vector: hit,
3891                });
3892            }
3893            state
3894                .query_cache
3895                .store(state.embedder.as_ref(), canonical, embedding.clone());
3896            return Ok(SemanticQueryEmbedding {
3897                context_token,
3898                vector: embedding,
3899            });
3900        }
3901    }
3902
3903    fn in_memory_two_tier_index(
3904        &self,
3905        tier_mode: SemanticTierMode,
3906    ) -> Result<Option<Arc<FsInMemoryTwoTierIndex>>> {
3907        loop {
3908            let (ann_path, embedder_id, context_token) = {
3909                let mut guard = self
3910                    .semantic
3911                    .lock()
3912                    .map_err(|_| anyhow!("semantic lock poisoned"))?;
3913                let state = guard.as_mut().ok_or_else(|| {
3914                    anyhow!("semantic search unavailable (no embedder or vector index)")
3915                })?;
3916                if let Some(index) = state.fs_in_memory_two_tier_index.as_ref()
3917                    && two_tier_index_supports_mode(index.as_ref(), tier_mode)
3918                {
3919                    return Ok(Some(Arc::clone(index)));
3920                }
3921                if state
3922                    .in_memory_two_tier_unavailable
3923                    .is_known_unavailable(tier_mode)
3924                {
3925                    return Ok(None);
3926                }
3927                (
3928                    state.ann_path.clone(),
3929                    state.embedder.id().to_string(),
3930                    Arc::clone(&state.context_token),
3931                )
3932            };
3933
3934            let index = build_in_memory_two_tier_index(ann_path.clone(), &embedder_id, tier_mode);
3935
3936            let mut guard = self
3937                .semantic
3938                .lock()
3939                .map_err(|_| anyhow!("semantic lock poisoned"))?;
3940            let state = guard.as_mut().ok_or_else(|| {
3941                anyhow!("semantic search unavailable (no embedder or vector index)")
3942            })?;
3943            if let Some(existing) = state.fs_in_memory_two_tier_index.as_ref()
3944                && two_tier_index_supports_mode(existing.as_ref(), tier_mode)
3945            {
3946                return Ok(Some(Arc::clone(existing)));
3947            }
3948            if !Arc::ptr_eq(&state.context_token, &context_token) {
3949                continue;
3950            }
3951            let Some(index) = index else {
3952                state
3953                    .in_memory_two_tier_unavailable
3954                    .mark_unavailable(tier_mode);
3955                return Ok(None);
3956            };
3957            if !two_tier_index_supports_mode(index.as_ref(), tier_mode) {
3958                state
3959                    .in_memory_two_tier_unavailable
3960                    .mark_unavailable(tier_mode);
3961                return Ok(None);
3962            }
3963            state.fs_in_memory_two_tier_index = Some(Arc::clone(&index));
3964            if index.has_quality_index() {
3965                state.in_memory_two_tier_unavailable = InMemoryTwoTierUnavailable::default();
3966            } else {
3967                state.in_memory_two_tier_unavailable.fast_only = false;
3968            }
3969            return Ok(Some(index));
3970        }
3971    }
3972
3973    fn ann_index(&self) -> Result<Arc<FsHnswIndex>> {
3974        loop {
3975            let (ann_path, fs_semantic_index) = {
3976                let mut guard = self
3977                    .semantic
3978                    .lock()
3979                    .map_err(|_| anyhow!("semantic lock poisoned"))?;
3980                let state = guard.as_mut().ok_or_else(|| {
3981                    anyhow!("semantic search unavailable (no embedder or vector index)")
3982                })?;
3983                if let Some(index) = state.fs_ann_index.as_ref() {
3984                    return Ok(Arc::clone(index));
3985                }
3986                let ann_path = state.ann_path.clone().ok_or_else(|| {
3987                    anyhow!(
3988                        "approximate search unavailable: HNSW index missing (run 'cass index --semantic --build-hnsw')"
3989                    )
3990                })?;
3991                (ann_path, Arc::clone(&state.fs_semantic_index))
3992            };
3993
3994            let ann = Arc::new(open_fs_semantic_ann_index(
3995                fs_semantic_index.as_ref(),
3996                &ann_path,
3997            )?);
3998
3999            let mut guard = self
4000                .semantic
4001                .lock()
4002                .map_err(|_| anyhow!("semantic lock poisoned"))?;
4003            let state = guard.as_mut().ok_or_else(|| {
4004                anyhow!("semantic search unavailable (no embedder or vector index)")
4005            })?;
4006            if let Some(existing) = state.fs_ann_index.as_ref() {
4007                return Ok(Arc::clone(existing));
4008            }
4009            if state.ann_path.as_ref() != Some(&ann_path)
4010                || !Arc::ptr_eq(&state.fs_semantic_index, &fs_semantic_index)
4011            {
4012                continue;
4013            }
4014            state.fs_ann_index = Some(Arc::clone(&ann));
4015            return Ok(ann);
4016        }
4017    }
4018
4019    fn collapse_semantic_results(
4020        best_by_message: HashMap<u64, VectorSearchResult>,
4021        fetch_limit: usize,
4022    ) -> Vec<VectorSearchResult> {
4023        let mut collapsed: Vec<VectorSearchResult> = best_by_message.into_values().collect();
4024        collapsed.sort_by(|a, b| {
4025            b.score
4026                .total_cmp(&a.score)
4027                .then_with(|| a.message_id.cmp(&b.message_id))
4028        });
4029        if collapsed.len() > fetch_limit {
4030            collapsed.truncate(fetch_limit);
4031        }
4032        collapsed
4033    }
4034
4035    fn semantic_exact_candidate_limit(fetch_limit: usize, record_count: usize) -> usize {
4036        fetch_limit
4037            .saturating_mul(SEMANTIC_EXACT_CHUNK_OVERFETCH_MULTIPLIER)
4038            .max(fetch_limit)
4039            .min(record_count)
4040    }
4041
4042    fn semantic_window_may_omit_competitor(
4043        collapsed: &[VectorSearchResult],
4044        fetch_limit: usize,
4045        max_omitted_score: Option<f32>,
4046    ) -> bool {
4047        if fetch_limit == 0 {
4048            return false;
4049        }
4050        let Some(max_omitted_score) = max_omitted_score else {
4051            return false;
4052        };
4053        if collapsed.len() < fetch_limit {
4054            return true;
4055        }
4056        let Some(last_in_requested_window) = collapsed.get(fetch_limit - 1) else {
4057            return true;
4058        };
4059        !last_in_requested_window
4060            .score
4061            .total_cmp(&max_omitted_score)
4062            .is_gt()
4063    }
4064
4065    fn record_fs_semantic_hit(
4066        best_by_message: &mut HashMap<u64, VectorSearchResult>,
4067        hit: &FsVectorHit,
4068    ) {
4069        let Some(parsed) = parse_semantic_doc_id(&hit.doc_id) else {
4070            return;
4071        };
4072        best_by_message
4073            .entry(parsed.message_id)
4074            .and_modify(|entry| {
4075                if hit.score > entry.score {
4076                    entry.score = hit.score;
4077                    entry.chunk_idx = parsed.chunk_idx;
4078                }
4079            })
4080            .or_insert(VectorSearchResult {
4081                message_id: parsed.message_id,
4082                chunk_idx: parsed.chunk_idx,
4083                score: hit.score,
4084            });
4085    }
4086
4087    fn search_exact_semantic_indexes(
4088        context: &SemanticCandidateContext,
4089        embedding: &[f32],
4090        fetch_limit: usize,
4091        fs_filter: Option<&dyn FsSearchFilter>,
4092    ) -> Result<(Vec<VectorSearchResult>, SemanticCandidateRetryState)> {
4093        if context.fs_semantic_indexes.len() == 1 {
4094            let record_count = context.fs_semantic_index.record_count();
4095            let candidate_limit = Self::semantic_exact_candidate_limit(fetch_limit, record_count);
4096            let fs_hits = context
4097                .fs_semantic_index
4098                .search_top_k(embedding, candidate_limit, fs_filter)
4099                .map_err(|err| anyhow!("frankensearch semantic search failed: {err}"))?;
4100            let mut best_by_message = HashMap::with_capacity(fs_hits.len());
4101            for hit in &fs_hits {
4102                Self::record_fs_semantic_hit(&mut best_by_message, hit);
4103            }
4104            let collapsed = Self::collapse_semantic_results(best_by_message, candidate_limit);
4105            let has_more_candidates =
4106                fs_hits.len() >= candidate_limit && candidate_limit < record_count;
4107            let max_omitted_score = if has_more_candidates {
4108                fs_hits.last().map(|hit| hit.score)
4109            } else {
4110                None
4111            };
4112            let exact_window_may_omit_competitor = Self::semantic_window_may_omit_competitor(
4113                &collapsed,
4114                fetch_limit,
4115                max_omitted_score,
4116            );
4117            return Ok((
4118                collapsed,
4119                SemanticCandidateRetryState {
4120                    has_more_candidates,
4121                    exact_window_may_omit_competitor,
4122                },
4123            ));
4124        }
4125
4126        let mut best_by_message = HashMap::new();
4127        let mut raw_hits = 0usize;
4128        let mut max_omitted_score: Option<f32> = None;
4129        let mut has_more_candidates = false;
4130        for index in context.fs_semantic_indexes.iter() {
4131            let shard_record_count = index.record_count();
4132            // Search chunks, then collapse by message. A message can have many
4133            // high-scoring chunks, so per-shard top-k chunks alone is not a
4134            // proof of per-message top-k. Use a bounded overfetch window and
4135            // retry only when the omitted-score bound can still beat the last
4136            // collapsed message in the requested window.
4137            let shard_limit = Self::semantic_exact_candidate_limit(fetch_limit, shard_record_count);
4138            if shard_limit == 0 {
4139                continue;
4140            }
4141            let fs_hits = index
4142                .search_top_k(embedding, shard_limit, fs_filter)
4143                .map_err(|err| anyhow!("frankensearch sharded semantic search failed: {err}"))?;
4144            if fs_hits.len() >= shard_limit
4145                && shard_limit < shard_record_count
4146                && let Some(last_hit) = fs_hits.last()
4147            {
4148                has_more_candidates = true;
4149                max_omitted_score = Some(
4150                    max_omitted_score
4151                        .map(|current| current.max(last_hit.score))
4152                        .unwrap_or(last_hit.score),
4153                );
4154            }
4155            raw_hits = raw_hits.saturating_add(fs_hits.len());
4156            best_by_message.reserve(fs_hits.len());
4157            for hit in &fs_hits {
4158                Self::record_fs_semantic_hit(&mut best_by_message, hit);
4159            }
4160        }
4161        let candidate_return_limit = Self::semantic_exact_candidate_limit(fetch_limit, raw_hits);
4162        let collapsed = Self::collapse_semantic_results(best_by_message, candidate_return_limit);
4163        let exact_window_may_omit_competitor =
4164            Self::semantic_window_may_omit_competitor(&collapsed, fetch_limit, max_omitted_score);
4165        tracing::debug!(
4166            shard_count = context.fs_semantic_indexes.len(),
4167            raw_hits,
4168            returned = collapsed.len(),
4169            "semantic sharded exact merge complete"
4170        );
4171        Ok((
4172            collapsed,
4173            SemanticCandidateRetryState {
4174                has_more_candidates,
4175                exact_window_may_omit_competitor,
4176            },
4177        ))
4178    }
4179
4180    fn search_semantic_candidates(
4181        &self,
4182        context: &SemanticCandidateContext,
4183        embedding: &[f32],
4184        filters: &SearchFilters,
4185        request: SemanticCandidateSearchRequest<'_>,
4186    ) -> Result<(
4187        Vec<VectorSearchResult>,
4188        SemanticCandidateRetryState,
4189        Option<crate::search::ann_index::AnnSearchStats>,
4190    )> {
4191        let mut semantic_filter =
4192            SemanticFilter::from_search_filters(filters, &context.filter_maps)?;
4193        if let Some(roles) = context.roles.clone() {
4194            semantic_filter = semantic_filter.with_roles(Some(roles));
4195        }
4196
4197        if request.tier_mode.wants_two_tier() && !request.approximate {
4198            let fs_filter = semantic_filter_as_search_filter(&semantic_filter);
4199            if let Some(two_tier_index) = request.in_memory_two_tier_index {
4200                let config = request.tier_mode.to_frankensearch_config();
4201                let searcher = FsSyncTwoTierSearcher::new(Arc::clone(two_tier_index), config);
4202                let (tier_hits, metrics) = searcher
4203                    .search_collect_with_filter(embedding, request.fetch_limit, fs_filter)
4204                    .map_err(|err| {
4205                        anyhow!("frankensearch two-tier semantic search failed: {err}")
4206                    })?;
4207
4208                tracing::debug!(
4209                    tier_mode = ?request.tier_mode,
4210                    phase1_ms = metrics.phase1_total_ms,
4211                    phase2_ms = metrics.phase2_total_ms,
4212                    skip_reason = ?metrics.skip_reason,
4213                    returned = tier_hits.len(),
4214                    "semantic two-tier search executed"
4215                );
4216
4217                let mut best_by_message: HashMap<u64, VectorSearchResult> =
4218                    HashMap::with_capacity(tier_hits.len());
4219                for hit in tier_hits.iter() {
4220                    let Some(parsed) = parse_semantic_doc_id(&hit.doc_id) else {
4221                        continue;
4222                    };
4223                    best_by_message
4224                        .entry(parsed.message_id)
4225                        .and_modify(|entry| {
4226                            if hit.score > entry.score {
4227                                entry.score = hit.score;
4228                                entry.chunk_idx = parsed.chunk_idx;
4229                            }
4230                        })
4231                        .or_insert(VectorSearchResult {
4232                            message_id: parsed.message_id,
4233                            chunk_idx: parsed.chunk_idx,
4234                            score: hit.score,
4235                        });
4236                }
4237
4238                return Ok((
4239                    Self::collapse_semantic_results(best_by_message, request.fetch_limit),
4240                    SemanticCandidateRetryState {
4241                        has_more_candidates: tier_hits.len() >= request.fetch_limit,
4242                        exact_window_may_omit_competitor: false,
4243                    },
4244                    None,
4245                ));
4246            }
4247
4248            tracing::debug!(
4249                tier_mode = ?request.tier_mode,
4250                "two-tier semantic unavailable; falling back to exact single-tier search"
4251            );
4252
4253            let fs_filter = semantic_filter_as_search_filter(&semantic_filter);
4254            let (results, truncated) = Self::search_exact_semantic_indexes(
4255                context,
4256                embedding,
4257                request.fetch_limit,
4258                fs_filter,
4259            )?;
4260            return Ok((results, truncated, None));
4261        }
4262
4263        if request.approximate {
4264            if request.tier_mode.wants_two_tier() {
4265                tracing::debug!(
4266                    tier_mode = ?request.tier_mode,
4267                    "approximate search requested; bypassing two-tier mode"
4268                );
4269            }
4270
4271            let ann = request
4272                .ann_index
4273                .ok_or_else(|| anyhow!("HNSW index failed to initialize"))?;
4274            let candidate = request
4275                .fetch_limit
4276                .saturating_mul(ANN_CANDIDATE_MULTIPLIER)
4277                .max(request.fetch_limit);
4278            let ef = FS_HNSW_DEFAULT_EF_SEARCH.max(candidate);
4279            let (ann_results, search_stats) =
4280                ann.knn_search_with_stats(embedding, candidate, ef)
4281                    .map_err(|err| anyhow!("frankensearch approximate search failed: {err}"))?;
4282            let ann_stats = Some(crate::search::ann_index::AnnSearchStats {
4283                index_size: search_stats.index_size,
4284                dimension: search_stats.dimension,
4285                ef_search: search_stats.ef_search,
4286                k_requested: search_stats.k_requested,
4287                k_returned: search_stats.k_returned,
4288                search_time_us: search_stats.search_time_us,
4289                estimated_recall: search_stats.estimated_recall as f32,
4290                is_approximate: search_stats.is_approximate,
4291            });
4292
4293            let fs_filter = semantic_filter_as_search_filter(&semantic_filter);
4294
4295            let mut best_by_message: HashMap<u64, VectorSearchResult> =
4296                HashMap::with_capacity(ann_results.len());
4297            for hit in ann_results.iter() {
4298                if let Some(filter) = fs_filter
4299                    && !filter.matches(&hit.doc_id, None)
4300                {
4301                    continue;
4302                }
4303                let Some(parsed) = parse_semantic_doc_id(&hit.doc_id) else {
4304                    continue;
4305                };
4306                best_by_message
4307                    .entry(parsed.message_id)
4308                    .and_modify(|entry| {
4309                        if hit.score > entry.score {
4310                            entry.score = hit.score;
4311                            entry.chunk_idx = parsed.chunk_idx;
4312                        }
4313                    })
4314                    .or_insert(VectorSearchResult {
4315                        message_id: parsed.message_id,
4316                        chunk_idx: parsed.chunk_idx,
4317                        score: hit.score,
4318                    });
4319            }
4320
4321            return Ok((
4322                Self::collapse_semantic_results(best_by_message, request.fetch_limit),
4323                SemanticCandidateRetryState {
4324                    has_more_candidates: ann_results.len() >= candidate,
4325                    exact_window_may_omit_competitor: false,
4326                },
4327                ann_stats,
4328            ));
4329        }
4330
4331        let fs_filter = semantic_filter_as_search_filter(&semantic_filter);
4332        let (results, truncated) = Self::search_exact_semantic_indexes(
4333            context,
4334            embedding,
4335            request.fetch_limit,
4336            fs_filter,
4337        )?;
4338        Ok((results, truncated, None))
4339    }
4340
4341    pub fn can_progressively_refine(&self) -> bool {
4342        self.progressive_context()
4343            .map(|context| {
4344                context.as_ref().is_some_and(|ctx| {
4345                    ctx.quality_embedder.is_some() && ctx.index.has_quality_index()
4346                })
4347            })
4348            .unwrap_or(false)
4349    }
4350
4351    fn progressive_context(&self) -> Result<Option<Arc<ProgressiveTwoTierContext>>> {
4352        loop {
4353            let (ann_path, embedder, context_token) = {
4354                let mut guard = self
4355                    .semantic
4356                    .lock()
4357                    .map_err(|_| anyhow!("semantic lock poisoned"))?;
4358                let state = guard.as_mut().ok_or_else(|| {
4359                    anyhow!("semantic search unavailable (no embedder or vector index)")
4360                })?;
4361                if let Some(context) = state.progressive_context.as_ref() {
4362                    return Ok(Some(Arc::clone(context)));
4363                }
4364                if state.progressive_context_unavailable {
4365                    return Ok(None);
4366                }
4367                (
4368                    state.ann_path.clone(),
4369                    Arc::clone(&state.embedder),
4370                    Arc::clone(&state.context_token),
4371                )
4372            };
4373
4374            let context = match self.build_progressive_context(
4375                ann_path.clone(),
4376                embedder,
4377                Arc::clone(&context_token),
4378            ) {
4379                Ok(context) => context,
4380                Err(err) => {
4381                    let mut guard = self
4382                        .semantic
4383                        .lock()
4384                        .map_err(|_| anyhow!("semantic lock poisoned"))?;
4385                    let state = guard.as_mut().ok_or_else(|| {
4386                        anyhow!("semantic search unavailable (no embedder or vector index)")
4387                    })?;
4388                    if let Some(existing) = state.progressive_context.as_ref() {
4389                        return Ok(Some(Arc::clone(existing)));
4390                    }
4391                    if !Arc::ptr_eq(&state.context_token, &context_token) {
4392                        continue;
4393                    }
4394                    return Err(err);
4395                }
4396            };
4397
4398            let Some(context) = context else {
4399                let mut guard = self
4400                    .semantic
4401                    .lock()
4402                    .map_err(|_| anyhow!("semantic lock poisoned"))?;
4403                let state = guard.as_mut().ok_or_else(|| {
4404                    anyhow!("semantic search unavailable (no embedder or vector index)")
4405                })?;
4406                if let Some(existing) = state.progressive_context.as_ref() {
4407                    return Ok(Some(Arc::clone(existing)));
4408                }
4409                if !Arc::ptr_eq(&state.context_token, &context_token) {
4410                    continue;
4411                }
4412                state.progressive_context_unavailable = true;
4413                return Ok(None);
4414            };
4415
4416            let mut guard = self
4417                .semantic
4418                .lock()
4419                .map_err(|_| anyhow!("semantic lock poisoned"))?;
4420            let state = guard.as_mut().ok_or_else(|| {
4421                anyhow!("semantic search unavailable (no embedder or vector index)")
4422            })?;
4423            if let Some(existing) = state.progressive_context.as_ref() {
4424                return Ok(Some(Arc::clone(existing)));
4425            }
4426            if !Arc::ptr_eq(&state.context_token, &context_token) {
4427                continue;
4428            }
4429            state.progressive_context_unavailable = false;
4430            state.progressive_context = Some(Arc::clone(&context));
4431            return Ok(Some(context));
4432        }
4433    }
4434
4435    fn build_progressive_context(
4436        &self,
4437        ann_path: Option<PathBuf>,
4438        embedder: Arc<dyn Embedder>,
4439        context_token: Arc<()>,
4440    ) -> Result<Option<Arc<ProgressiveTwoTierContext>>> {
4441        let Some(index_dir) = ann_path
4442            .as_ref()
4443            .and_then(|path| path.parent().map(Path::to_path_buf))
4444        else {
4445            return Ok(None);
4446        };
4447
4448        let fast_path = {
4449            let explicit = index_dir.join("vector.fast.idx");
4450            if explicit.is_file() {
4451                explicit
4452            } else {
4453                let fallback = index_dir.join("vector.idx");
4454                if fallback.is_file() {
4455                    fallback
4456                } else {
4457                    return Ok(None);
4458                }
4459            }
4460        };
4461        let quality_path = index_dir.join("vector.quality.idx");
4462        if !quality_path.is_file() {
4463            return Ok(None);
4464        }
4465
4466        let fast_index = FsVectorIndex::open(&fast_path)
4467            .map_err(|err| anyhow!("open fast-tier index failed: {err}"))?;
4468        let quality_index = FsVectorIndex::open(&quality_path)
4469            .map_err(|err| anyhow!("open quality-tier index failed: {err}"))?;
4470        let index = Arc::new(
4471            FsTwoTierIndex::open(&index_dir, frankensearch_two_tier_config())
4472                .map_err(|err| anyhow!("open progressive two-tier index failed: {err}"))?,
4473        );
4474
4475        let fast_embedder = self.load_embedder_for_progressive_id(
4476            &embedder,
4477            fast_index.embedder_id(),
4478            fast_index.dimension(),
4479        )?;
4480        let fast_embedder: Arc<dyn frankensearch::Embedder> = Arc::new(FsSyncEmbedderAdapter(
4481            SharedCassSyncEmbedder::new(fast_embedder),
4482        ));
4483        let quality_embedder = Some(self.load_embedder_for_progressive_id(
4484            &embedder,
4485            quality_index.embedder_id(),
4486            quality_index.dimension(),
4487        )?);
4488        let quality_embedder = quality_embedder.map(|embedder| {
4489            Arc::new(FsSyncEmbedderAdapter(SharedCassSyncEmbedder::new(embedder)))
4490                as Arc<dyn frankensearch::Embedder>
4491        });
4492
4493        Ok(Some(Arc::new(ProgressiveTwoTierContext {
4494            context_token,
4495            index,
4496            fast_embedder,
4497            quality_embedder,
4498        })))
4499    }
4500
4501    fn load_embedder_for_progressive_id(
4502        &self,
4503        current_embedder: &Arc<dyn Embedder>,
4504        embedder_id: &str,
4505        dimension: usize,
4506    ) -> Result<Arc<dyn Embedder>> {
4507        if current_embedder.id() == embedder_id {
4508            return Ok(Arc::clone(current_embedder));
4509        }
4510
4511        if let Some(dim) = embedder_id.strip_prefix("fnv1a-")
4512            && let Ok(parsed) = dim.parse::<usize>()
4513        {
4514            return Ok(Arc::new(crate::search::hash_embedder::HashEmbedder::new(
4515                parsed.max(dimension),
4516            )));
4517        }
4518
4519        if let Some(embedder_name) =
4520            crate::search::fastembed_embedder::FastEmbedder::canonical_name(embedder_id)
4521        {
4522            let data_dir = self
4523                .sqlite_path
4524                .as_ref()
4525                .and_then(|path| path.parent())
4526                .ok_or_else(|| anyhow!("cannot resolve data dir for progressive embedder load"))?;
4527            let embedder = crate::search::fastembed_embedder::FastEmbedder::load_by_name(
4528                data_dir,
4529                embedder_name,
4530            )
4531            .with_context(|| format!("loading FastEmbed model for {embedder_name}"))?;
4532            if embedder.dimension() != dimension {
4533                bail!(
4534                    "progressive embedder dimension mismatch: {} index expects {}, model has {}",
4535                    embedder_id,
4536                    dimension,
4537                    embedder.dimension()
4538                );
4539            }
4540            return Ok(Arc::new(embedder));
4541        }
4542
4543        bail!("unsupported progressive embedder id: {embedder_id}");
4544    }
4545
4546    fn resolve_semantic_doc_ids_for_hits(
4547        &self,
4548        hits: &[SearchHit],
4549    ) -> Result<Vec<Option<ResolvedSemanticDocId>>> {
4550        if hits.is_empty() {
4551            return Ok(Vec::new());
4552        }
4553
4554        let lookup_keys: Vec<Option<ProgressiveLookupKey>> = hits
4555            .iter()
4556            .map(|hit| {
4557                let idx = hit
4558                    .line_number
4559                    .and_then(|line| line.checked_sub(1))
4560                    .map(i64::try_from)
4561                    .transpose()
4562                    .ok()
4563                    .flatten()?;
4564                Some((
4565                    normalized_search_hit_source_id(hit),
4566                    hit.source_path.clone(),
4567                    hit.conversation_id,
4568                    hit.title.trim().to_string(),
4569                    idx,
4570                    hit.created_at,
4571                    hit.content_hash,
4572                ))
4573            })
4574            .collect();
4575
4576        let mut seen_exact = HashSet::new();
4577        let mut exact_query_keys = Vec::new();
4578        let mut seen_fallback = HashSet::new();
4579        let mut fallback_query_keys = Vec::new();
4580        for (source_id, source_path, conversation_id, _title, idx, _created_at, _content_hash) in
4581            lookup_keys.iter().flatten()
4582        {
4583            if let Some(conversation_id) = conversation_id {
4584                let query_key: ProgressiveExactQueryKey = (*conversation_id, *idx);
4585                if seen_exact.insert(query_key) {
4586                    exact_query_keys.push(query_key);
4587                }
4588            } else {
4589                let query_key: ProgressiveFallbackQueryKey =
4590                    (source_id.clone(), source_path.clone(), *idx);
4591                if seen_fallback.insert(query_key.clone()) {
4592                    fallback_query_keys.push(query_key);
4593                }
4594            }
4595        }
4596
4597        if exact_query_keys.is_empty() && fallback_query_keys.is_empty() {
4598            return Ok(vec![None; hits.len()]);
4599        }
4600
4601        let sqlite_guard = self.sqlite_guard()?;
4602        let conn = sqlite_guard
4603            .as_ref()
4604            .ok_or_else(|| anyhow!("progressive search requires database connection"))?;
4605
4606        let mut resolved_by_key = HashMap::new();
4607        let normalized_source_sql =
4608            normalized_search_source_id_sql_expr("c.source_id", "s.kind", "c.origin_host");
4609
4610        const CHUNK_SIZE: usize = 300;
4611        for chunk in exact_query_keys.chunks(CHUNK_SIZE) {
4612            let mut sql = String::from("SELECT c.id, ");
4613            sql.push_str(&normalized_source_sql);
4614            sql.push_str(
4615                ", c.source_path, m.idx, m.id, c.agent_id, c.workspace_id, m.role, m.created_at, m.content, c.title
4616                 FROM messages m
4617                 JOIN conversations c ON m.conversation_id = c.id
4618                 LEFT JOIN sources s ON c.source_id = s.id
4619                 WHERE ",
4620            );
4621            let mut params = Vec::with_capacity(chunk.len().saturating_mul(2));
4622            for (idx, (conversation_id, line_idx)) in chunk.iter().enumerate() {
4623                if idx > 0 {
4624                    sql.push_str(" OR ");
4625                }
4626                sql.push_str("(c.id = ? AND m.idx = ?)");
4627                params.push(ParamValue::from(*conversation_id));
4628                params.push(ParamValue::from(*line_idx));
4629            }
4630
4631            let chunk_rows: Vec<ResolvedSemanticLookupRow> =
4632                conn.query_map_collect(&sql, &params, |row: &frankensqlite::Row| {
4633                    let conversation_id: i64 = row.get_typed(0)?;
4634                    let source_id: String = row.get_typed(1)?;
4635                    let source_path: String = row.get_typed(2)?;
4636                    let idx: i64 = row.get_typed(3)?;
4637                    let message_id_raw: i64 = row.get_typed(4)?;
4638                    // agent_id is nullable for legacy V1 conversations; treat
4639                    // NULL the same as the negative-sentinel branch below (0).
4640                    let agent_id_raw: Option<i64> = row.get_typed(5)?;
4641                    let workspace_id_raw: Option<i64> = row.get_typed(6)?;
4642                    let role_raw: String = row.get_typed(7)?;
4643                    let created_at_ms: Option<i64> = row.get_typed(8)?;
4644                    let content: String = row.get_typed(9)?;
4645                    let title: Option<String> = row.get_typed(10)?;
4646
4647                    let canonical = canonicalize_for_embedding(&content);
4648                    if canonical.is_empty() {
4649                        return Ok(None);
4650                    }
4651
4652                    let message_id = u64::try_from(message_id_raw).map_err(|_| {
4653                        std::io::Error::other("message id out of range for progressive doc_id")
4654                    })?;
4655                    let agent_id = semantic_doc_component_id_from_db(agent_id_raw);
4656                    let workspace_id = semantic_doc_component_id_from_db(workspace_id_raw);
4657                    let role = role_code_from_str(&role_raw).unwrap_or(ROLE_USER);
4658                    let doc_id = SemanticDocId {
4659                        message_id,
4660                        chunk_idx: 0,
4661                        agent_id,
4662                        workspace_id,
4663                        source_id: crc32fast::hash(source_id.as_bytes()),
4664                        role,
4665                        created_at_ms: created_at_ms.unwrap_or(0),
4666                        content_hash: Some(content_hash(&canonical)),
4667                    }
4668                    .to_doc_id_string();
4669                    let line_number = usize::try_from(idx).ok().map(|line| line.saturating_add(1));
4670                    let lookup_key = (
4671                        source_id,
4672                        source_path.clone(),
4673                        Some(conversation_id),
4674                        title.unwrap_or_default().trim().to_string(),
4675                        idx,
4676                        created_at_ms,
4677                        stable_hit_hash(&content, &source_path, line_number, created_at_ms),
4678                    );
4679
4680                    Ok(Some((
4681                        lookup_key,
4682                        ResolvedSemanticDocId { message_id, doc_id },
4683                    )))
4684                })?;
4685
4686            for row in chunk_rows.into_iter().flatten() {
4687                resolved_by_key.insert(row.0, row.1);
4688            }
4689        }
4690
4691        for chunk in fallback_query_keys.chunks(CHUNK_SIZE) {
4692            let mut sql = String::from("SELECT ");
4693            sql.push_str(&normalized_source_sql);
4694            sql.push_str(
4695                ", c.source_path, m.idx, m.id, c.agent_id, c.workspace_id, m.role, m.created_at, m.content, c.title
4696                 FROM messages m
4697                 JOIN conversations c ON m.conversation_id = c.id
4698                 LEFT JOIN sources s ON c.source_id = s.id
4699                 WHERE ",
4700            );
4701            let mut params = Vec::with_capacity(chunk.len().saturating_mul(3));
4702            for (idx, (source_id, source_path, line_idx)) in chunk.iter().enumerate() {
4703                if idx > 0 {
4704                    sql.push_str(" OR ");
4705                }
4706                sql.push_str(&format!(
4707                    "({normalized_source_sql} = ? AND c.source_path = ? AND m.idx = ?)"
4708                ));
4709                params.push(ParamValue::from(normalize_search_source_filter_value(
4710                    source_id,
4711                )));
4712                params.push(ParamValue::from(source_path.clone()));
4713                params.push(ParamValue::from(*line_idx));
4714            }
4715
4716            let chunk_rows: Vec<ResolvedSemanticLookupRow> =
4717                conn.query_map_collect(&sql, &params, |row: &frankensqlite::Row| {
4718                    let source_id: String = row.get_typed(0)?;
4719                    let source_path: String = row.get_typed(1)?;
4720                    let idx: i64 = row.get_typed(2)?;
4721                    let message_id_raw: i64 = row.get_typed(3)?;
4722                    // agent_id is nullable for legacy V1 conversations; treat
4723                    // NULL the same as the negative-sentinel branch below (0).
4724                    let agent_id_raw: Option<i64> = row.get_typed(4)?;
4725                    let workspace_id_raw: Option<i64> = row.get_typed(5)?;
4726                    let role_raw: String = row.get_typed(6)?;
4727                    let created_at_ms: Option<i64> = row.get_typed(7)?;
4728                    let content: String = row.get_typed(8)?;
4729                    let title: Option<String> = row.get_typed(9)?;
4730
4731                    let canonical = canonicalize_for_embedding(&content);
4732                    if canonical.is_empty() {
4733                        return Ok(None);
4734                    }
4735
4736                    let message_id = u64::try_from(message_id_raw).map_err(|_| {
4737                        std::io::Error::other("message id out of range for progressive doc_id")
4738                    })?;
4739                    let agent_id = semantic_doc_component_id_from_db(agent_id_raw);
4740                    let workspace_id = semantic_doc_component_id_from_db(workspace_id_raw);
4741                    let role = role_code_from_str(&role_raw).unwrap_or(ROLE_USER);
4742                    let doc_id = SemanticDocId {
4743                        message_id,
4744                        chunk_idx: 0,
4745                        agent_id,
4746                        workspace_id,
4747                        source_id: crc32fast::hash(source_id.as_bytes()),
4748                        role,
4749                        created_at_ms: created_at_ms.unwrap_or(0),
4750                        content_hash: Some(content_hash(&canonical)),
4751                    }
4752                    .to_doc_id_string();
4753                    let line_number = usize::try_from(idx).ok().map(|line| line.saturating_add(1));
4754                    let lookup_key = (
4755                        source_id,
4756                        source_path.clone(),
4757                        None,
4758                        title.unwrap_or_default().trim().to_string(),
4759                        idx,
4760                        created_at_ms,
4761                        stable_hit_hash(&content, &source_path, line_number, created_at_ms),
4762                    );
4763
4764                    Ok(Some((
4765                        lookup_key,
4766                        ResolvedSemanticDocId { message_id, doc_id },
4767                    )))
4768                })?;
4769
4770            for row in chunk_rows.into_iter().flatten() {
4771                resolved_by_key.insert(row.0, row.1);
4772            }
4773        }
4774
4775        Ok(lookup_keys
4776            .into_iter()
4777            .map(|key| key.and_then(|lookup| resolved_by_key.get(&lookup).cloned()))
4778            .collect())
4779    }
4780
4781    fn load_message_text_by_id(&self, message_id: u64) -> Result<Option<String>> {
4782        let sqlite_guard = self.sqlite_guard()?;
4783        let conn = sqlite_guard
4784            .as_ref()
4785            .ok_or_else(|| anyhow!("progressive search requires database connection"))?;
4786        let rows: Vec<String> = conn.query_map_collect(
4787            "SELECT content FROM messages WHERE id = ?",
4788            &[ParamValue::from(i64::try_from(message_id)?)],
4789            |row: &frankensqlite::Row| row.get_typed(0),
4790        )?;
4791        Ok(rows.into_iter().next())
4792    }
4793
4794    fn collapse_progressive_scored_results(
4795        &self,
4796        results: &[FsScoredResult],
4797        fetch_limit: usize,
4798    ) -> Vec<VectorSearchResult> {
4799        let fetch = fetch_limit.max(1);
4800        let mut best_by_message: HashMap<u64, VectorSearchResult> =
4801            HashMap::with_capacity(results.len());
4802        for hit in results {
4803            let Some(parsed) = parse_semantic_doc_id(&hit.doc_id) else {
4804                continue;
4805            };
4806            best_by_message
4807                .entry(parsed.message_id)
4808                .and_modify(|entry| {
4809                    if hit.score > entry.score {
4810                        entry.score = hit.score;
4811                        entry.chunk_idx = parsed.chunk_idx;
4812                    }
4813                })
4814                .or_insert(VectorSearchResult {
4815                    message_id: parsed.message_id,
4816                    chunk_idx: parsed.chunk_idx,
4817                    score: hit.score,
4818                });
4819        }
4820        let mut collapsed: Vec<VectorSearchResult> = best_by_message.into_values().collect();
4821        collapsed.sort_by(|a, b| {
4822            b.score
4823                .total_cmp(&a.score)
4824                .then_with(|| a.message_id.cmp(&b.message_id))
4825        });
4826        if collapsed.len() > fetch {
4827            collapsed.truncate(fetch);
4828        }
4829        collapsed
4830    }
4831
4832    fn hydrate_semantic_hits_with_ids(
4833        &self,
4834        results: &[VectorSearchResult],
4835        field_mask: FieldMask,
4836    ) -> Result<Vec<(u64, SearchHit)>> {
4837        if results.is_empty() {
4838            return Ok(Vec::new());
4839        }
4840        let sqlite_guard = self.sqlite_guard()?;
4841        let conn = sqlite_guard
4842            .as_ref()
4843            .ok_or_else(|| anyhow!("semantic search requires database connection"))?;
4844
4845        #[derive(Debug)]
4846        struct MessageHydrationRow {
4847            message_id: u64,
4848            conversation_id: i64,
4849            full_content: String,
4850            msg_created_at: Option<i64>,
4851            idx: Option<i64>,
4852        }
4853
4854        #[derive(Debug)]
4855        struct ConversationHydrationRow {
4856            title: Option<String>,
4857            source_path: String,
4858            source_id: String,
4859            origin_host: Option<String>,
4860            agent: String,
4861            workspace: Option<String>,
4862            origin_kind: Option<String>,
4863            started_at: Option<i64>,
4864        }
4865
4866        let mut unique_message_ids = Vec::with_capacity(results.len());
4867        let mut seen_message_ids = HashSet::with_capacity(results.len());
4868        for result in results {
4869            if seen_message_ids.insert(result.message_id) {
4870                unique_message_ids.push(result.message_id);
4871            }
4872        }
4873
4874        let message_placeholder_capacity =
4875            unique_message_ids.len().saturating_mul(2).saturating_sub(1);
4876        let mut message_placeholders = String::with_capacity(message_placeholder_capacity);
4877        let mut message_params: Vec<ParamValue> = Vec::with_capacity(unique_message_ids.len());
4878        for (idx, message_id) in unique_message_ids.iter().enumerate() {
4879            if idx > 0 {
4880                message_placeholders.push(',');
4881            }
4882            message_placeholders.push('?');
4883            message_params.push(ParamValue::from(i64::try_from(*message_id)?));
4884        }
4885
4886        let message_sql = format!(
4887            "SELECT id, conversation_id, content, created_at, idx
4888             FROM messages
4889             WHERE id IN ({message_placeholders})"
4890        );
4891
4892        let message_rows: Vec<MessageHydrationRow> =
4893            conn.query_map_collect(&message_sql, &message_params, |row: &frankensqlite::Row| {
4894                let message_id: i64 = row.get_typed(0)?;
4895                Ok(MessageHydrationRow {
4896                    message_id: semantic_message_id_from_db(message_id)?,
4897                    conversation_id: row.get_typed(1)?,
4898                    full_content: row.get_typed(2)?,
4899                    msg_created_at: row.get_typed(3)?,
4900                    idx: row.get_typed(4)?,
4901                })
4902            })?;
4903        if message_rows.is_empty() {
4904            return Ok(Vec::new());
4905        }
4906
4907        let title_expr = if field_mask.wants_title() {
4908            "c.title"
4909        } else {
4910            "''"
4911        };
4912        let normalized_source_sql =
4913            normalized_search_source_id_sql_expr("c.source_id", "s.kind", "c.origin_host");
4914        let mut conversation_ids = Vec::with_capacity(message_rows.len());
4915        let mut seen_conversation_ids = HashSet::with_capacity(message_rows.len());
4916        for row in &message_rows {
4917            if seen_conversation_ids.insert(row.conversation_id) {
4918                conversation_ids.push(row.conversation_id);
4919            }
4920        }
4921        let conversation_placeholder_capacity =
4922            conversation_ids.len().saturating_mul(2).saturating_sub(1);
4923        let mut conversation_placeholders =
4924            String::with_capacity(conversation_placeholder_capacity);
4925        let mut conversation_params: Vec<ParamValue> = Vec::with_capacity(conversation_ids.len());
4926        for (idx, conversation_id) in conversation_ids.iter().enumerate() {
4927            if idx > 0 {
4928                conversation_placeholders.push(',');
4929            }
4930            conversation_placeholders.push('?');
4931            conversation_params.push(ParamValue::from(*conversation_id));
4932        }
4933        // LEFT JOIN + COALESCE on agents so search hits for conversations
4934        // with NULL agent_id (legacy V1 schema) still surface instead of
4935        // being silently dropped from results.  Consistent with the fts/
4936        // lexical rebuild paths (8a0c547c, e1c08e7c).
4937        let sql = format!(
4938            "SELECT c.id, {title_expr}, c.source_path, {normalized_source_sql}, c.origin_host, COALESCE(a.slug, 'unknown'), w.path, s.kind, c.started_at
4939             FROM conversations c
4940             LEFT JOIN agents a ON c.agent_id = a.id
4941             LEFT JOIN workspaces w ON c.workspace_id = w.id
4942             LEFT JOIN sources s ON c.source_id = s.id
4943             WHERE c.id IN ({conversation_placeholders})"
4944        );
4945
4946        let conversation_rows: Vec<(i64, ConversationHydrationRow)> =
4947            conn.query_map_collect(&sql, &conversation_params, |row: &frankensqlite::Row| {
4948                let conversation_id: i64 = row.get_typed(0)?;
4949                let title: Option<String> = if field_mask.wants_title() {
4950                    row.get_typed(1)?
4951                } else {
4952                    None
4953                };
4954                Ok((
4955                    conversation_id,
4956                    ConversationHydrationRow {
4957                        title,
4958                        source_path: row.get_typed(2)?,
4959                        source_id: row.get_typed(3)?,
4960                        origin_host: row.get_typed(4)?,
4961                        agent: row.get_typed(5)?,
4962                        workspace: row.get_typed(6)?,
4963                        origin_kind: row.get_typed(7)?,
4964                        started_at: row.get_typed(8)?,
4965                    },
4966                ))
4967            })?;
4968
4969        let conversations_by_id: HashMap<i64, ConversationHydrationRow> =
4970            conversation_rows.into_iter().collect();
4971
4972        let rows: Vec<(u64, SearchHit)> = message_rows
4973            .into_iter()
4974            .filter_map(|message| {
4975                let conversation = conversations_by_id.get(&message.conversation_id)?;
4976
4977                let created_at = message.msg_created_at.or(conversation.started_at);
4978                let line_number = message
4979                    .idx
4980                    .and_then(|i| usize::try_from(i).ok())
4981                    .map(|i| i.saturating_add(1));
4982                let snippet = if field_mask.wants_snippet() {
4983                    snippet_from_content(&message.full_content)
4984                } else {
4985                    String::new()
4986                };
4987                let content = if field_mask.needs_content() {
4988                    message.full_content.clone()
4989                } else {
4990                    String::new()
4991                };
4992                let content_hash = stable_hit_hash(
4993                    &message.full_content,
4994                    &conversation.source_path,
4995                    line_number,
4996                    created_at,
4997                );
4998                let source_id = normalized_search_hit_source_id_parts(
4999                    conversation.source_id.as_str(),
5000                    conversation.origin_kind.as_deref().unwrap_or_default(),
5001                    conversation.origin_host.as_deref(),
5002                );
5003                let origin_kind = normalized_search_hit_origin_kind(
5004                    &source_id,
5005                    conversation.origin_kind.as_deref(),
5006                );
5007
5008                let hit = SearchHit {
5009                    title: if field_mask.wants_title() {
5010                        conversation.title.clone().unwrap_or_default()
5011                    } else {
5012                        String::new()
5013                    },
5014                    snippet,
5015                    content,
5016                    content_hash,
5017                    conversation_id: Some(message.conversation_id),
5018                    score: 0.0,
5019                    source_path: conversation.source_path.clone(),
5020                    agent: conversation.agent.clone(),
5021                    workspace: conversation.workspace.clone().unwrap_or_default(),
5022                    workspace_original: None,
5023                    created_at,
5024                    line_number,
5025                    match_type: MatchType::Exact,
5026                    source_id,
5027                    origin_kind,
5028                    origin_host: conversation.origin_host.clone(),
5029                };
5030
5031                Some((message.message_id, hit))
5032            })
5033            .collect();
5034
5035        let mut hits_by_id = HashMap::new();
5036        for (id, hit) in rows {
5037            hits_by_id.insert(id, hit);
5038        }
5039
5040        let mut ordered = Vec::new();
5041        for result in results {
5042            if let Some(mut hit) = hits_by_id.remove(&result.message_id) {
5043                hit.score = result.score;
5044                ordered.push((result.message_id, hit));
5045            }
5046        }
5047
5048        Ok(ordered)
5049    }
5050
5051    fn overlay_progressive_lexical_hit(
5052        &self,
5053        hit: &mut SearchHit,
5054        lexical: &ProgressiveLexicalHit,
5055        field_mask: FieldMask,
5056    ) {
5057        if field_mask.wants_title() && !lexical.title.is_empty() {
5058            hit.title = lexical.title.clone();
5059        }
5060        if field_mask.wants_snippet() && !lexical.snippet.is_empty() {
5061            hit.snippet = lexical.snippet.clone();
5062        }
5063        if field_mask.needs_content() && !lexical.content.is_empty() {
5064            hit.content = lexical.content.clone();
5065        }
5066        hit.match_type = lexical.match_type;
5067        hit.line_number = lexical.line_number.or(hit.line_number);
5068    }
5069
5070    fn progressive_phase_to_result(
5071        &self,
5072        results: &[FsScoredResult],
5073        ctx: ProgressivePhaseContext<'_>,
5074    ) -> Result<SearchResult> {
5075        let collapsed = self.collapse_progressive_scored_results(results, ctx.fetch_limit);
5076        let missing: Vec<VectorSearchResult> = collapsed
5077            .iter()
5078            .filter(|result| {
5079                ctx.lexical_cache
5080                    .and_then(|cache| cache.hits_by_message.get(&result.message_id))
5081                    .is_none()
5082            })
5083            .map(|result| VectorSearchResult {
5084                message_id: result.message_id,
5085                chunk_idx: result.chunk_idx,
5086                score: result.score,
5087            })
5088            .collect();
5089        let mut hydrated_by_id: HashMap<u64, SearchHit> = self
5090            .hydrate_semantic_hits_with_ids(&missing, ctx.field_mask)?
5091            .into_iter()
5092            .collect();
5093
5094        let mut hydrated: Vec<(u64, SearchHit)> = Vec::with_capacity(collapsed.len());
5095        for result in &collapsed {
5096            if let Some(cache) = ctx.lexical_cache
5097                && let Some(lexical) = cache.hits_by_message.get(&result.message_id)
5098            {
5099                hydrated.push((result.message_id, lexical.to_search_hit(result.score)));
5100                continue;
5101            }
5102            if let Some(mut hit) = hydrated_by_id.remove(&result.message_id) {
5103                if let Some(cache) = ctx.lexical_cache
5104                    && let Some(lexical) = cache.hits_by_message.get(&result.message_id)
5105                {
5106                    self.overlay_progressive_lexical_hit(&mut hit, lexical, ctx.field_mask);
5107                }
5108                hydrated.push((result.message_id, hit));
5109            }
5110        }
5111
5112        let mut hits: Vec<SearchHit> = hydrated.into_iter().map(|(_, hit)| hit).collect();
5113        (_, hits) = self.postprocess_hits_page(hits, ctx.query, ctx.filters, ctx.limit, 0);
5114
5115        let (wildcard_fallback, suggestions) = ctx
5116            .lexical_cache
5117            .map(|cache| {
5118                let suggestions = if hits.is_empty() {
5119                    cache.suggestions.clone()
5120                } else {
5121                    Vec::new()
5122                };
5123                (cache.wildcard_fallback, suggestions)
5124            })
5125            .unwrap_or((false, Vec::new()));
5126
5127        Ok(SearchResult {
5128            hits,
5129            wildcard_fallback,
5130            cache_stats: self.cache_stats(),
5131            suggestions,
5132            ann_stats: None,
5133            total_count: None,
5134        })
5135    }
5136
5137    pub(crate) async fn search_progressive_with_callback(
5138        self: &Arc<Self>,
5139        request: ProgressiveSearchRequest<'_>,
5140        mut on_event: impl FnMut(ProgressiveSearchEvent) + Send,
5141    ) -> Result<()> {
5142        let ProgressiveSearchRequest {
5143            cx,
5144            query,
5145            filters,
5146            limit,
5147            sparse_threshold,
5148            field_mask,
5149            mode,
5150        } = request;
5151        let field_mask = effective_field_mask(field_mask);
5152        let limit = limit.max(1);
5153        let fetch_limit = progressive_phase_fetch_limit(limit);
5154
5155        match mode {
5156            SearchMode::Lexical => {
5157                let started = Instant::now();
5158                let result = self.search_with_fallback(
5159                    query,
5160                    filters,
5161                    limit,
5162                    0,
5163                    sparse_threshold,
5164                    field_mask,
5165                )?;
5166                on_event(ProgressiveSearchEvent::Phase {
5167                    kind: ProgressivePhaseKind::Initial,
5168                    elapsed_ms: started.elapsed().as_millis(),
5169                    result,
5170                });
5171                return Ok(());
5172            }
5173            SearchMode::Semantic | SearchMode::Hybrid => {}
5174        }
5175
5176        let progressive_context = {
5177            self.progressive_context()?
5178                .ok_or_else(|| anyhow!("progressive two-tier context unavailable"))?
5179        };
5180        let progressive_context_token = Arc::clone(&progressive_context.context_token);
5181
5182        let lexical_cache: Arc<Mutex<ProgressiveLexicalSnapshot>> =
5183            Arc::new(Mutex::new(Arc::new(ProgressiveLexicalCache::default())));
5184        let text_cache: Arc<Mutex<HashMap<u64, String>>> = Arc::new(Mutex::new(HashMap::new()));
5185        let text_client = Arc::clone(self);
5186        let text_cache_for_lookup = Arc::clone(&text_cache);
5187        let text_fn = move |doc_id: &str| -> Option<String> {
5188            let parsed = parse_semantic_doc_id(doc_id)?;
5189            if let Ok(cache) = text_cache_for_lookup.lock()
5190                && let Some(text) = cache.get(&parsed.message_id)
5191            {
5192                return Some(text.clone());
5193            }
5194            let loaded = text_client
5195                .load_message_text_by_id(parsed.message_id)
5196                .ok()
5197                .flatten()?;
5198            if let Ok(mut cache) = text_cache_for_lookup.lock() {
5199                cache.insert(parsed.message_id, loaded.clone());
5200            }
5201            Some(loaded)
5202        };
5203
5204        let mut searcher = FsTwoTierSearcher::new(
5205            Arc::clone(&progressive_context.index),
5206            Arc::clone(&progressive_context.fast_embedder),
5207            frankensearch_two_tier_config(),
5208        );
5209
5210        if let Some(quality_embedder) = progressive_context.quality_embedder.as_ref() {
5211            searcher = searcher.with_quality_embedder(Arc::clone(quality_embedder));
5212        }
5213
5214        if matches!(mode, SearchMode::Hybrid) {
5215            let lexical = Arc::new(CassProgressiveLexicalAdapter::new(
5216                Arc::clone(self),
5217                filters.clone(),
5218                field_mask,
5219                sparse_threshold,
5220                Arc::clone(&lexical_cache),
5221            ));
5222            searcher = searcher.with_lexical(lexical);
5223        }
5224
5225        let phase_client = Arc::clone(self);
5226        let phase_filters = filters.clone();
5227        let phase_cache = Arc::clone(&lexical_cache);
5228        let mut phase_error: Option<anyhow::Error> = None;
5229
5230        let search_result = searcher
5231            .search(cx, query, fetch_limit, text_fn, |phase| {
5232                if phase_error.is_some() {
5233                    return;
5234                }
5235                match phase_client.semantic_context_matches(&progressive_context_token) {
5236                    Ok(true) => {}
5237                    Ok(false) => {
5238                        phase_error = Some(anyhow!(
5239                            "progressive search aborted: semantic context changed"
5240                        ));
5241                        cx.set_cancel_requested(true);
5242                        return;
5243                    }
5244                    Err(err) => {
5245                        phase_error = Some(err);
5246                        cx.set_cancel_requested(true);
5247                        return;
5248                    }
5249                }
5250                let lexical_snapshot = phase_cache.lock().ok().map(|guard| Arc::clone(&guard));
5251                let event_result = match phase {
5252                    FsSearchPhase::Initial {
5253                        results, latency, ..
5254                    } => phase_client
5255                        .progressive_phase_to_result(
5256                            &results,
5257                            ProgressivePhaseContext {
5258                                query,
5259                                filters: &phase_filters,
5260                                field_mask,
5261                                lexical_cache: lexical_snapshot.as_deref(),
5262                                limit,
5263                                fetch_limit,
5264                            },
5265                        )
5266                        .map(|result| ProgressiveSearchEvent::Phase {
5267                            kind: ProgressivePhaseKind::Initial,
5268                            elapsed_ms: latency.as_millis(),
5269                            result,
5270                        }),
5271                    FsSearchPhase::Refined {
5272                        results, latency, ..
5273                    } => phase_client
5274                        .progressive_phase_to_result(
5275                            &results,
5276                            ProgressivePhaseContext {
5277                                query,
5278                                filters: &phase_filters,
5279                                field_mask,
5280                                lexical_cache: lexical_snapshot.as_deref(),
5281                                limit,
5282                                fetch_limit,
5283                            },
5284                        )
5285                        .map(|result| ProgressiveSearchEvent::Phase {
5286                            kind: ProgressivePhaseKind::Refined,
5287                            elapsed_ms: latency.as_millis(),
5288                            result,
5289                        }),
5290                    // frankensearch may emit a final reranked phase after the
5291                    // quality-refined pass. cass's progressive consumers only
5292                    // distinguish fast initial results from a better upgraded
5293                    // replacement set, so reranked results flow through the
5294                    // existing refined/upgrade path.
5295                    FsSearchPhase::Reranked {
5296                        results, latency, ..
5297                    } => phase_client
5298                        .progressive_phase_to_result(
5299                            &results,
5300                            ProgressivePhaseContext {
5301                                query,
5302                                filters: &phase_filters,
5303                                field_mask,
5304                                lexical_cache: lexical_snapshot.as_deref(),
5305                                limit,
5306                                fetch_limit,
5307                            },
5308                        )
5309                        .map(|result| ProgressiveSearchEvent::Phase {
5310                            kind: ProgressivePhaseKind::Refined,
5311                            elapsed_ms: latency.as_millis(),
5312                            result,
5313                        }),
5314                    FsSearchPhase::RefinementFailed { error, latency, .. } => {
5315                        Ok(ProgressiveSearchEvent::RefinementFailed {
5316                            latency_ms: latency.as_millis(),
5317                            error: error.to_string(),
5318                        })
5319                    }
5320                };
5321
5322                match event_result {
5323                    Ok(event) => on_event(event),
5324                    Err(err) => {
5325                        phase_error = Some(err);
5326                        cx.set_cancel_requested(true);
5327                    }
5328                }
5329            })
5330            .await;
5331
5332        if let Some(err) = phase_error {
5333            return Err(err);
5334        }
5335
5336        search_result
5337            .map(|_| ())
5338            .map_err(|err| anyhow!("progressive search failed: {err}"))
5339    }
5340
5341    /// Semantic search result containing hits and optional ANN statistics.
5342    pub fn search_semantic(
5343        &self,
5344        query: &str,
5345        filters: SearchFilters,
5346        limit: usize,
5347        offset: usize,
5348        field_mask: FieldMask,
5349        approximate: bool,
5350    ) -> Result<(
5351        Vec<SearchHit>,
5352        Option<crate::search::ann_index::AnnSearchStats>,
5353    )> {
5354        self.search_semantic_with_tier(
5355            query,
5356            filters,
5357            limit,
5358            offset,
5359            field_mask,
5360            approximate,
5361            SemanticTierMode::Single,
5362        )
5363    }
5364
5365    /// Semantic search with optional progressive two-tier execution strategy.
5366    #[allow(clippy::too_many_arguments)]
5367    pub fn search_semantic_with_tier(
5368        &self,
5369        query: &str,
5370        filters: SearchFilters,
5371        limit: usize,
5372        offset: usize,
5373        field_mask: FieldMask,
5374        approximate: bool,
5375        tier_mode: SemanticTierMode,
5376    ) -> Result<(
5377        Vec<SearchHit>,
5378        Option<crate::search::ann_index::AnnSearchStats>,
5379    )> {
5380        let field_mask = effective_field_mask(field_mask);
5381        let canonical = canonicalize_for_embedding(query);
5382        if canonical.trim().is_empty() {
5383            return Ok((Vec::new(), None));
5384        }
5385        let limit = if limit == 0 {
5386            self.total_docs().min(no_limit_result_cap()).max(1)
5387        } else {
5388            limit
5389        };
5390        let target_hits = limit.saturating_add(offset);
5391        if target_hits == 0 {
5392            return Ok((Vec::new(), None));
5393        }
5394        let initial_fetch_limit = target_hits;
5395        let fallback_fetch_limit = target_hits.saturating_mul(3);
5396        loop {
5397            let (embedding, candidate_context, in_memory_two_tier_index, ann_index, context_token) = loop {
5398                let embedding = self.semantic_query_embedding(&canonical)?;
5399                let (candidate_context, context_token) = {
5400                    let guard = self
5401                        .semantic
5402                        .lock()
5403                        .map_err(|_| anyhow!("semantic lock poisoned"))?;
5404                    let state = guard.as_ref().ok_or_else(|| {
5405                        anyhow!("semantic search unavailable (no embedder or vector index)")
5406                    })?;
5407                    (
5408                        SemanticCandidateContext {
5409                            fs_semantic_index: Arc::clone(&state.fs_semantic_index),
5410                            fs_semantic_indexes: Arc::clone(&state.fs_semantic_indexes),
5411                            filter_maps: state.filter_maps.clone(),
5412                            roles: state.roles.clone(),
5413                        },
5414                        Arc::clone(&state.context_token),
5415                    )
5416                };
5417                if !Arc::ptr_eq(&embedding.context_token, &context_token) {
5418                    continue;
5419                }
5420                let in_memory_two_tier_index = if tier_mode.wants_two_tier() && !approximate {
5421                    self.in_memory_two_tier_index(tier_mode)?
5422                } else {
5423                    None
5424                };
5425                let ann_index = if approximate {
5426                    Some(self.ann_index()?)
5427                } else {
5428                    None
5429                };
5430
5431                let guard = self
5432                    .semantic
5433                    .lock()
5434                    .map_err(|_| anyhow!("semantic lock poisoned"))?;
5435                let state = guard.as_ref().ok_or_else(|| {
5436                    anyhow!("semantic search unavailable (no embedder or vector index)")
5437                })?;
5438                if !Arc::ptr_eq(&state.context_token, &context_token) {
5439                    continue;
5440                }
5441                break (
5442                    embedding.vector,
5443                    candidate_context,
5444                    in_memory_two_tier_index,
5445                    ann_index,
5446                    context_token,
5447                );
5448            };
5449
5450            let finalize_hits =
5451                |results: &[VectorSearchResult]| -> Result<(usize, Vec<SearchHit>)> {
5452                    let hits = self.hydrate_semantic_hits(results, field_mask)?;
5453                    Ok(self.postprocess_hits_page(hits, query, &filters, limit, offset))
5454                };
5455
5456            let (results, retry_state, mut ann_stats) = self.search_semantic_candidates(
5457                &candidate_context,
5458                &embedding,
5459                &filters,
5460                SemanticCandidateSearchRequest {
5461                    fetch_limit: initial_fetch_limit,
5462                    approximate,
5463                    tier_mode,
5464                    in_memory_two_tier_index: in_memory_two_tier_index.as_ref(),
5465                    ann_index: ann_index.as_ref(),
5466                },
5467            )?;
5468            if !self.semantic_context_matches(&context_token)? {
5469                tracing::debug!("semantic context changed during candidate search; retrying");
5470                continue;
5471            }
5472            let (mut available_hits, mut paged_hits) = finalize_hits(&results)?;
5473
5474            let needs_retry = initial_fetch_limit < fallback_fetch_limit
5475                && ((available_hits < target_hits && retry_state.has_more_candidates)
5476                    || retry_state.exact_window_may_omit_competitor);
5477
5478            if needs_retry {
5479                tracing::debug!(
5480                    query = canonical,
5481                    target_hits,
5482                    available_hits,
5483                    initial_fetch_limit,
5484                    fallback_fetch_limit,
5485                    "retrying semantic fetch due to candidate-window shortfall"
5486                );
5487                let (retry_results, _, retry_ann_stats) = self.search_semantic_candidates(
5488                    &candidate_context,
5489                    &embedding,
5490                    &filters,
5491                    SemanticCandidateSearchRequest {
5492                        fetch_limit: fallback_fetch_limit,
5493                        approximate,
5494                        tier_mode,
5495                        in_memory_two_tier_index: in_memory_two_tier_index.as_ref(),
5496                        ann_index: ann_index.as_ref(),
5497                    },
5498                )?;
5499                if !self.semantic_context_matches(&context_token)? {
5500                    tracing::debug!("semantic context changed during retry fetch; retrying");
5501                    continue;
5502                }
5503                (available_hits, paged_hits) = finalize_hits(&retry_results)?;
5504                ann_stats = retry_ann_stats;
5505            }
5506
5507            tracing::trace!(
5508                query = canonical,
5509                target_hits,
5510                available_hits,
5511                returned = paged_hits.len(),
5512                "semantic fetch complete"
5513            );
5514
5515            return Ok((paged_hits, ann_stats));
5516        }
5517    }
5518
5519    fn hydrate_semantic_hits(
5520        &self,
5521        results: &[VectorSearchResult],
5522        field_mask: FieldMask,
5523    ) -> Result<Vec<SearchHit>> {
5524        self.hydrate_semantic_hits_with_ids(results, field_mask)
5525            .map(|rows| rows.into_iter().map(|(_, hit)| hit).collect())
5526    }
5527
5528    fn postprocess_hits_page(
5529        &self,
5530        hits: Vec<SearchHit>,
5531        query: &str,
5532        filters: &SearchFilters,
5533        limit: usize,
5534        offset: usize,
5535    ) -> (usize, Vec<SearchHit>) {
5536        let mut hits = deduplicate_hits_with_query(hits, query);
5537        if !filters.session_paths.is_empty() {
5538            hits.retain(|hit| filters.session_paths.contains(&hit.source_path));
5539        }
5540        let available_hits = hits.len();
5541        let paged_hits = hits.into_iter().skip(offset).take(limit).collect();
5542        (available_hits, paged_hits)
5543    }
5544
5545    /// Search with automatic wildcard fallback for sparse results.
5546    /// If the initial search returns fewer than `sparse_threshold` results and the query
5547    /// doesn't already contain wildcards, automatically retry with substring wildcards (*term*).
5548    pub fn search_with_fallback(
5549        &self,
5550        query: &str,
5551        filters: SearchFilters,
5552        limit: usize,
5553        offset: usize,
5554        sparse_threshold: usize,
5555        field_mask: FieldMask,
5556    ) -> Result<SearchResult> {
5557        // First, try the normal search
5558        let hits = self.search(query, filters.clone(), limit, offset, field_mask)?;
5559        let baseline_stats = self.cache_stats();
5560        // Capture the true total from Tantivy's Count collector (set during search_tantivy).
5561        let tantivy_total = self
5562            .last_tantivy_total_count
5563            .lock()
5564            .ok()
5565            .and_then(|guard| *guard);
5566
5567        // Check if we should try wildcard fallback
5568        let query_has_wildcards = query.contains('*');
5569        let has_boolean_or_phrase = fs_cass_has_boolean_operators(query);
5570        let is_sparse = should_try_wildcard_fallback(hits.len(), limit, offset, sparse_threshold);
5571
5572        if !is_sparse || query_has_wildcards || has_boolean_or_phrase || query.trim().is_empty() {
5573            // Either we have enough results, query already has wildcards,
5574            // query uses boolean/phrases, or query is empty.
5575            // Generate suggestions only if truly zero hits
5576            let suggestions = if hits.is_empty() && !query.trim().is_empty() {
5577                self.generate_suggestions(query, &filters)
5578            } else {
5579                Vec::new()
5580            };
5581            return Ok(SearchResult {
5582                hits,
5583                wildcard_fallback: false,
5584                cache_stats: baseline_stats,
5585                suggestions,
5586                ann_stats: None,
5587                total_count: tantivy_total,
5588            });
5589        }
5590
5591        if should_skip_automatic_wildcard_fallback_for_long_zero_hit_query(query, hits.len()) {
5592            let suggestions = if hits.is_empty() {
5593                self.generate_suggestions(query, &filters)
5594            } else {
5595                Vec::new()
5596            };
5597            return Ok(SearchResult {
5598                hits,
5599                wildcard_fallback: false,
5600                cache_stats: baseline_stats,
5601                suggestions,
5602                ann_stats: None,
5603                total_count: tantivy_total,
5604            });
5605        }
5606
5607        // Try wildcard fallback: wrap each term in *term*
5608        let wildcard_query = query
5609            .split_whitespace()
5610            .map(|term| format!("*{}*", term.trim_matches('*')))
5611            .collect::<Vec<_>>()
5612            .join(" ");
5613
5614        tracing::info!(
5615            original_query = query,
5616            wildcard_query = wildcard_query,
5617            original_count = hits.len(),
5618            "wildcard_fallback"
5619        );
5620
5621        let mut fallback_hits =
5622            self.search(&wildcard_query, filters.clone(), limit, offset, field_mask)?;
5623        let fallback_stats = self.cache_stats();
5624        // Re-capture total_count after wildcard search (may have changed)
5625        let fallback_tantivy_total = self
5626            .last_tantivy_total_count
5627            .lock()
5628            .ok()
5629            .and_then(|guard| *guard);
5630
5631        // Use fallback results if they're better
5632        if fallback_hits.len() > hits.len() {
5633            // Mark all hits as ImplicitWildcard since we auto-added wildcards
5634            for hit in &mut fallback_hits {
5635                hit.match_type = MatchType::ImplicitWildcard;
5636            }
5637            // Generate suggestions if still zero hits after fallback
5638            let suggestions = if fallback_hits.is_empty() {
5639                self.generate_suggestions(query, &filters)
5640            } else {
5641                Vec::new()
5642            };
5643            Ok(SearchResult {
5644                hits: fallback_hits,
5645                wildcard_fallback: true,
5646                cache_stats: fallback_stats,
5647                suggestions,
5648                ann_stats: None,
5649                total_count: fallback_tantivy_total,
5650            })
5651        } else {
5652            // Keep original results even if sparse
5653            // Generate suggestions if zero hits
5654            let suggestions = if hits.is_empty() {
5655                self.generate_suggestions(query, &filters)
5656            } else {
5657                Vec::new()
5658            };
5659            Ok(SearchResult {
5660                hits,
5661                wildcard_fallback: false,
5662                cache_stats: baseline_stats,
5663                suggestions,
5664                ann_stats: None,
5665                total_count: tantivy_total,
5666            })
5667        }
5668    }
5669
5670    /// Hybrid search that fuses lexical + semantic results with RRF.
5671    #[allow(clippy::too_many_arguments)]
5672    pub fn search_hybrid(
5673        &self,
5674        lexical_query: &str,
5675        semantic_query: &str,
5676        filters: SearchFilters,
5677        limit: usize,
5678        offset: usize,
5679        sparse_threshold: usize,
5680        field_mask: FieldMask,
5681        approximate: bool,
5682    ) -> Result<SearchResult> {
5683        self.search_hybrid_with_tier(
5684            lexical_query,
5685            semantic_query,
5686            filters,
5687            limit,
5688            offset,
5689            sparse_threshold,
5690            field_mask,
5691            approximate,
5692            SemanticTierMode::Single,
5693        )
5694    }
5695
5696    /// Hybrid search that fuses lexical + semantic results with optional
5697    /// progressive two-tier semantic execution.
5698    #[allow(clippy::too_many_arguments)]
5699    pub fn search_hybrid_with_tier(
5700        &self,
5701        lexical_query: &str,
5702        semantic_query: &str,
5703        filters: SearchFilters,
5704        limit: usize,
5705        offset: usize,
5706        sparse_threshold: usize,
5707        field_mask: FieldMask,
5708        approximate: bool,
5709        semantic_tier_mode: SemanticTierMode,
5710    ) -> Result<SearchResult> {
5711        let requested_limit = limit;
5712        let total_docs = self.total_docs().max(1);
5713        let limit = if requested_limit == 0 {
5714            total_docs.min(no_limit_result_cap()).max(1)
5715        } else {
5716            requested_limit
5717        };
5718        let fetch = limit.saturating_add(offset);
5719        if fetch == 0 {
5720            return Ok(SearchResult {
5721                hits: Vec::new(),
5722                wildcard_fallback: false,
5723                cache_stats: self.cache_stats(),
5724                suggestions: Vec::new(),
5725                ann_stats: None,
5726                total_count: None,
5727            });
5728        }
5729
5730        if semantic_query.trim().is_empty() {
5731            return self.search_with_fallback(
5732                lexical_query,
5733                filters,
5734                limit,
5735                offset,
5736                sparse_threshold,
5737                field_mask,
5738            );
5739        }
5740
5741        let budget =
5742            hybrid_candidate_budget(semantic_query, requested_limit, limit, offset, total_docs);
5743        let lexical = self.search_with_fallback(
5744            lexical_query,
5745            filters.clone(),
5746            budget.lexical_candidates,
5747            0,
5748            sparse_threshold,
5749            field_mask,
5750        )?;
5751        let (semantic_hits, semantic_ann_stats) = self.search_semantic_with_tier(
5752            semantic_query,
5753            filters,
5754            budget.semantic_candidates,
5755            0,
5756            field_mask,
5757            approximate,
5758            semantic_tier_mode,
5759        )?;
5760        let fused = rrf_fuse_hits(&lexical.hits, &semantic_hits, semantic_query, limit, offset);
5761        let suggestions = if fused.is_empty() {
5762            lexical.suggestions.clone()
5763        } else {
5764            Vec::new()
5765        };
5766        Ok(SearchResult {
5767            hits: fused,
5768            wildcard_fallback: lexical.wildcard_fallback,
5769            cache_stats: lexical.cache_stats,
5770            suggestions,
5771            ann_stats: semantic_ann_stats,
5772            total_count: None,
5773        })
5774    }
5775
5776    /// Generate "did-you-mean" suggestions for zero-hit queries.
5777    fn generate_suggestions(&self, query: &str, filters: &SearchFilters) -> Vec<QuerySuggestion> {
5778        let mut suggestions = Vec::new();
5779        let query_lower = query.to_lowercase();
5780
5781        // 1. Suggest wildcard search if query doesn't have wildcards
5782        if !query.contains('*') && query.len() >= 2 {
5783            suggestions.push(QuerySuggestion::wildcard(query).with_shortcut(1));
5784        }
5785
5786        // 2. Suggest removing agent filter if one is set
5787        if !filters.agents.is_empty() {
5788            let agents: Vec<&str> = filters
5789                .agents
5790                .iter()
5791                .map(std::string::String::as_str)
5792                .collect();
5793            let agent_str = agents.join(", ");
5794            suggestions
5795                .push(QuerySuggestion::remove_agent_filter(&agent_str, filters).with_shortcut(2));
5796        }
5797
5798        // 3. Suggest common agent names if query looks like a typo of one
5799        let known_agents = [
5800            "codex",
5801            "claude",
5802            "claude_code",
5803            "cline",
5804            "gemini",
5805            "amp",
5806            "opencode",
5807        ];
5808        for agent in &known_agents {
5809            if levenshtein_distance(&query_lower, agent) <= 2 && query_lower != *agent {
5810                suggestions.push(
5811                    QuerySuggestion::spelling(query, agent)
5812                        .with_shortcut(suggestions.len().min(2) as u8 + 1),
5813                );
5814                break; // Only suggest one spelling fix
5815            }
5816        }
5817
5818        // 4. Suggest alternative agents if SQLite is already open and no agent
5819        // filter is set. Avoid lazy-opening storage solely for no-hit advice:
5820        // large read-only frankensqlite opens can dominate fast lexical misses.
5821        if filters.agents.is_empty()
5822            && let Ok(sqlite_guard) = self.sqlite.lock()
5823            && let Some(conn) = sqlite_guard.as_ref()
5824            && let Ok(rows) = conn.query_map_collect(
5825                "SELECT a.slug
5826                 FROM conversations c
5827                 JOIN agents a ON c.agent_id = a.id
5828                 GROUP BY a.slug
5829                 ORDER BY MAX(c.id) DESC
5830                 LIMIT 3",
5831                &[],
5832                |row: &frankensqlite::Row| row.get_typed::<String>(0),
5833            )
5834        {
5835            for row in rows {
5836                if suggestions.len() < 3 {
5837                    suggestions.push(
5838                        QuerySuggestion::try_agent(&row)
5839                            .with_shortcut(suggestions.len().min(2) as u8 + 1),
5840                    );
5841                }
5842            }
5843        }
5844
5845        // Ensure we have at most 3 suggestions with shortcuts 1, 2, 3
5846        suggestions.truncate(3);
5847        for (i, sugg) in suggestions.iter_mut().enumerate() {
5848            sugg.shortcut = Some((i + 1) as u8);
5849        }
5850
5851        suggestions
5852    }
5853
5854    fn searcher_for_thread(&self, reader: &IndexReader) -> Searcher {
5855        let epoch = self.reload_epoch.load(Ordering::Relaxed);
5856        let reader_key = reader as *const IndexReader as usize;
5857        THREAD_SEARCHER.with(|slot| {
5858            let mut slot = slot.borrow_mut();
5859            if let Some(entry) = slot.as_ref()
5860                && entry.epoch == epoch
5861                && entry.reader_key == reader_key
5862            {
5863                return entry.searcher.clone();
5864            }
5865            let searcher = reader.searcher();
5866            *slot = Some(SearcherCacheEntry {
5867                epoch,
5868                reader_key,
5869                searcher: searcher.clone(),
5870            });
5871            searcher
5872        })
5873    }
5874
5875    fn federated_readers(&self) -> Option<Arc<Vec<FederatedIndexReader>>> {
5876        FEDERATED_SEARCH_READERS
5877            .read()
5878            .get(&self.cache_namespace)
5879            .cloned()
5880    }
5881
5882    fn maybe_reload_federated_readers(
5883        &self,
5884        readers: &[FederatedIndexReader],
5885    ) -> Result<Option<u64>> {
5886        if !self.reload_on_search || readers.is_empty() {
5887            return Ok(None);
5888        }
5889        const MIN_RELOAD_INTERVAL: Duration = Duration::from_millis(300);
5890        let now = Instant::now();
5891        let mut guard = self.last_reload.lock().unwrap_or_else(|e| e.into_inner());
5892        if guard
5893            .map(|t| now.duration_since(t) < MIN_RELOAD_INTERVAL)
5894            .unwrap_or(false)
5895        {
5896            let signature = self.federated_generation_signature(readers);
5897            return Ok(Some(signature));
5898        }
5899
5900        let reload_started = Instant::now();
5901        for shard in readers {
5902            shard.reader.reload()?;
5903        }
5904        let elapsed = reload_started.elapsed();
5905        *guard = Some(now);
5906        let epoch = self.reload_epoch.fetch_add(1, Ordering::SeqCst) + 1;
5907        self.metrics.record_reload(elapsed);
5908        tracing::debug!(
5909            duration_ms = elapsed.as_millis() as u64,
5910            reload_epoch = epoch,
5911            shards = readers.len(),
5912            "tantivy_reader_reload_federated"
5913        );
5914        Ok(Some(self.federated_generation_signature(readers)))
5915    }
5916
5917    fn federated_generation_signature(&self, readers: &[FederatedIndexReader]) -> u64 {
5918        let mut hasher = std::collections::hash_map::DefaultHasher::new();
5919        readers.len().hash(&mut hasher);
5920        for shard in readers {
5921            self.searcher_for_thread(&shard.reader)
5922                .generation()
5923                .generation_id()
5924                .hash(&mut hasher);
5925        }
5926        hasher.finish()
5927    }
5928
5929    fn track_generation(&self, generation: u64) {
5930        let mut guard = self
5931            .last_generation
5932            .lock()
5933            .unwrap_or_else(|e| e.into_inner());
5934        if let Some(prev) = *guard
5935            && prev != generation
5936            && let Ok(mut cache) = self.prefix_cache.lock()
5937        {
5938            cache.clear();
5939        }
5940        *guard = Some(generation);
5941    }
5942
5943    fn hydrate_tantivy_hit_contents(
5944        &self,
5945        exact_keys: &[TantivyContentExactKey],
5946        fallback_keys: &[TantivyContentFallbackKey],
5947    ) -> Result<TantivyHydratedContentMaps> {
5948        if exact_keys.is_empty() && fallback_keys.is_empty() {
5949            return Ok((HashMap::new(), HashMap::new()));
5950        }
5951
5952        let sqlite_guard = match self.sqlite_guard() {
5953            Ok(guard) => guard,
5954            Err(_) => return Ok((HashMap::new(), HashMap::new())),
5955        };
5956        let Some(conn) = sqlite_guard.as_ref() else {
5957            return Ok((HashMap::new(), HashMap::new()));
5958        };
5959
5960        let mut hydrated_exact = HashMap::new();
5961        let mut hydrated_fallback = HashMap::new();
5962        const CHUNK_SIZE: usize = 300;
5963
5964        if !exact_keys.is_empty() {
5965            let mut unique_exact_keys = Vec::with_capacity(exact_keys.len());
5966            let mut seen = HashSet::with_capacity(exact_keys.len());
5967            for key in exact_keys {
5968                if seen.insert(*key) {
5969                    unique_exact_keys.push(*key);
5970                }
5971            }
5972
5973            hydrated_exact.extend(hydrate_message_content_by_conversation(
5974                conn,
5975                &unique_exact_keys,
5976            )?);
5977        }
5978
5979        if !fallback_keys.is_empty() {
5980            let mut unique_fallback_keys = Vec::with_capacity(fallback_keys.len());
5981            let mut seen = HashSet::with_capacity(fallback_keys.len());
5982            for key in fallback_keys {
5983                if seen.insert(key.clone()) {
5984                    unique_fallback_keys.push(key.clone());
5985                }
5986            }
5987
5988            let mut unique_source_paths = Vec::with_capacity(unique_fallback_keys.len());
5989            let mut seen_source_paths = HashSet::with_capacity(unique_fallback_keys.len());
5990            for (_, source_path, _) in &unique_fallback_keys {
5991                if seen_source_paths.insert(source_path.clone()) {
5992                    unique_source_paths.push(source_path.clone());
5993                }
5994            }
5995
5996            let mut conversations_by_key: HashMap<(String, String), Vec<i64>> = HashMap::new();
5997            for chunk in unique_source_paths.chunks(CHUNK_SIZE) {
5998                let placeholders = sql_placeholders(chunk.len());
5999                let sql = format!(
6000                    "SELECT c.id,
6001                            c.source_path,
6002                            COALESCE(c.source_id, ''),
6003                            COALESCE(c.origin_host, ''),
6004                            COALESCE(s.kind, '')
6005                     FROM conversations c
6006                     LEFT JOIN sources s ON c.source_id = s.id
6007                     WHERE c.source_path IN ({placeholders})
6008                     ORDER BY c.id"
6009                );
6010                let params = chunk
6011                    .iter()
6012                    .map(|source_path| ParamValue::from(source_path.clone()))
6013                    .collect::<Vec<_>>();
6014                let rows: Vec<(i64, String, String, String, String)> =
6015                    franken_query_map_collect_retry(conn, &sql, &params, |row| {
6016                        Ok((
6017                            row.get_typed(0)?,
6018                            row.get_typed(1)?,
6019                            row.get_typed(2)?,
6020                            row.get_typed(3)?,
6021                            row.get_typed(4)?,
6022                        ))
6023                    })?;
6024
6025                for (conversation_id, source_path, raw_source_id, origin_host, origin_kind) in rows
6026                {
6027                    let normalized_source_id = normalized_search_hit_source_id_parts(
6028                        &raw_source_id,
6029                        &origin_kind,
6030                        (!origin_host.trim().is_empty()).then_some(origin_host.as_str()),
6031                    );
6032                    conversations_by_key
6033                        .entry((normalized_source_id, source_path))
6034                        .or_default()
6035                        .push(conversation_id);
6036                }
6037            }
6038
6039            let mut message_requests = Vec::new();
6040            let mut fallback_keys_by_exact: HashMap<
6041                TantivyContentExactKey,
6042                Vec<TantivyContentFallbackKey>,
6043            > = HashMap::new();
6044            let mut seen_message_requests = HashSet::new();
6045            for (source_id, source_path, line_idx) in &unique_fallback_keys {
6046                let key = (source_id.clone(), source_path.clone());
6047                let Some(conversation_ids) = conversations_by_key.get(&key) else {
6048                    continue;
6049                };
6050                for &conversation_id in conversation_ids {
6051                    let exact_key = (conversation_id, *line_idx);
6052                    if seen_message_requests.insert(exact_key) {
6053                        message_requests.push(exact_key);
6054                    }
6055                    fallback_keys_by_exact.entry(exact_key).or_default().push((
6056                        source_id.clone(),
6057                        source_path.clone(),
6058                        *line_idx,
6059                    ));
6060                }
6061            }
6062
6063            for ((conversation_id, line_idx), content) in
6064                hydrate_message_content_by_conversation(conn, &message_requests)?
6065            {
6066                if let Some(fallback_keys) =
6067                    fallback_keys_by_exact.get(&(conversation_id, line_idx))
6068                {
6069                    for fallback_key in fallback_keys {
6070                        hydrated_fallback.insert(fallback_key.clone(), content.clone());
6071                    }
6072                }
6073            }
6074        }
6075
6076        Ok((hydrated_exact, hydrated_fallback))
6077    }
6078
6079    #[allow(clippy::too_many_arguments)]
6080    fn search_tantivy(
6081        &self,
6082        reader: &IndexReader,
6083        fields: &FsCassFields,
6084        raw_query: &str,
6085        sanitized_query: &str,
6086        filters: SearchFilters,
6087        limit: usize,
6088        offset: usize,
6089        field_mask: FieldMask,
6090    ) -> Result<(Vec<SearchHit>, usize)> {
6091        struct PendingTantivyHit {
6092            score: f32,
6093            doc: TantivyDocument,
6094            title: String,
6095            stored_content: String,
6096            stored_preview: String,
6097            agent: String,
6098            source_path: String,
6099            workspace: String,
6100            workspace_original: Option<String>,
6101            created_at: Option<i64>,
6102            line_number: Option<usize>,
6103            stored_preview_snippet: Option<String>,
6104            source_id: String,
6105            conversation_id: Option<i64>,
6106            raw_origin_kind: Option<String>,
6107            origin_host: Option<String>,
6108        }
6109
6110        self.maybe_reload_reader(reader)?;
6111        let searcher = self.searcher_for_thread(reader);
6112        self.track_generation(searcher.generation().generation_id());
6113
6114        let wants_snippet = field_mask.wants_snippet();
6115        let needs_content = field_mask.needs_content() || wants_snippet;
6116
6117        // Delegate cass-compatible query parsing + Tantivy clause construction to frankensearch.
6118        // cass retains ownership of paging/fallback orchestration and stored-field hydration.
6119        let fs_filters = FsCassQueryFilters {
6120            agents: filters.agents.into_iter().collect(),
6121            workspaces: filters.workspaces.into_iter().collect(),
6122            created_from: filters.created_from,
6123            created_to: filters.created_to,
6124            source_filter: match filters.source_filter {
6125                SourceFilter::All => FsCassSourceFilter::All,
6126                SourceFilter::Local => FsCassSourceFilter::Local,
6127                SourceFilter::Remote => FsCassSourceFilter::Remote,
6128                SourceFilter::SourceId(id) => {
6129                    FsCassSourceFilter::SourceId(normalize_search_source_filter_value(&id))
6130                }
6131            },
6132        };
6133
6134        // NOTE: session_paths filtering is applied post-search since source_path
6135        // is STORED but not indexed. See apply_session_paths_filter().
6136        let q: Box<dyn Query> = fs_cass_build_tantivy_query(raw_query, &fs_filters, fields);
6137
6138        let prefix_only = is_prefix_only(sanitized_query);
6139        let top_docs = execute_query_with_lazy_exact_count(&searcher, &*q, limit, offset)?;
6140        let tantivy_total_count = top_docs.total_count;
6141        let query_match_type = dominant_match_type(sanitized_query);
6142        let mut pending_hits = Vec::with_capacity(top_docs.hits.len());
6143        let mut missing_exact_content_keys = Vec::new();
6144        let mut missing_fallback_content_keys = Vec::new();
6145
6146        for ranked_hit in top_docs.hits {
6147            let score = ranked_hit.bm25_score;
6148            let doc: TantivyDocument = fs_load_doc(&searcher, ranked_hit.doc_address)?;
6149            let title = if field_mask.wants_title() {
6150                doc.get_first(fields.title)
6151                    .and_then(|v| v.as_str())
6152                    .unwrap_or("")
6153                    .to_string()
6154            } else {
6155                String::new()
6156            };
6157            let stored_content = doc
6158                .get_first(fields.content)
6159                .and_then(|v| v.as_str())
6160                .unwrap_or("")
6161                .to_string();
6162            let stored_preview = doc
6163                .get_first(fields.preview)
6164                .and_then(|v| v.as_str())
6165                .unwrap_or("")
6166                .to_string();
6167            let stored_preview_snippet = snippet_from_preview_without_full_content(
6168                field_mask,
6169                &stored_preview,
6170                sanitized_query,
6171            );
6172            let agent = doc
6173                .get_first(fields.agent)
6174                .and_then(|v| v.as_str())
6175                .unwrap_or("")
6176                .to_string();
6177            let workspace = doc
6178                .get_first(fields.workspace)
6179                .and_then(|v| v.as_str())
6180                .unwrap_or("")
6181                .to_string();
6182            let workspace_original = doc
6183                .get_first(fields.workspace_original)
6184                .and_then(|v| v.as_str())
6185                .filter(|s| !s.is_empty())
6186                .map(String::from);
6187            let created_at = doc.get_first(fields.created_at).and_then(|v| v.as_i64());
6188            let line_number = doc
6189                .get_first(fields.msg_idx)
6190                .and_then(|v| v.as_u64())
6191                .and_then(|i| usize::try_from(i).ok())
6192                .map(|i| i.saturating_add(1));
6193            let raw_source_id = doc
6194                .get_first(fields.source_id)
6195                .and_then(|v| v.as_str())
6196                .unwrap_or_default()
6197                .to_string();
6198            let conversation_id = fields
6199                .conversation_id
6200                .and_then(|field| doc.get_first(field))
6201                .and_then(|v| v.as_i64());
6202            let source_path = doc
6203                .get_first(fields.source_path)
6204                .and_then(|v| v.as_str())
6205                .unwrap_or("")
6206                .to_string();
6207            let raw_origin_kind = doc
6208                .get_first(fields.origin_kind)
6209                .and_then(|v| v.as_str())
6210                .map(str::to_string);
6211            let origin_host = doc
6212                .get_first(fields.origin_host)
6213                .and_then(|v| v.as_str())
6214                .filter(|s| !s.is_empty())
6215                .map(String::from);
6216            let source_id = normalized_search_hit_source_id_parts(
6217                raw_source_id.as_str(),
6218                raw_origin_kind.as_deref().unwrap_or_default(),
6219                origin_host.as_deref(),
6220            );
6221
6222            let preview_satisfies_bounded_content =
6223                field_mask.preview_content_limit().is_some() && !stored_preview.is_empty();
6224            let preview_satisfies_full_content = field_mask.needs_content()
6225                && field_mask.preview_content_limit().is_none()
6226                && stored_preview_is_complete_content(&stored_preview);
6227            if needs_content
6228                && let Some(line_idx) = line_number
6229                    .and_then(|line| line.checked_sub(1))
6230                    .and_then(|line| i64::try_from(line).ok())
6231                && stored_content.is_empty()
6232                && !preview_satisfies_bounded_content
6233                && !preview_satisfies_full_content
6234                && stored_preview_snippet.is_none()
6235            {
6236                if let Some(conversation_id) = conversation_id {
6237                    missing_exact_content_keys.push((conversation_id, line_idx));
6238                } else {
6239                    missing_fallback_content_keys.push((
6240                        source_id.clone(),
6241                        source_path.clone(),
6242                        line_idx,
6243                    ));
6244                }
6245            }
6246
6247            pending_hits.push(PendingTantivyHit {
6248                score,
6249                doc,
6250                title,
6251                stored_content,
6252                stored_preview,
6253                agent,
6254                source_path,
6255                workspace,
6256                workspace_original,
6257                created_at,
6258                line_number,
6259                stored_preview_snippet,
6260                source_id,
6261                conversation_id,
6262                raw_origin_kind,
6263                origin_host,
6264            });
6265        }
6266
6267        let (hydrated_contents, hydrated_fallback_contents) = if needs_content
6268            && (!missing_exact_content_keys.is_empty() || !missing_fallback_content_keys.is_empty())
6269        {
6270            self.hydrate_tantivy_hit_contents(
6271                &missing_exact_content_keys,
6272                &missing_fallback_content_keys,
6273            )?
6274        } else {
6275            (HashMap::new(), HashMap::new())
6276        };
6277        let needs_tantivy_snippet_generator = wants_snippet
6278            && !prefix_only
6279            && pending_hits
6280                .iter()
6281                .any(|pending| pending.stored_preview_snippet.is_none());
6282        let snippet_generator = if needs_tantivy_snippet_generator {
6283            let snippet_cfg = FsSnippetConfig {
6284                max_chars: 160,
6285                highlight_prefix: "<b>".to_string(),
6286                highlight_postfix: "</b>".to_string(),
6287            };
6288            fs_try_build_snippet_generator(&searcher, &*q, fields.content, &snippet_cfg)
6289        } else {
6290            None
6291        };
6292        let mut hits = Vec::with_capacity(pending_hits.len());
6293        for pending in pending_hits {
6294            let hydrated_content = pending
6295                .line_number
6296                .and_then(|line| line.checked_sub(1))
6297                .and_then(|line| i64::try_from(line).ok())
6298                .and_then(|line_idx| {
6299                    if let Some(conversation_id) = pending.conversation_id {
6300                        hydrated_contents.get(&(conversation_id, line_idx)).cloned()
6301                    } else {
6302                        hydrated_fallback_contents
6303                            .get(&(
6304                                pending.source_id.clone(),
6305                                pending.source_path.clone(),
6306                                line_idx,
6307                            ))
6308                            .cloned()
6309                    }
6310                });
6311            let preview_satisfies_effective_content = !pending.stored_preview.is_empty()
6312                && (field_mask.preview_content_limit().is_some()
6313                    || (field_mask.needs_content()
6314                        && field_mask.preview_content_limit().is_none()
6315                        && stored_preview_is_complete_content(&pending.stored_preview)));
6316            let effective_content = if !pending.stored_content.is_empty() {
6317                pending.stored_content.clone()
6318            } else if preview_satisfies_effective_content {
6319                pending.stored_preview.clone()
6320            } else if let Some(content) = hydrated_content {
6321                content
6322            } else {
6323                pending.stored_preview.clone()
6324            };
6325            let snippet = if wants_snippet {
6326                if let Some(snippet) = pending.stored_preview_snippet.clone() {
6327                    snippet
6328                } else if let Some(r#gen) = &snippet_generator {
6329                    let rendered = if !pending.stored_content.is_empty() {
6330                        fs_render_snippet_html(r#gen, &pending.doc, "<b>", "</b>")
6331                    } else if !effective_content.is_empty() {
6332                        let mut snippet_doc = TantivyDocument::new();
6333                        snippet_doc.add_text(fields.content, &effective_content);
6334                        fs_render_snippet_html(r#gen, &snippet_doc, "<b>", "</b>")
6335                    } else {
6336                        None
6337                    };
6338                    rendered
6339                        .map(|html| html.replace("<b>", "**").replace("</b>", "**"))
6340                        .or_else(|| cached_prefix_snippet(&effective_content, sanitized_query, 160))
6341                        .unwrap_or_else(|| {
6342                            quick_prefix_snippet(&effective_content, sanitized_query, 160)
6343                        })
6344                } else if let Some(sn) =
6345                    cached_prefix_snippet(&effective_content, sanitized_query, 160)
6346                {
6347                    sn
6348                } else {
6349                    quick_prefix_snippet(&effective_content, sanitized_query, 160)
6350                }
6351            } else {
6352                String::new()
6353            };
6354            let content = if field_mask.needs_content() {
6355                effective_content.clone()
6356            } else {
6357                String::new()
6358            };
6359            let content_hash = stable_hit_hash(
6360                &effective_content,
6361                &pending.source_path,
6362                pending.line_number,
6363                pending.created_at,
6364            );
6365            let origin_kind = normalized_search_hit_origin_kind(
6366                &pending.source_id,
6367                pending.raw_origin_kind.as_deref(),
6368            )
6369            .to_string();
6370            hits.push(SearchHit {
6371                title: pending.title,
6372                snippet,
6373                content,
6374                content_hash,
6375                conversation_id: pending.conversation_id,
6376                score: pending.score,
6377                source_path: pending.source_path,
6378                agent: pending.agent,
6379                workspace: pending.workspace,
6380                workspace_original: pending.workspace_original,
6381                created_at: pending.created_at,
6382                line_number: pending.line_number,
6383                match_type: query_match_type,
6384                source_id: pending.source_id,
6385                origin_kind,
6386                origin_host: pending.origin_host,
6387            });
6388        }
6389        Ok((hits, tantivy_total_count))
6390    }
6391
6392    #[allow(clippy::too_many_arguments)]
6393    fn search_tantivy_federated(
6394        &self,
6395        readers: &[FederatedIndexReader],
6396        raw_query: &str,
6397        sanitized_query: &str,
6398        filters: SearchFilters,
6399        limit: usize,
6400        field_mask: FieldMask,
6401    ) -> Result<(Vec<SearchHit>, usize)> {
6402        let mut ranked_hits = Vec::new();
6403        let mut total_count = 0usize;
6404
6405        for (shard_index, shard) in readers.iter().enumerate() {
6406            let (shard_hits, shard_total_count) = self.search_tantivy(
6407                &shard.reader,
6408                &shard.fields,
6409                raw_query,
6410                sanitized_query,
6411                filters.clone(),
6412                limit,
6413                0,
6414                field_mask,
6415            )?;
6416            total_count = total_count.saturating_add(shard_total_count);
6417            for (shard_rank, hit) in shard_hits.into_iter().enumerate() {
6418                ranked_hits.push(FederatedRankedHit {
6419                    hit,
6420                    shard_index,
6421                    shard_rank,
6422                    fused_score: federated_rrf_score(shard_rank),
6423                });
6424            }
6425        }
6426
6427        let raw_hit_count = ranked_hits.len();
6428        let generation_signature = self.federated_generation_signature(readers);
6429        self.track_generation(generation_signature);
6430        let combined_hits = merge_federated_ranked_hits(ranked_hits);
6431        tracing::debug!(
6432            generation_signature,
6433            shard_count = readers.len(),
6434            total_count,
6435            raw_hit_count,
6436            returned_hit_count = combined_hits.len(),
6437            merge_policy = "rrf_rank_then_stable_hit_key",
6438            "federated lexical search merged shard results"
6439        );
6440
6441        Ok((combined_hits, total_count))
6442    }
6443
6444    fn sqlite_fts_uses_message_id_column(conn: &Connection) -> Result<bool> {
6445        let params: [ParamValue; 0] = [];
6446        let ddl_rows: Vec<String> = franken_query_map_collect_retry(
6447            conn,
6448            "SELECT COALESCE(sql, '')
6449             FROM sqlite_master
6450             WHERE name = 'fts_messages'
6451             ORDER BY rowid DESC
6452             LIMIT 1",
6453            &params,
6454            |row: &frankensqlite::Row| row.get_typed::<String>(0),
6455        )?;
6456        Ok(ddl_rows
6457            .first()
6458            .map(|sql| sql.to_ascii_lowercase().contains("message_id"))
6459            .unwrap_or(false))
6460    }
6461
6462    fn sqlite_fts_match_mode(conn: &Connection) -> Result<SqliteFtsMatchMode> {
6463        let params = [ParamValue::from("__cass_fts_probe_no_match__")];
6464        match franken_query_map_collect_retry(
6465            conn,
6466            "SELECT COUNT(*) FROM fts_messages WHERE fts_messages MATCH ?",
6467            &params,
6468            |row: &frankensqlite::Row| row.get_typed::<i64>(0),
6469        ) {
6470            Ok(_) => Ok(SqliteFtsMatchMode::Table),
6471            Err(err)
6472                if err
6473                    .to_string()
6474                    .contains("no such column: fts_messages in table fts_messages") =>
6475            {
6476                Ok(SqliteFtsMatchMode::IndexedColumns)
6477            }
6478            Err(err) => Err(anyhow!(err)),
6479        }
6480    }
6481
6482    fn sqlite_fts5_rowid_projection_available(conn: &Connection) -> bool {
6483        let params: [ParamValue; 0] = [];
6484        franken_query_map_collect_retry(
6485            conn,
6486            "SELECT rowid FROM fts_messages LIMIT 1",
6487            &params,
6488            |row: &frankensqlite::Row| row.get_typed::<i64>(0),
6489        )
6490        .is_ok()
6491    }
6492
6493    fn sqlite_fts5_match_clause(match_mode: SqliteFtsMatchMode) -> &'static str {
6494        match match_mode {
6495            SqliteFtsMatchMode::Table => "fts_messages MATCH ?",
6496            SqliteFtsMatchMode::IndexedColumns => {
6497                "(content MATCH ?
6498                  OR title MATCH ?
6499                  OR agent MATCH ?
6500                  OR workspace MATCH ?
6501                  OR source_path MATCH ?)"
6502            }
6503        }
6504    }
6505
6506    fn push_sqlite_fts5_match_params(
6507        params: &mut Vec<ParamValue>,
6508        fts_query: &str,
6509        match_mode: SqliteFtsMatchMode,
6510    ) {
6511        let copies = match match_mode {
6512            SqliteFtsMatchMode::Table => 1,
6513            SqliteFtsMatchMode::IndexedColumns => 5,
6514        };
6515        for _ in 0..copies {
6516            params.push(ParamValue::from(fts_query));
6517        }
6518    }
6519
6520    fn sqlite_fts5_rank_query(
6521        fts_query: &str,
6522        _filters: &SearchFilters,
6523        limit: usize,
6524        offset: usize,
6525        _uses_message_id: bool,
6526        match_mode: SqliteFtsMatchMode,
6527    ) -> (String, Vec<ParamValue>) {
6528        let match_clause = Self::sqlite_fts5_match_clause(match_mode);
6529        let mut sql = format!(
6530            "SELECT rowid,
6531                    bm25(fts_messages)
6532             FROM fts_messages
6533             WHERE {match_clause}"
6534        );
6535        let mut params = Vec::with_capacity(9);
6536        Self::push_sqlite_fts5_match_params(&mut params, fts_query, match_mode);
6537
6538        sql.push_str(" ORDER BY bm25(fts_messages), rowid LIMIT ? OFFSET ?");
6539        params.push(ParamValue::from(limit as i64));
6540        params.push(ParamValue::from(offset as i64));
6541
6542        (sql, params)
6543    }
6544
6545    fn sqlite_fts5_hydrate_query(
6546        row_count: usize,
6547        field_mask: FieldMask,
6548        uses_message_id: bool,
6549    ) -> String {
6550        let title_expr = if field_mask.wants_title() {
6551            "fts_messages.title"
6552        } else {
6553            "NULL"
6554        };
6555        let content_expr = if field_mask.needs_content() || field_mask.wants_snippet() {
6556            "fts_messages.content"
6557        } else {
6558            "NULL"
6559        };
6560        let message_key_expr = if uses_message_id {
6561            "CAST(fts_messages.message_id AS INTEGER)"
6562        } else {
6563            "rowid"
6564        };
6565        let placeholders = sql_placeholders(row_count);
6566
6567        format!(
6568            "SELECT rowid,
6569                    {message_key_expr},
6570                    {title_expr},
6571                    {content_expr},
6572                    fts_messages.agent,
6573                    fts_messages.workspace,
6574                    fts_messages.source_path,
6575                    CAST(fts_messages.created_at AS INTEGER)
6576             FROM fts_messages
6577             WHERE rowid IN ({placeholders})"
6578        )
6579    }
6580
6581    fn sqlite_fts5_message_hydrate_query(row_count: usize, field_mask: FieldMask) -> String {
6582        let title_expr = if field_mask.wants_title() {
6583            "COALESCE(c.title, '')"
6584        } else {
6585            "''"
6586        };
6587        let content_expr = if field_mask.needs_content() || field_mask.wants_snippet() {
6588            "COALESCE(m.content, '')"
6589        } else {
6590            "''"
6591        };
6592        let normalized_source_sql =
6593            normalized_search_source_id_sql_expr("c.source_id", "s.kind", "c.origin_host");
6594        let placeholders = sql_placeholders(row_count);
6595
6596        format!(
6597            "SELECT m.id,
6598                    {title_expr},
6599                    {content_expr},
6600                    COALESCE(a.slug, ''),
6601                    COALESCE(w.path, ''),
6602                    COALESCE(c.source_path, ''),
6603                    CAST(m.created_at AS INTEGER),
6604                    m.idx,
6605                    c.id,
6606                    {normalized_source_sql},
6607                    c.origin_host,
6608                    s.kind
6609             FROM messages m
6610             LEFT JOIN conversations c ON m.conversation_id = c.id
6611             LEFT JOIN sources s ON c.source_id = s.id
6612             LEFT JOIN agents a ON c.agent_id = a.id
6613             LEFT JOIN workspaces w ON c.workspace_id = w.id
6614             WHERE m.id IN ({placeholders})"
6615        )
6616    }
6617
6618    fn sqlite_fts5_hydrate_row_chunks(
6619        ranked_rows: &[(i64, f64)],
6620    ) -> impl Iterator<Item = &[(i64, f64)]> {
6621        const _: () = assert!(SQLITE_FTS5_HYDRATE_PARAM_CHUNK <= SQLITE_MAX_VARIABLE_NUMBER);
6622        ranked_rows.chunks(SQLITE_FTS5_HYDRATE_PARAM_CHUNK)
6623    }
6624
6625    fn sqlite_fts5_filters_need_post_hydration(filters: &SearchFilters) -> bool {
6626        !filters.agents.is_empty()
6627            || !filters.workspaces.is_empty()
6628            || filters.created_from.is_some()
6629            || filters.created_to.is_some()
6630            || !filters.source_filter.is_all()
6631            || !filters.session_paths.is_empty()
6632    }
6633
6634    fn sqlite_fts5_hit_matches_filters(hit: &SearchHit, filters: &SearchFilters) -> bool {
6635        if !filters.agents.is_empty() && !filters.agents.contains(&hit.agent) {
6636            return false;
6637        }
6638        if !filters.workspaces.is_empty() && !filters.workspaces.contains(&hit.workspace) {
6639            return false;
6640        }
6641        if filters.created_from.is_some() || filters.created_to.is_some() {
6642            let Some(created_at) = hit.created_at else {
6643                return false;
6644            };
6645            if let Some(created_from) = filters.created_from
6646                && created_at < created_from
6647            {
6648                return false;
6649            }
6650            if let Some(created_to) = filters.created_to
6651                && created_at > created_to
6652            {
6653                return false;
6654            }
6655        }
6656        if !filters.session_paths.is_empty() && !filters.session_paths.contains(&hit.source_path) {
6657            return false;
6658        }
6659
6660        match &filters.source_filter {
6661            SourceFilter::All => true,
6662            SourceFilter::Local => matches!(
6663                hit.source_id
6664                    .as_str()
6665                    .cmp(crate::sources::provenance::LOCAL_SOURCE_ID),
6666                CmpOrdering::Equal
6667            ),
6668            SourceFilter::Remote => !matches!(
6669                hit.source_id
6670                    .as_str()
6671                    .cmp(crate::sources::provenance::LOCAL_SOURCE_ID),
6672                CmpOrdering::Equal
6673            ),
6674            SourceFilter::SourceId(id) => {
6675                let normalized = normalize_search_source_filter_value(id);
6676                matches!(
6677                    hit.source_id.as_str().cmp(normalized.as_str()),
6678                    CmpOrdering::Equal
6679                )
6680            }
6681        }
6682    }
6683
6684    fn sqlite_message_scan_query(raw_query: &str) -> Option<SqliteMessageScanQuery> {
6685        fn scan_parts(parts: Vec<String>) -> Vec<String> {
6686            parts
6687                .into_iter()
6688                .map(|part| part.trim_end_matches('*').to_lowercase())
6689                .filter(|part| !part.is_empty())
6690                .collect()
6691        }
6692
6693        let tokens = fs_cass_parse_boolean_query(raw_query);
6694        if tokens.is_empty() {
6695            return None;
6696        }
6697
6698        let mut include_groups = Vec::new();
6699        let mut pending_or_group: SqliteMessageScanGroup = Vec::new();
6700        let mut exclude_terms = Vec::new();
6701        let mut negated = false;
6702        let mut in_or_sequence = false;
6703        for token in tokens {
6704            match token {
6705                FsCassQueryToken::And => {
6706                    if !pending_or_group.is_empty() {
6707                        include_groups.push(std::mem::take(&mut pending_or_group));
6708                    }
6709                    in_or_sequence = false;
6710                    negated = false;
6711                }
6712                FsCassQueryToken::Or => {
6713                    if include_groups.is_empty() && pending_or_group.is_empty() {
6714                        continue;
6715                    }
6716                    if negated {
6717                        return None;
6718                    }
6719                    in_or_sequence = true;
6720                }
6721                FsCassQueryToken::Not => {
6722                    if in_or_sequence {
6723                        return None;
6724                    }
6725                    if !pending_or_group.is_empty() {
6726                        include_groups.push(std::mem::take(&mut pending_or_group));
6727                    }
6728                    negated = true;
6729                    in_or_sequence = false;
6730                }
6731                FsCassQueryToken::Term(term) => {
6732                    let parts = scan_parts(normalize_term_parts(&term));
6733                    if parts.is_empty() {
6734                        continue;
6735                    }
6736                    if negated {
6737                        exclude_terms.extend(parts);
6738                    } else if in_or_sequence {
6739                        if pending_or_group.is_empty() {
6740                            let previous = include_groups.pop()?;
6741                            pending_or_group.extend(previous);
6742                        }
6743                        pending_or_group.push(parts);
6744                    } else {
6745                        include_groups.push(vec![parts]);
6746                    }
6747                    negated = false;
6748                }
6749                FsCassQueryToken::Phrase(phrase) => {
6750                    let parts = normalize_phrase_terms(&phrase);
6751                    if parts.is_empty() {
6752                        continue;
6753                    }
6754                    if negated {
6755                        exclude_terms.extend(parts);
6756                    } else if in_or_sequence {
6757                        if pending_or_group.is_empty() {
6758                            let previous = include_groups.pop()?;
6759                            pending_or_group.extend(previous);
6760                        }
6761                        pending_or_group.push(parts);
6762                    } else {
6763                        include_groups.push(vec![parts]);
6764                    }
6765                    negated = false;
6766                }
6767            }
6768        }
6769
6770        if !pending_or_group.is_empty() {
6771            include_groups.push(pending_or_group);
6772        }
6773
6774        for group in &mut include_groups {
6775            for alternative in group.iter_mut() {
6776                alternative.sort();
6777                alternative.dedup();
6778            }
6779            group.retain(|alternative| !alternative.is_empty());
6780            group.sort();
6781            group.dedup();
6782        }
6783        include_groups.retain(|group| !group.is_empty());
6784        exclude_terms.sort();
6785        exclude_terms.dedup();
6786        if include_groups.is_empty() {
6787            return None;
6788        }
6789
6790        Some(SqliteMessageScanQuery {
6791            include_groups,
6792            exclude_terms,
6793        })
6794    }
6795
6796    fn sqlite_message_scan_score(haystack: &str, scan_query: &SqliteMessageScanQuery) -> f32 {
6797        for term in &scan_query.exclude_terms {
6798            if haystack.contains(term) {
6799                return 0.0;
6800            }
6801        }
6802
6803        let mut score = 0.0f32;
6804        for group in &scan_query.include_groups {
6805            let mut group_score = 0.0f32;
6806            for alternative in group {
6807                let mut alternative_score = 0.0f32;
6808                for term in alternative {
6809                    let matches = haystack.matches(term).count();
6810                    if matches < 1 {
6811                        alternative_score = 0.0;
6812                        break;
6813                    }
6814                    alternative_score += matches as f32;
6815                }
6816                group_score = group_score.max(alternative_score);
6817            }
6818            if group_score <= 0.0 {
6819                return 0.0;
6820            }
6821            score += group_score;
6822        }
6823        score
6824    }
6825
6826    fn sqlite_message_scan_query_sql(field_mask: FieldMask) -> String {
6827        let title_expr = if field_mask.wants_title() {
6828            "COALESCE(c.title, '')"
6829        } else {
6830            "''"
6831        };
6832        let content_expr = if field_mask.needs_content() || field_mask.wants_snippet() {
6833            "COALESCE(m.content, '')"
6834        } else {
6835            "''"
6836        };
6837        let normalized_source_sql =
6838            normalized_search_source_id_sql_expr("c.source_id", "s.kind", "c.origin_host");
6839
6840        format!(
6841            "SELECT m.id,
6842                    {title_expr},
6843                    {content_expr},
6844                    COALESCE(a.slug, ''),
6845                    COALESCE(w.path, ''),
6846                    COALESCE(c.source_path, ''),
6847                    CAST(m.created_at AS INTEGER),
6848                    m.idx,
6849                    c.id,
6850                    {normalized_source_sql},
6851                    c.origin_host,
6852                    s.kind,
6853                    COALESCE(m.content, ''),
6854                    COALESCE(c.title, '')
6855             FROM messages m
6856             LEFT JOIN conversations c ON m.conversation_id = c.id
6857             LEFT JOIN sources s ON c.source_id = s.id
6858             LEFT JOIN agents a ON c.agent_id = a.id
6859             LEFT JOIN workspaces w ON c.workspace_id = w.id
6860             ORDER BY m.id
6861             LIMIT ?"
6862        )
6863    }
6864
6865    fn search_sqlite_message_scan(
6866        &self,
6867        conn: &Connection,
6868        request: SqliteMessageScanRequest<'_>,
6869    ) -> Result<Vec<SearchHit>> {
6870        let Some(scan_query) = Self::sqlite_message_scan_query(request.raw_query) else {
6871            return Ok(Vec::new());
6872        };
6873
6874        let sql = Self::sqlite_message_scan_query_sql(request.field_mask);
6875        let params = [ParamValue::from(SQLITE_MESSAGE_SCAN_FALLBACK_LIMIT as i64)];
6876        let rows: Vec<(SqliteFtsMessageRow, String, String)> =
6877            franken_query_map_collect_retry(conn, &sql, &params, |row| {
6878                Ok((
6879                    (
6880                        row.get_typed(0)?,
6881                        row.get_typed(1)?,
6882                        row.get_typed(2)?,
6883                        row.get_typed(3)?,
6884                        row.get_typed(4)?,
6885                        row.get_typed(5)?,
6886                        row.get_typed(6)?,
6887                        row.get_typed(7)?,
6888                        row.get_typed(8)?,
6889                        row.get_typed::<Option<String>>(9)?,
6890                        row.get_typed(10)?,
6891                        row.get_typed(11)?,
6892                    ),
6893                    row.get_typed(12)?,
6894                    row.get_typed(13)?,
6895                ))
6896            })?;
6897
6898        let mut scored_hits = Vec::new();
6899        for (
6900            (
6901                _message_id,
6902                title,
6903                raw_content,
6904                agent,
6905                workspace,
6906                source_path,
6907                created_at,
6908                idx,
6909                conversation_id,
6910                raw_source_id,
6911                origin_host,
6912                raw_origin_kind,
6913            ),
6914            scan_content,
6915            scan_title,
6916        ) in rows
6917        {
6918            let mut haystack = String::with_capacity(
6919                scan_content.len()
6920                    + scan_title.len()
6921                    + agent.len()
6922                    + workspace.len()
6923                    + source_path.len()
6924                    + 4,
6925            );
6926            haystack.push_str(&scan_content);
6927            haystack.push(' ');
6928            haystack.push_str(&scan_title);
6929            haystack.push(' ');
6930            haystack.push_str(&agent);
6931            haystack.push(' ');
6932            haystack.push_str(&workspace);
6933            haystack.push(' ');
6934            haystack.push_str(&source_path);
6935            let haystack = haystack.to_lowercase();
6936            let score = Self::sqlite_message_scan_score(&haystack, &scan_query);
6937            if score <= 0.0 {
6938                continue;
6939            }
6940
6941            let raw_source_id = raw_source_id.unwrap_or_else(default_source_id);
6942            let source_id = normalized_search_hit_source_id_parts(
6943                raw_source_id.as_str(),
6944                raw_origin_kind.as_deref().unwrap_or_default(),
6945                origin_host.as_deref(),
6946            );
6947            let origin_kind =
6948                normalized_search_hit_origin_kind(source_id.as_str(), raw_origin_kind.as_deref());
6949            let line_number = idx
6950                .and_then(|i| usize::try_from(i).ok())
6951                .map(|i| i.saturating_add(1));
6952            let snippet = if request.field_mask.wants_snippet() {
6953                snippet_from_content(&scan_content)
6954            } else {
6955                String::new()
6956            };
6957            let content = if request.field_mask.needs_content() {
6958                raw_content
6959            } else {
6960                String::new()
6961            };
6962            let content_hash = if content.is_empty() {
6963                stable_hit_hash(&snippet, &source_path, line_number, created_at)
6964            } else {
6965                stable_hit_hash(&content, &source_path, line_number, created_at)
6966            };
6967
6968            let hit = SearchHit {
6969                title,
6970                snippet,
6971                content,
6972                content_hash,
6973                conversation_id,
6974                score,
6975                source_path,
6976                agent,
6977                workspace,
6978                workspace_original: None,
6979                created_at,
6980                line_number,
6981                match_type: request.query_match_type,
6982                source_id,
6983                origin_kind,
6984                origin_host,
6985            };
6986
6987            if Self::sqlite_fts5_hit_matches_filters(&hit, request.filters) {
6988                scored_hits.push(hit);
6989            }
6990        }
6991
6992        scored_hits.sort_by(|left, right| {
6993            right
6994                .score
6995                .partial_cmp(&left.score)
6996                .unwrap_or(CmpOrdering::Equal)
6997        });
6998
6999        Ok(scored_hits
7000            .into_iter()
7001            .skip(request.offset)
7002            .take(request.limit)
7003            .collect())
7004    }
7005
7006    fn search_sqlite_fts5(
7007        &self,
7008        _db_path: &Path,
7009        raw_query: &str,
7010        filters: SearchFilters,
7011        limit: usize,
7012        offset: usize,
7013        field_mask: FieldMask,
7014    ) -> Result<Vec<SearchHit>> {
7015        if limit < 1 {
7016            return Ok(Vec::new());
7017        }
7018
7019        let fts_query = match transpile_to_fts5(raw_query) {
7020            Some(q) if !q.trim().is_empty() => q,
7021            _ => return Ok(Vec::new()),
7022        };
7023
7024        let sqlite_guard = self.sqlite_guard()?;
7025        let Some(conn) = sqlite_guard.as_ref() else {
7026            return Ok(Vec::new());
7027        };
7028
7029        let empty_params: [ParamValue; 0] = [];
7030        let has_fts = franken_query_map_collect_retry(
7031            conn,
7032            "SELECT 1 FROM sqlite_master WHERE name = 'fts_messages'",
7033            &empty_params,
7034            |row| row.get_typed::<i64>(0),
7035        )
7036        .map(|rows| !rows.is_empty())
7037        .unwrap_or(false);
7038        if !has_fts {
7039            return Ok(Vec::new());
7040        }
7041        crate::storage::sqlite::validate_fts_messages_integrity_for_connection(conn)
7042            .with_context(|| "validating sqlite fts_messages fallback integrity before search")?;
7043
7044        let query_match_type = dominant_match_type(raw_query);
7045        let scan_request = SqliteMessageScanRequest {
7046            raw_query,
7047            filters: &filters,
7048            limit,
7049            offset,
7050            field_mask,
7051            query_match_type,
7052        };
7053        let uses_message_id =
7054            if let Ok(uses_message_id) = Self::sqlite_fts_uses_message_id_column(conn) {
7055                uses_message_id
7056            } else {
7057                tracing::warn!(
7058                    "sqlite FTS fallback is present but not queryable; skipping fallback search"
7059                );
7060                return self.search_sqlite_message_scan(conn, scan_request);
7061            };
7062        let match_mode = match Self::sqlite_fts_match_mode(conn) {
7063            Ok(match_mode) => match_mode,
7064            Err(err) => {
7065                tracing::warn!(
7066                    error = %err,
7067                    "sqlite FTS fallback is present but not queryable; skipping fallback search"
7068                );
7069                return self.search_sqlite_message_scan(conn, scan_request);
7070            }
7071        };
7072        if !Self::sqlite_fts5_rowid_projection_available(conn) {
7073            tracing::warn!(
7074                "sqlite FTS fallback cannot project rowid through frankensqlite; using source-table scan fallback"
7075            );
7076            return self.search_sqlite_message_scan(conn, scan_request);
7077        }
7078
7079        let post_filter = Self::sqlite_fts5_filters_need_post_hydration(&filters);
7080        let target_hits = if post_filter {
7081            offset.saturating_add(limit)
7082        } else {
7083            limit
7084        };
7085        let rank_batch_limit = if post_filter {
7086            target_hits.clamp(1, SQLITE_FTS5_POST_FILTER_SCAN_CHUNK)
7087        } else {
7088            limit
7089        };
7090        let mut rank_offset = if post_filter { 0 } else { offset };
7091        let mut scanned_rows = 0usize;
7092        let mut hits = Vec::with_capacity(target_hits.min(rank_batch_limit));
7093
7094        loop {
7095            let (rank_sql, rank_params) = Self::sqlite_fts5_rank_query(
7096                fts_query.as_str(),
7097                &filters,
7098                rank_batch_limit,
7099                rank_offset,
7100                uses_message_id,
7101                match_mode,
7102            );
7103            let ranked_rows: Vec<(i64, f64)> =
7104                match franken_query_map_collect_retry(conn, &rank_sql, &rank_params, |row| {
7105                    Ok((row.get_typed(0)?, row.get_typed(1)?))
7106                }) {
7107                    Ok(rows) => rows,
7108                    Err(err) => {
7109                        tracing::warn!(
7110                            error = %err,
7111                            "sqlite FTS fallback rank query failed; returning no fallback hits"
7112                        );
7113                        return self.search_sqlite_message_scan(conn, scan_request);
7114                    }
7115                };
7116            if ranked_rows.is_empty() {
7117                break;
7118            }
7119
7120            scanned_rows = scanned_rows.saturating_add(ranked_rows.len());
7121            let bm25_by_rowid: HashMap<i64, f64> = ranked_rows.iter().copied().collect();
7122            let mut fts_rows_by_rowid = HashMap::with_capacity(ranked_rows.len());
7123            let mut message_ids = Vec::with_capacity(ranked_rows.len());
7124            let mut seen_message_ids = HashSet::with_capacity(ranked_rows.len());
7125
7126            for rank_chunk in Self::sqlite_fts5_hydrate_row_chunks(&ranked_rows) {
7127                let hydrate_sql =
7128                    Self::sqlite_fts5_hydrate_query(rank_chunk.len(), field_mask, uses_message_id);
7129                let hydrate_params = rank_chunk
7130                    .iter()
7131                    .map(|(fts_rowid, _)| ParamValue::from(*fts_rowid))
7132                    .collect::<Vec<_>>();
7133                let rows: Vec<SqliteFtsHydratedRow> = match franken_query_map_collect_retry(
7134                    conn,
7135                    &hydrate_sql,
7136                    &hydrate_params,
7137                    |row| {
7138                        Ok((
7139                            row.get_typed(0)?,
7140                            row.get_typed(1)?,
7141                            row.get_typed(2)?,
7142                            row.get_typed(3)?,
7143                            row.get_typed(4)?,
7144                            row.get_typed(5)?,
7145                            row.get_typed(6)?,
7146                            row.get_typed(7)?,
7147                        ))
7148                    },
7149                ) {
7150                    Ok(rows) => rows,
7151                    Err(err) => {
7152                        tracing::warn!(
7153                            error = %err,
7154                            "sqlite FTS fallback rowid hydration query failed; returning no fallback hits"
7155                        );
7156                        return self.search_sqlite_message_scan(conn, scan_request);
7157                    }
7158                };
7159
7160                for row in rows {
7161                    let fts_rowid = row.0;
7162                    let message_id = row.1.unwrap_or(fts_rowid);
7163                    if seen_message_ids.insert(message_id) {
7164                        message_ids.push(message_id);
7165                    }
7166                    fts_rows_by_rowid.insert(fts_rowid, row);
7167                }
7168            }
7169
7170            let mut metadata_by_message_id = HashMap::with_capacity(message_ids.len());
7171            for message_chunk in message_ids.chunks(SQLITE_FTS5_HYDRATE_PARAM_CHUNK) {
7172                let metadata_sql =
7173                    Self::sqlite_fts5_message_hydrate_query(message_chunk.len(), field_mask);
7174                let metadata_params = message_chunk
7175                    .iter()
7176                    .map(|message_id| ParamValue::from(*message_id))
7177                    .collect::<Vec<_>>();
7178                let metadata_rows: Vec<SqliteFtsMessageRow> = match franken_query_map_collect_retry(
7179                    conn,
7180                    &metadata_sql,
7181                    &metadata_params,
7182                    |row| {
7183                        Ok((
7184                            row.get_typed(0)?,
7185                            row.get_typed(1)?,
7186                            row.get_typed(2)?,
7187                            row.get_typed(3)?,
7188                            row.get_typed(4)?,
7189                            row.get_typed(5)?,
7190                            row.get_typed(6)?,
7191                            row.get_typed(7)?,
7192                            row.get_typed(8)?,
7193                            row.get_typed::<Option<String>>(9)?,
7194                            row.get_typed(10)?,
7195                            row.get_typed(11)?,
7196                        ))
7197                    },
7198                ) {
7199                    Ok(rows) => rows,
7200                    Err(err) => {
7201                        tracing::warn!(
7202                            error = %err,
7203                            "sqlite FTS fallback message hydration query failed; returning no fallback hits"
7204                        );
7205                        return self.search_sqlite_message_scan(conn, scan_request);
7206                    }
7207                };
7208                metadata_by_message_id.extend(metadata_rows.into_iter().map(|row| (row.0, row)));
7209            }
7210
7211            let mut hits_by_rowid = HashMap::with_capacity(ranked_rows.len());
7212            for (
7213                fts_rowid,
7214                fts_message_id,
7215                fts_title,
7216                fts_content,
7217                fts_agent,
7218                fts_workspace,
7219                fts_source_path,
7220                fts_created_at,
7221            ) in fts_rows_by_rowid.into_values()
7222            {
7223                let Some(&bm25_score) = bm25_by_rowid.get(&fts_rowid) else {
7224                    continue;
7225                };
7226                let message_id = fts_message_id.unwrap_or(fts_rowid);
7227                let (
7228                    title,
7229                    raw_content,
7230                    agent,
7231                    workspace,
7232                    source_path,
7233                    created_at,
7234                    idx,
7235                    conversation_id,
7236                    raw_source_id,
7237                    origin_host,
7238                    raw_origin_kind,
7239                ) = match metadata_by_message_id.remove(&message_id) {
7240                    Some((
7241                        _,
7242                        metadata_title,
7243                        metadata_content,
7244                        metadata_agent,
7245                        metadata_workspace,
7246                        metadata_source_path,
7247                        metadata_created_at,
7248                        metadata_idx,
7249                        metadata_conversation_id,
7250                        metadata_raw_source_id,
7251                        metadata_origin_host,
7252                        metadata_raw_origin_kind,
7253                    )) => (
7254                        if metadata_title.is_empty() {
7255                            fts_title.unwrap_or_default()
7256                        } else {
7257                            metadata_title
7258                        },
7259                        if metadata_content.is_empty() {
7260                            fts_content.unwrap_or_default()
7261                        } else {
7262                            metadata_content
7263                        },
7264                        if metadata_agent.is_empty() {
7265                            fts_agent.unwrap_or_default()
7266                        } else {
7267                            metadata_agent
7268                        },
7269                        if metadata_workspace.is_empty() {
7270                            fts_workspace.unwrap_or_default()
7271                        } else {
7272                            metadata_workspace
7273                        },
7274                        if metadata_source_path.is_empty() {
7275                            fts_source_path.unwrap_or_default()
7276                        } else {
7277                            metadata_source_path
7278                        },
7279                        metadata_created_at.or(fts_created_at),
7280                        metadata_idx,
7281                        metadata_conversation_id,
7282                        metadata_raw_source_id.unwrap_or_else(default_source_id),
7283                        metadata_origin_host,
7284                        metadata_raw_origin_kind,
7285                    ),
7286                    None => (
7287                        fts_title.unwrap_or_default(),
7288                        fts_content.unwrap_or_default(),
7289                        fts_agent.unwrap_or_default(),
7290                        fts_workspace.unwrap_or_default(),
7291                        fts_source_path.unwrap_or_default(),
7292                        fts_created_at,
7293                        None,
7294                        None,
7295                        default_source_id(),
7296                        None,
7297                        None,
7298                    ),
7299                };
7300
7301                let source_id = normalized_search_hit_source_id_parts(
7302                    raw_source_id.as_str(),
7303                    raw_origin_kind.as_deref().unwrap_or_default(),
7304                    origin_host.as_deref(),
7305                );
7306                let origin_kind = normalized_search_hit_origin_kind(
7307                    source_id.as_str(),
7308                    raw_origin_kind.as_deref(),
7309                );
7310                let line_number = idx
7311                    .and_then(|i| usize::try_from(i).ok())
7312                    .map(|i| i.saturating_add(1));
7313                let snippet = if field_mask.wants_snippet() {
7314                    snippet_from_content(&raw_content)
7315                } else {
7316                    String::new()
7317                };
7318                let content = if field_mask.needs_content() {
7319                    raw_content
7320                } else {
7321                    String::new()
7322                };
7323                let content_hash = if content.is_empty() {
7324                    stable_hit_hash(&snippet, &source_path, line_number, created_at)
7325                } else {
7326                    stable_hit_hash(&content, &source_path, line_number, created_at)
7327                };
7328
7329                let hit = SearchHit {
7330                    title,
7331                    snippet,
7332                    content,
7333                    content_hash,
7334                    conversation_id,
7335                    score: (-bm25_score) as f32,
7336                    source_path,
7337                    agent,
7338                    workspace,
7339                    workspace_original: None,
7340                    created_at,
7341                    line_number,
7342                    match_type: query_match_type,
7343                    source_id,
7344                    origin_kind,
7345                    origin_host,
7346                };
7347                hits_by_rowid.insert(fts_rowid, hit);
7348            }
7349
7350            for (fts_rowid, _) in &ranked_rows {
7351                if let Some(hit) = hits_by_rowid.remove(fts_rowid)
7352                    && Self::sqlite_fts5_hit_matches_filters(&hit, &filters)
7353                {
7354                    hits.push(hit);
7355                    if hits.len() >= target_hits {
7356                        break;
7357                    }
7358                }
7359            }
7360
7361            if hits.len() >= target_hits
7362                || !post_filter
7363                || ranked_rows.len() < rank_batch_limit
7364                || scanned_rows >= SQLITE_FTS5_POST_FILTER_SCAN_LIMIT
7365            {
7366                break;
7367            }
7368            rank_offset = rank_offset.saturating_add(ranked_rows.len());
7369        }
7370
7371        if post_filter {
7372            let hits = hits
7373                .into_iter()
7374                .skip(offset)
7375                .take(limit)
7376                .collect::<Vec<_>>();
7377            if hits.is_empty() {
7378                self.search_sqlite_message_scan(conn, scan_request)
7379            } else {
7380                Ok(hits)
7381            }
7382        } else if hits.is_empty() {
7383            self.search_sqlite_message_scan(conn, scan_request)
7384        } else {
7385            Ok(hits)
7386        }
7387    }
7388
7389    /// Browse messages ordered by date, without any text query.
7390    ///
7391    /// Used when the TUI query is empty and the user wants to see recent (or
7392    /// oldest) sessions. Bypasses BM25 scoring entirely and returns results
7393    /// ordered by `created_at`. Applies agent, workspace, time-range, and
7394    /// source filters identically to the normal search path.
7395    pub fn browse_by_date(
7396        &self,
7397        filters: SearchFilters,
7398        limit: usize,
7399        offset: usize,
7400        newest_first: bool,
7401        field_mask: FieldMask,
7402    ) -> Result<Vec<SearchHit>> {
7403        let sqlite_guard = self.sqlite_guard()?;
7404        if let Some(conn) = sqlite_guard.as_ref() {
7405            self.browse_by_date_sqlite(conn, filters, limit, offset, newest_first, field_mask)
7406        } else {
7407            Ok(Vec::new())
7408        }
7409    }
7410
7411    fn browse_by_date_sqlite(
7412        &self,
7413        conn: &Connection,
7414        filters: SearchFilters,
7415        limit: usize,
7416        offset: usize,
7417        newest_first: bool,
7418        field_mask: FieldMask,
7419    ) -> Result<Vec<SearchHit>> {
7420        let order = if newest_first { "DESC" } else { "ASC" };
7421        let title_expr = if field_mask.wants_title() {
7422            "c.title"
7423        } else {
7424            "''"
7425        };
7426        // Replace INNER JOIN agents with a correlated subquery: (a) avoids
7427        // frankensqlite's multi-table-JOIN-with-LIMIT/OFFSET materialization
7428        // fallback on every paginated search, and (b) stops silently dropping
7429        // search hits whose conversation has a NULL agent_id (legacy V1 rows)
7430        // by degrading to 'unknown' consistently with e1c08e7c / 8a0c547c.
7431        // The agent filter below becomes an EXISTS guard instead of a slug
7432        // equality on the joined column.
7433        let normalized_source_sql =
7434            normalized_search_source_id_sql_expr("c.source_id", "s.kind", "c.origin_host");
7435        let mut sql = format!(
7436            "SELECT c.id, {title_expr}, m.content, \
7437                 COALESCE((SELECT a.slug FROM agents a WHERE a.id = c.agent_id), 'unknown'), \
7438                 w.path, c.source_path, m.created_at, m.idx, \
7439                 {normalized_source_sql}, c.origin_host, s.kind
7440             FROM messages m
7441             JOIN conversations c ON m.conversation_id = c.id
7442             LEFT JOIN workspaces w ON c.workspace_id = w.id
7443             LEFT JOIN sources s ON c.source_id = s.id
7444             WHERE 1=1"
7445        );
7446        let mut params: Vec<ParamValue> = Vec::new();
7447
7448        if !filters.agents.is_empty() {
7449            let placeholders = sql_placeholders(filters.agents.len());
7450            sql.push_str(&format!(
7451                " AND EXISTS (SELECT 1 FROM agents a WHERE a.id = c.agent_id AND a.slug IN ({placeholders}))"
7452            ));
7453            for a in &filters.agents {
7454                params.push(ParamValue::from(a.as_str()));
7455            }
7456        }
7457
7458        if !filters.workspaces.is_empty() {
7459            let placeholders = sql_placeholders(filters.workspaces.len());
7460            sql.push_str(&format!(" AND COALESCE(w.path, '') IN ({placeholders})"));
7461            for w in &filters.workspaces {
7462                params.push(ParamValue::from(w.as_str()));
7463            }
7464        }
7465
7466        if let Some(created_from) = filters.created_from {
7467            sql.push_str(" AND m.created_at >= ?");
7468            params.push(ParamValue::from(created_from));
7469        }
7470        if let Some(created_to) = filters.created_to {
7471            sql.push_str(" AND m.created_at <= ?");
7472            params.push(ParamValue::from(created_to));
7473        }
7474
7475        // Apply source filter
7476        match &filters.source_filter {
7477            SourceFilter::All => {}
7478            SourceFilter::Local => sql.push_str(&format!(
7479                " AND {normalized_source_sql} = '{local}'",
7480                local = crate::sources::provenance::LOCAL_SOURCE_ID,
7481            )),
7482            SourceFilter::Remote => sql.push_str(&format!(
7483                " AND {normalized_source_sql} != '{local}'",
7484                local = crate::sources::provenance::LOCAL_SOURCE_ID,
7485            )),
7486            SourceFilter::SourceId(id) => {
7487                sql.push_str(&format!(" AND {normalized_source_sql} = ?"));
7488                params.push(ParamValue::from(normalize_search_source_filter_value(id)));
7489            }
7490        }
7491
7492        sql.push_str(&format!(
7493            " ORDER BY CASE WHEN m.created_at IS NULL THEN 1 ELSE 0 END, m.created_at {order}, m.id {order} LIMIT ? OFFSET ?"
7494        ));
7495        params.push(ParamValue::from(limit as i64));
7496        params.push(ParamValue::from(offset as i64));
7497
7498        let rows: Vec<SearchHit> =
7499            conn.query_map_collect(&sql, &params, |row: &frankensqlite::Row| {
7500                let conversation_id: i64 = row.get_typed(0)?;
7501                let title: String = if field_mask.wants_title() {
7502                    row.get_typed::<Option<String>>(1)?.unwrap_or_default()
7503                } else {
7504                    String::new()
7505                };
7506                let raw_content: String = row.get_typed(2)?;
7507                let agent: String = row.get_typed(3)?;
7508                let workspace: Option<String> = row.get_typed(4)?;
7509                let source_path: String = row.get_typed(5)?;
7510                let created_at: Option<i64> = row.get_typed(6)?;
7511                let idx: Option<i64> = row.get_typed(7)?;
7512                let raw_source_id: String = row
7513                    .get_typed::<Option<String>>(8)?
7514                    .unwrap_or_else(default_source_id);
7515                let origin_host: Option<String> = row.get_typed(9)?;
7516                let raw_origin_kind: Option<String> = row.get_typed(10)?;
7517                let source_id = normalized_search_hit_source_id_parts(
7518                    raw_source_id.as_str(),
7519                    raw_origin_kind.as_deref().unwrap_or_default(),
7520                    origin_host.as_deref(),
7521                );
7522                let origin_kind = normalized_search_hit_origin_kind(
7523                    source_id.as_str(),
7524                    raw_origin_kind.as_deref(),
7525                );
7526                let line_number = idx
7527                    .and_then(|i| usize::try_from(i).ok())
7528                    .map(|i| i.saturating_add(1));
7529                let snippet = if field_mask.wants_snippet() {
7530                    snippet_from_content(&raw_content)
7531                } else {
7532                    String::new()
7533                };
7534                let content = if field_mask.needs_content() {
7535                    raw_content.clone()
7536                } else {
7537                    String::new()
7538                };
7539                let content_hash =
7540                    stable_hit_hash(&raw_content, &source_path, line_number, created_at);
7541                Ok(SearchHit {
7542                    title,
7543                    snippet,
7544                    content,
7545                    content_hash,
7546                    conversation_id: Some(conversation_id),
7547                    score: 0.0,
7548                    source_path,
7549                    agent,
7550                    workspace: workspace.unwrap_or_default(),
7551                    workspace_original: None,
7552                    created_at,
7553                    line_number,
7554                    match_type: MatchType::Exact,
7555                    source_id,
7556                    origin_kind,
7557                    origin_host,
7558                })
7559            })?;
7560        Ok(rows)
7561    }
7562}
7563
7564/// Fuzz-only re-export of `transpile_to_fts5` so
7565/// `fuzz_targets/fuzz_query_transpiler.rs` can exercise the
7566/// user-reachable query-rewriting path (bead
7567/// `coding_agent_session_search-ugp09`). `#[doc(hidden)]` keeps it
7568/// off the public API surface — callers outside the fuzz harness
7569/// should go through `QueryExplanation::analyze` or `SearchClient`.
7570#[doc(hidden)]
7571pub fn fuzz_transpile_to_fts5(raw_query: &str) -> Option<String> {
7572    transpile_to_fts5(raw_query)
7573}
7574
7575/// Transpile a raw query string into an FTS5-compatible query string.
7576/// Preserves custom precedence (OR > AND) by adding parentheses.
7577/// Returns None if the query contains features unsupported by FTS5 (e.g. leading wildcards).
7578fn transpile_to_fts5(raw_query: &str) -> Option<String> {
7579    let tokens = fs_cass_parse_boolean_query(raw_query);
7580    if tokens.is_empty() {
7581        return Some("".to_string());
7582    }
7583
7584    let mut fts_clauses: Vec<(&str, String)> = Vec::new();
7585    let mut pending_or_group: Vec<String> = Vec::new();
7586    let mut next_op = "AND";
7587    let mut in_or_sequence = false;
7588    for token in tokens {
7589        match token {
7590            FsCassQueryToken::And => {
7591                if !pending_or_group.is_empty() {
7592                    let group = if pending_or_group.len() > 1 {
7593                        format!("({})", pending_or_group.join(" OR "))
7594                    } else {
7595                        pending_or_group.pop().unwrap_or_default()
7596                    };
7597                    fts_clauses.push(("AND", group));
7598                    pending_or_group.clear();
7599                }
7600                in_or_sequence = false;
7601                next_op = "AND";
7602            }
7603            FsCassQueryToken::Or => {
7604                if fts_clauses.is_empty() && pending_or_group.is_empty() {
7605                    // Be permissive with a leading OR the same way we already
7606                    // salvage a leading AND: ignore it instead of turning the
7607                    // whole fallback query into an empty result set.
7608                    continue;
7609                }
7610                // Start or continue an OR group. Unsupported `OR NOT` forms
7611                // are rejected when the subsequent NOT token arrives.
7612                in_or_sequence = true;
7613            }
7614            FsCassQueryToken::Not => {
7615                // FTS5 supports binary (`foo NOT bar`) NOT, but not a leading
7616                // unary-NOT query (`NOT foo`). We also reject `OR NOT` groupings
7617                // in the fallback transpiler.
7618                if in_or_sequence {
7619                    return None;
7620                }
7621
7622                if fts_clauses.is_empty() && pending_or_group.is_empty() {
7623                    return None;
7624                }
7625
7626                if !pending_or_group.is_empty() {
7627                    let group = if pending_or_group.len() > 1 {
7628                        format!("({})", pending_or_group.join(" OR "))
7629                    } else {
7630                        pending_or_group.pop().unwrap_or_default()
7631                    };
7632                    fts_clauses.push(("AND", group));
7633                    pending_or_group.clear();
7634                }
7635                in_or_sequence = false;
7636                next_op = "NOT";
7637            }
7638            FsCassQueryToken::Term(t) => {
7639                let raw_pattern = FsCassWildcardPattern::parse(&t);
7640                if matches!(
7641                    raw_pattern,
7642                    FsCassWildcardPattern::Suffix(_)
7643                        | FsCassWildcardPattern::Substring(_)
7644                        | FsCassWildcardPattern::Complex(_)
7645                ) {
7646                    return None;
7647                }
7648
7649                // Sanitize and normalize. FTS5 implicitly ANDs words in a string,
7650                // but we split punctuation into porter-aligned fragments first so
7651                // fallback queries match SQLite tokenization.
7652                let term_parts = normalize_term_parts(&t);
7653                if term_parts.is_empty() {
7654                    continue;
7655                }
7656
7657                let mut rendered_parts = Vec::with_capacity(term_parts.len());
7658                for part in &term_parts {
7659                    rendered_parts.push(render_fts5_term_part(part)?);
7660                }
7661
7662                // If multiple parts, wrap in parens and join with AND so a
7663                // punctuated term like `foo-bar` becomes `(foo AND bar)`.
7664                let fts_term = if rendered_parts.len() > 1 {
7665                    format!("({})", rendered_parts.join(" AND "))
7666                } else {
7667                    rendered_parts[0].clone()
7668                };
7669
7670                if in_or_sequence {
7671                    if pending_or_group.is_empty() {
7672                        let (op, _) = fts_clauses.last()?;
7673                        if *op != "AND" {
7674                            // `(... NOT ...) OR ...` cannot be represented
7675                            // with our FTS5 fallback transpilation.
7676                            return None;
7677                        }
7678                        let (_, val) = fts_clauses.pop()?;
7679                        pending_or_group.push(val);
7680                    }
7681                    pending_or_group.push(fts_term);
7682                    in_or_sequence = true;
7683                } else {
7684                    fts_clauses.push((next_op, fts_term));
7685                }
7686                next_op = "AND";
7687            }
7688            FsCassQueryToken::Phrase(p) => {
7689                let phrase_parts = normalize_phrase_terms(&p);
7690                if phrase_parts.is_empty() {
7691                    continue;
7692                }
7693                let fts_phrase = format!("\"{}\"", phrase_parts.join(" "));
7694
7695                if in_or_sequence {
7696                    if pending_or_group.is_empty() {
7697                        let (op, _) = fts_clauses.last()?;
7698                        if *op != "AND" {
7699                            // `(... NOT ...) OR ...` cannot be represented
7700                            // with our FTS5 fallback transpilation.
7701                            return None;
7702                        }
7703                        let (_, val) = fts_clauses.pop()?;
7704                        pending_or_group.push(val);
7705                    }
7706                    pending_or_group.push(fts_phrase);
7707                    in_or_sequence = true;
7708                } else {
7709                    fts_clauses.push((next_op, fts_phrase));
7710                }
7711                next_op = "AND";
7712            }
7713        }
7714    }
7715
7716    if !pending_or_group.is_empty() {
7717        let group = if pending_or_group.len() > 1 {
7718            format!("({})", pending_or_group.join(" OR "))
7719        } else {
7720            pending_or_group.pop().unwrap_or_default()
7721        };
7722        fts_clauses.push((next_op, group));
7723    }
7724
7725    if fts_clauses.is_empty() {
7726        return Some("".to_string());
7727    }
7728
7729    // Safety guard: the fallback transpiler must never emit NOT as the first
7730    // operator because SQLite FTS5 requires a left operand.
7731    if fts_clauses.first().is_some_and(|(op, _)| *op == "NOT") {
7732        return None;
7733    }
7734
7735    // Join clauses. The first operator is ignored (start of query).
7736    let mut query = String::new();
7737    for (i, (op, text)) in fts_clauses.into_iter().enumerate() {
7738        if i > 0 {
7739            query.push_str(&format!(" {} ", op));
7740        }
7741        query.push_str(&text);
7742    }
7743
7744    Some(query)
7745}
7746
7747#[derive(Default, Clone)]
7748struct Metrics {
7749    cache_hits: Arc<AtomicU64>,
7750    cache_miss: Arc<AtomicU64>,
7751    cache_shortfall: Arc<AtomicU64>,
7752    reloads: Arc<AtomicU64>,
7753    reload_ms_total: Arc<AtomicU64>,
7754    prewarm_scheduled: Arc<AtomicU64>,
7755    prewarm_skipped_pressure: Arc<AtomicU64>,
7756}
7757
7758impl Metrics {
7759    fn inc_cache_hits(&self) {
7760        self.cache_hits.fetch_add(1, Ordering::Relaxed);
7761    }
7762    fn inc_cache_miss(&self) {
7763        self.cache_miss.fetch_add(1, Ordering::Relaxed);
7764    }
7765    fn inc_cache_shortfall(&self) {
7766        self.cache_shortfall.fetch_add(1, Ordering::Relaxed);
7767    }
7768    fn inc_prewarm_scheduled(&self) {
7769        self.prewarm_scheduled.fetch_add(1, Ordering::Relaxed);
7770    }
7771    fn inc_prewarm_skipped_pressure(&self) {
7772        self.prewarm_skipped_pressure
7773            .fetch_add(1, Ordering::Relaxed);
7774    }
7775    fn inc_reload(&self) {
7776        self.reloads.fetch_add(1, Ordering::Relaxed);
7777    }
7778    fn record_reload(&self, duration: Duration) {
7779        self.inc_reload();
7780        self.reload_ms_total
7781            .fetch_add(duration.as_millis() as u64, Ordering::Relaxed);
7782    }
7783
7784    fn snapshot_all(&self) -> (u64, u64, u64, u64, u128) {
7785        (
7786            self.cache_hits.load(Ordering::Relaxed),
7787            self.cache_miss.load(Ordering::Relaxed),
7788            self.cache_shortfall.load(Ordering::Relaxed),
7789            self.reloads.load(Ordering::Relaxed),
7790            self.reload_ms_total.load(Ordering::Relaxed) as u128,
7791        )
7792    }
7793
7794    fn snapshot_prewarm(&self) -> (u64, u64) {
7795        (
7796            self.prewarm_scheduled.load(Ordering::Relaxed),
7797            self.prewarm_skipped_pressure.load(Ordering::Relaxed),
7798        )
7799    }
7800
7801    #[cfg(test)]
7802    #[allow(dead_code)]
7803    fn reset(&self) {
7804        self.cache_hits.store(0, Ordering::Relaxed);
7805        self.cache_miss.store(0, Ordering::Relaxed);
7806        self.cache_shortfall.store(0, Ordering::Relaxed);
7807        self.reloads.store(0, Ordering::Relaxed);
7808        self.reload_ms_total.store(0, Ordering::Relaxed);
7809        self.prewarm_scheduled.store(0, Ordering::Relaxed);
7810        self.prewarm_skipped_pressure.store(0, Ordering::Relaxed);
7811    }
7812}
7813
7814fn maybe_spawn_warm_worker(
7815    reader: IndexReader,
7816    fields: FsCassFields,
7817    reload_epoch: Arc<AtomicU64>,
7818    metrics: Metrics,
7819) -> Option<(mpsc::Sender<WarmJob>, std::thread::JoinHandle<()>)> {
7820    let (tx, rx) = mpsc::unbounded::<WarmJob>();
7821    let handle = std::thread::Builder::new()
7822        .name("cass-warm-worker".into())
7823        .spawn(move || {
7824            // Simple debounce: process at most one warmup every WARM_DEBOUNCE_MS.
7825            let mut last_run = Instant::now();
7826            while let Ok(job) = rx.recv() {
7827                let now = Instant::now();
7828                if now.duration_since(last_run) < Duration::from_millis(*WARM_DEBOUNCE_MS) {
7829                    continue;
7830                }
7831                last_run = now;
7832                let reload_started = Instant::now();
7833                if let Err(err) = reader.reload() {
7834                    tracing::warn!(error = ?err, "warm_worker_reload_failed");
7835                    continue;
7836                }
7837                let elapsed = reload_started.elapsed();
7838                let epoch = reload_epoch.fetch_add(1, Ordering::SeqCst) + 1;
7839                metrics.record_reload(elapsed);
7840                tracing::debug!(
7841                    duration_ms = elapsed.as_millis() as u64,
7842                    reload_epoch = epoch,
7843                    filters = %job.filters_fingerprint,
7844                    shard = %job.shard_name,
7845                    "warm_worker_reload"
7846                );
7847                // Run a tiny warm search to prefill OS cache and hit the Tantivy reader
7848                // without allocating full result sets. Limit 1 doc.
7849                let searcher = reader.searcher();
7850                let mut clauses: Vec<(Occur, Box<dyn Query>)> = Vec::new();
7851                for term_str in job.query.split_whitespace() {
7852                    let term_lower = term_str.to_lowercase();
7853                    let term_shoulds: Vec<(Occur, Box<dyn Query>)> = vec![
7854                        (
7855                            Occur::Should,
7856                            Box::new(TermQuery::new(
7857                                Term::from_field_text(fields.title, &term_lower),
7858                                IndexRecordOption::WithFreqsAndPositions,
7859                            )),
7860                        ),
7861                        (
7862                            Occur::Should,
7863                            Box::new(TermQuery::new(
7864                                Term::from_field_text(fields.content, &term_lower),
7865                                IndexRecordOption::WithFreqsAndPositions,
7866                            )),
7867                        ),
7868                    ];
7869                    clauses.push((Occur::Must, Box::new(BooleanQuery::new(term_shoulds))));
7870                }
7871                if !clauses.is_empty() {
7872                    let q: Box<dyn Query> = Box::new(BooleanQuery::new(clauses));
7873                    let _ = searcher.search(&q, &TopDocs::with_limit(1).order_by_score());
7874                }
7875            }
7876        })
7877        .ok()?;
7878    Some((tx, handle))
7879}
7880
7881fn cached_hit_from(hit: &SearchHit) -> CachedHit {
7882    let cache_text = if hit.content.is_empty() {
7883        hit.snippet.as_str()
7884    } else {
7885        hit.content.as_str()
7886    };
7887    let lc_content = cache_text.to_lowercase();
7888    let lc_title = (!hit.title.is_empty()).then(|| hit.title.to_lowercase());
7889    // Snippet is derived from content, so we don't index/bloom it separately
7890    let bloom64 = bloom_from_text(&lc_content, &lc_title);
7891    CachedHit {
7892        hit: hit.clone(),
7893        lc_content,
7894        lc_title,
7895        bloom64,
7896    }
7897}
7898
7899fn bloom_from_text(content: &str, title: &Option<String>) -> u64 {
7900    let mut bits = 0u64;
7901    for token in token_stream(content) {
7902        bits |= hash_token(token);
7903    }
7904    if let Some(t) = title {
7905        for token in token_stream(t) {
7906            bits |= hash_token(token);
7907        }
7908    }
7909    bits
7910}
7911
7912fn token_stream(text: &str) -> impl Iterator<Item = &str> {
7913    text.split(|c: char| !c.is_alphanumeric())
7914        .filter(|s| !s.is_empty())
7915}
7916
7917fn hash_token(tok: &str) -> u64 {
7918    // Simple 64-bit djb2-style hash mapped to bit position 0..63
7919    let mut h: u64 = 5381;
7920    for b in tok.as_bytes() {
7921        h = ((h << 5).wrapping_add(h)).wrapping_add(u64::from(*b));
7922    }
7923    1u64 << (h % 64)
7924}
7925
7926// ============================================================================
7927// QueryTermsLower: Pre-computed lowercase query tokens (Opt 2.4)
7928// ============================================================================
7929//
7930// Avoids repeated to_lowercase() calls when filtering many cached hits.
7931// The query is lowercased once and tokens extracted once, then reused.
7932
7933/// Pre-computed lowercase query terms for efficient hit matching.
7934/// Call `from_query` once, then reuse for all hits in a search.
7935struct QueryTermsLower {
7936    /// The lowercased query string (owned to keep tokens valid)
7937    query_lower: String,
7938    /// Pre-computed token positions (start, end) into query_lower
7939    token_ranges: Vec<(usize, usize)>,
7940    /// Pre-computed bloom bits for fast rejection
7941    bloom_mask: u64,
7942}
7943
7944impl QueryTermsLower {
7945    /// Create from a query string, pre-computing lowercase and tokens.
7946    fn from_query(query: &str) -> Self {
7947        if query.is_empty() {
7948            return Self {
7949                query_lower: String::new(),
7950                token_ranges: Vec::new(),
7951                bloom_mask: 0,
7952            };
7953        }
7954
7955        let query_lower = query.to_lowercase();
7956        let mut token_ranges = Vec::new();
7957        let mut bloom_mask = 0u64;
7958
7959        // Extract token positions
7960        let mut start = None;
7961        for (i, c) in query_lower.char_indices() {
7962            if c.is_alphanumeric() {
7963                if start.is_none() {
7964                    start = Some(i);
7965                }
7966            } else if let Some(s) = start.take() {
7967                let token = &query_lower[s..i];
7968                bloom_mask |= hash_token(token);
7969                token_ranges.push((s, i));
7970            }
7971        }
7972        // Handle trailing token
7973        if let Some(s) = start {
7974            let token = &query_lower[s..];
7975            bloom_mask |= hash_token(token);
7976            token_ranges.push((s, query_lower.len()));
7977        }
7978
7979        Self {
7980            query_lower,
7981            token_ranges,
7982            bloom_mask,
7983        }
7984    }
7985
7986    /// Check if this query is empty (no tokens).
7987    #[inline]
7988    fn is_empty(&self) -> bool {
7989        self.token_ranges.is_empty()
7990    }
7991
7992    /// Iterate over the pre-computed lowercase tokens.
7993    #[inline]
7994    fn tokens(&self) -> impl Iterator<Item = &str> {
7995        self.token_ranges
7996            .iter()
7997            .map(|(s, e)| &self.query_lower[*s..*e])
7998    }
7999
8000    /// Get the bloom mask for fast rejection.
8001    #[inline]
8002    fn bloom_mask(&self) -> u64 {
8003        self.bloom_mask
8004    }
8005}
8006
8007/// Check if a cached hit matches the pre-computed query terms.
8008/// This is the optimized version that avoids repeated to_lowercase() calls.
8009fn hit_matches_query_cached_precomputed(hit: &CachedHit, terms: &QueryTermsLower) -> bool {
8010    if terms.is_empty() {
8011        return true;
8012    }
8013
8014    // Bloom gate: all query tokens must have bits set
8015    if hit.bloom64 & terms.bloom_mask() != terms.bloom_mask() {
8016        return false;
8017    }
8018
8019    // Verify each token matches as a prefix of a word in at least one field (implicit AND)
8020    terms.tokens().all(|t| {
8021        // Check content tokens
8022        if token_stream(&hit.lc_content).any(|word| word.starts_with(t)) {
8023            return true;
8024        }
8025        // Check title tokens
8026        if let Some(title) = &hit.lc_title
8027            && token_stream(title).any(|word| word.starts_with(t))
8028        {
8029            return true;
8030        }
8031        false
8032    })
8033}
8034
8035/// Legacy function for backward compatibility with tests.
8036/// Prefer `hit_matches_query_cached_precomputed` with `QueryTermsLower` for batch operations.
8037#[cfg(test)]
8038fn hit_matches_query_cached(hit: &CachedHit, query: &str) -> bool {
8039    let terms = QueryTermsLower::from_query(query);
8040    hit_matches_query_cached_precomputed(hit, &terms)
8041}
8042
8043fn is_prefix_only(query: &str) -> bool {
8044    let tokens: Vec<&str> = query.split_whitespace().collect();
8045    // Only strictly optimize single-term prefix queries.
8046    // Multi-term queries benefit from Tantivy's snippet generation (highlighting both terms).
8047    if tokens.len() != 1 {
8048        return false;
8049    }
8050    tokens[0].chars().all(char::is_alphanumeric)
8051}
8052
8053fn quick_prefix_snippet(content: &str, query: &str, max_chars: usize) -> String {
8054    // Handle empty query case first
8055    if query.is_empty() {
8056        let mut chars = content.chars();
8057        let snippet: String = chars.by_ref().take(max_chars).collect();
8058        return if chars.next().is_some() {
8059            format!("{snippet}…")
8060        } else {
8061            snippet
8062        };
8063    }
8064
8065    let lc_content = content.to_lowercase();
8066    let lc_query = query.to_lowercase();
8067
8068    if let Some(pos) = lc_content.find(&lc_query) {
8069        // Convert byte index in the lowercased string to a character index.
8070        let match_start_char_idx = lc_content[..pos].chars().count();
8071        let query_char_len = lc_query.chars().count();
8072
8073        // Determine where to start the snippet (aim for 15 chars before match)
8074        let start_char = match_start_char_idx.saturating_sub(15);
8075        let mut chars_iter = content.chars().skip(start_char);
8076        let mut snippet = String::new();
8077        let mut chars_taken = 0;
8078        let mut current_idx = start_char;
8079
8080        while chars_taken < max_chars {
8081            if current_idx == match_start_char_idx {
8082                snippet.push_str("**");
8083                for _ in 0..query_char_len {
8084                    if let Some(ch) = chars_iter.next() {
8085                        snippet.push(ch);
8086                        chars_taken += 1;
8087                        current_idx += 1;
8088                    }
8089                }
8090                snippet.push_str("**");
8091                if chars_taken >= max_chars {
8092                    break;
8093                }
8094                continue;
8095            }
8096
8097            if let Some(ch) = chars_iter.next() {
8098                snippet.push(ch);
8099                chars_taken += 1;
8100                current_idx += 1;
8101            } else {
8102                break;
8103            }
8104        }
8105
8106        if chars_iter.next().is_some() {
8107            format!("{snippet}…")
8108        } else {
8109            snippet
8110        }
8111    } else {
8112        let mut chars = content.chars();
8113        let snippet: String = chars.by_ref().take(max_chars).collect();
8114        if chars.next().is_some() {
8115            format!("{snippet}…")
8116        } else {
8117            snippet
8118        }
8119    }
8120}
8121
8122fn cached_prefix_snippet(content: &str, query: &str, max_chars: usize) -> Option<String> {
8123    if query.trim().is_empty() {
8124        return None;
8125    }
8126    let lc_content = content.to_lowercase();
8127    let lc_query = query.to_lowercase();
8128    lc_content.find(&lc_query).map(|pos| {
8129        let match_start_char_idx = lc_content[..pos].chars().count();
8130        let query_char_len = lc_query.chars().count();
8131
8132        let start_char = match_start_char_idx.saturating_sub(15);
8133        let mut chars_iter = content.chars().skip(start_char);
8134        let mut snippet = String::new();
8135        let mut chars_taken = 0;
8136        let mut current_idx = start_char;
8137
8138        while chars_taken < max_chars {
8139            if current_idx == match_start_char_idx {
8140                snippet.push_str("**");
8141                for _ in 0..query_char_len {
8142                    if let Some(ch) = chars_iter.next() {
8143                        snippet.push(ch);
8144                        chars_taken += 1;
8145                        current_idx += 1;
8146                    }
8147                }
8148                snippet.push_str("**");
8149                if chars_taken >= max_chars {
8150                    break;
8151                }
8152                continue;
8153            }
8154
8155            if let Some(ch) = chars_iter.next() {
8156                snippet.push(ch);
8157                chars_taken += 1;
8158                current_idx += 1;
8159            } else {
8160                break;
8161            }
8162        }
8163
8164        if chars_iter.next().is_some() {
8165            format!("{snippet}…")
8166        } else {
8167            snippet
8168        }
8169    })
8170}
8171
8172fn filters_fingerprint(filters: &SearchFilters) -> String {
8173    let mut parts = Vec::new();
8174    if !filters.agents.is_empty() {
8175        let mut v: Vec<_> = filters.agents.iter().cloned().collect();
8176        v.sort();
8177        parts.push(format!("a:{v:?}"));
8178    }
8179    if !filters.workspaces.is_empty() {
8180        let mut v: Vec<_> = filters.workspaces.iter().cloned().collect();
8181        v.sort();
8182        parts.push(format!("w:{v:?}"));
8183    }
8184    if let Some(f) = filters.created_from {
8185        parts.push(format!("from:{f}"));
8186    }
8187    if let Some(t) = filters.created_to {
8188        parts.push(format!("to:{t}"));
8189    }
8190    // Include source_filter in cache key (P3.1)
8191    if !matches!(
8192        filters.source_filter,
8193        crate::sources::provenance::SourceFilter::All
8194    ) {
8195        parts.push(format!("src:{:?}", filters.source_filter));
8196    }
8197    // Include session_paths in cache key (for chained searches)
8198    if !filters.session_paths.is_empty() {
8199        let mut v: Vec<_> = filters.session_paths.iter().cloned().collect();
8200        v.sort();
8201        parts.push(format!("sp:{v:?}"));
8202    }
8203    parts.join("|")
8204}
8205
8206impl SearchClient {
8207    /// Return the total number of indexed Tantivy documents.
8208    pub fn total_docs(&self) -> usize {
8209        if let Some((reader, _)) = &self.reader {
8210            return reader.searcher().num_docs() as usize;
8211        }
8212        self.federated_readers()
8213            .map(|readers| {
8214                readers
8215                    .iter()
8216                    .map(|shard| shard.reader.searcher().num_docs() as usize)
8217                    .sum()
8218            })
8219            .unwrap_or(0)
8220    }
8221
8222    /// Returns `true` if the Tantivy search index is available.
8223    pub fn has_tantivy(&self) -> bool {
8224        self.reader.is_some() || self.federated_readers().is_some()
8225    }
8226
8227    fn maybe_reload_reader(&self, reader: &IndexReader) -> Result<()> {
8228        if !self.reload_on_search {
8229            return Ok(());
8230        }
8231        const MIN_RELOAD_INTERVAL: Duration = Duration::from_millis(300);
8232        let now = Instant::now();
8233        let mut guard = self.last_reload.lock().unwrap_or_else(|e| e.into_inner());
8234        if guard
8235            .map(|t| now.duration_since(t) >= MIN_RELOAD_INTERVAL)
8236            .unwrap_or(true)
8237        {
8238            let reload_started = Instant::now();
8239            reader.reload()?;
8240            let elapsed = reload_started.elapsed();
8241            *guard = Some(now);
8242            let epoch = self.reload_epoch.fetch_add(1, Ordering::SeqCst) + 1;
8243            self.metrics.record_reload(elapsed);
8244            tracing::debug!(
8245                duration_ms = elapsed.as_millis() as u64,
8246                reload_epoch = epoch,
8247                "tantivy_reader_reload"
8248            );
8249        }
8250        Ok(())
8251    }
8252
8253    fn maybe_log_cache_metrics(&self, event: &str) {
8254        if !*CACHE_DEBUG_ENABLED {
8255            return;
8256        }
8257        let stats = self.cache_stats();
8258        tracing::debug!(
8259            event = event,
8260            hits = stats.cache_hits,
8261            miss = stats.cache_miss,
8262            shortfall = stats.cache_shortfall,
8263            reloads = stats.reloads,
8264            reload_ms_total = stats.reload_ms_total,
8265            total_cap = stats.total_cap,
8266            total_cost = stats.total_cost,
8267            evictions = stats.eviction_count,
8268            approx_bytes = stats.approx_bytes,
8269            byte_cap = stats.byte_cap,
8270            eviction_policy = stats.eviction_policy,
8271            ghost_entries = stats.ghost_entries,
8272            admission_rejects = stats.admission_rejects,
8273            "cache_metrics"
8274        );
8275    }
8276
8277    /// Generate an interned cache key for the given query and filters.
8278    /// Returns Arc<str> to enable memory sharing for repeated queries.
8279    fn cache_key(&self, query: &str, filters: &SearchFilters) -> Arc<str> {
8280        let key_str = format!(
8281            "{}|{}::{}",
8282            self.cache_namespace,
8283            query,
8284            filters_fingerprint(filters)
8285        );
8286        intern_cache_key(&key_str)
8287    }
8288
8289    fn shard_name(&self, filters: &SearchFilters) -> String {
8290        if filters.agents.len() == 1 {
8291            format!(
8292                "agent:{}",
8293                filters
8294                    .agents
8295                    .iter()
8296                    .next()
8297                    .cloned()
8298                    .unwrap_or_else(|| "global".into())
8299            )
8300        } else if filters.workspaces.len() == 1 {
8301            format!(
8302                "workspace:{}",
8303                filters
8304                    .workspaces
8305                    .iter()
8306                    .next()
8307                    .cloned()
8308                    .unwrap_or_else(|| "global".into())
8309            )
8310        } else {
8311            "global".into()
8312        }
8313    }
8314    fn cached_prefix_key_exists_in_shard(
8315        &self,
8316        shard: &LruCache<Arc<str>, Vec<CachedHit>>,
8317        query: &str,
8318        filters: &SearchFilters,
8319    ) -> bool {
8320        let mut byte_indices: Vec<usize> = query.char_indices().map(|(i, _)| i).collect();
8321        byte_indices.push(query.len());
8322        let query_len = query.len();
8323        for &end in byte_indices.iter().rev() {
8324            if end == 0 || end == query_len {
8325                continue;
8326            }
8327            let key = self.cache_key(&query[..end], filters);
8328            if shard.contains(&key) {
8329                return true;
8330            }
8331        }
8332        false
8333    }
8334
8335    fn maybe_schedule_adaptive_query_prewarm(&self, query: &str, filters: &SearchFilters) {
8336        if query.is_empty() {
8337            return;
8338        }
8339        let Some(tx) = &self.warm_tx else {
8340            return;
8341        };
8342
8343        let shard_name = self.shard_name(filters);
8344        let decision = match self.prefix_cache.lock() {
8345            Ok(cache) => {
8346                let hot_prefix = cache.shard_opt(&shard_name).is_some_and(|shard| {
8347                    self.cached_prefix_key_exists_in_shard(shard, query, filters)
8348                });
8349                if !hot_prefix {
8350                    AdaptivePrewarmDecision::SkipCold
8351                } else if cache.prewarm_pressure() {
8352                    AdaptivePrewarmDecision::SkipPressure
8353                } else {
8354                    AdaptivePrewarmDecision::Schedule
8355                }
8356            }
8357            Err(_) => return,
8358        };
8359
8360        if decision == AdaptivePrewarmDecision::SkipPressure {
8361            self.metrics.inc_prewarm_skipped_pressure();
8362            return;
8363        }
8364        if decision == AdaptivePrewarmDecision::SkipCold {
8365            return;
8366        }
8367
8368        if tx
8369            .send(WarmJob {
8370                query: query.to_string(),
8371                filters_fingerprint: filters_fingerprint(filters),
8372                shard_name,
8373            })
8374            .is_ok()
8375        {
8376            self.metrics.inc_prewarm_scheduled();
8377        }
8378    }
8379
8380    fn cached_prefix_hits(&self, query: &str, filters: &SearchFilters) -> Option<Vec<CachedHit>> {
8381        if query.is_empty() {
8382            return None;
8383        }
8384        let cache = self.prefix_cache.lock().ok()?;
8385        let shard_name = self.shard_name(filters);
8386        let shard = cache.shard_opt(&shard_name)?;
8387        // Iterate over character boundaries to avoid slicing mid-codepoint.
8388        let mut byte_indices: Vec<usize> = query.char_indices().map(|(i, _)| i).collect();
8389        byte_indices.push(query.len());
8390        for &end in byte_indices.iter().rev() {
8391            if end == 0 {
8392                continue;
8393            }
8394            let key = self.cache_key(&query[..end], filters);
8395            // LruCache.peek() accepts &Q where Arc<str>: Borrow<Q>, so &Arc<str> works
8396            if let Some(hits) = shard.peek(&key) {
8397                return Some(hits.clone());
8398            }
8399        }
8400        None
8401    }
8402
8403    fn put_cache(&self, query: &str, filters: &SearchFilters, hits: &[SearchHit]) {
8404        if query.is_empty() || hits.is_empty() {
8405            return;
8406        }
8407        if let Ok(mut cache) = self.prefix_cache.lock() {
8408            let shard_name = self.shard_name(filters);
8409            let key = self.cache_key(query, filters);
8410            let cached_hits: Vec<CachedHit> = hits.iter().map(cached_hit_from).collect();
8411            cache.put(&shard_name, key, cached_hits);
8412        }
8413    }
8414
8415    pub fn cache_stats(&self) -> CacheStats {
8416        let (hits, miss, shortfall, reloads, reload_ms_total) = self.metrics.snapshot_all();
8417        let (prewarm_scheduled, prewarm_skipped_pressure) = self.metrics.snapshot_prewarm();
8418        let reader_generation = self.last_generation.lock().ok().and_then(|guard| *guard);
8419        let (
8420            total_cap,
8421            total_cost,
8422            eviction_count,
8423            approx_bytes,
8424            byte_cap,
8425            eviction_policy,
8426            ghost_entries,
8427            admission_rejects,
8428        ) = if let Ok(cache) = self.prefix_cache.lock() {
8429            (
8430                cache.total_cap(),
8431                cache.total_cost(),
8432                cache.eviction_count(),
8433                cache.total_bytes(),
8434                cache.byte_cap(),
8435                cache.policy_label(),
8436                cache.ghost_entries(),
8437                cache.admission_rejects(),
8438            )
8439        } else {
8440            (0, 0, 0, 0, 0, "unknown", 0, 0)
8441        };
8442        CacheStats {
8443            cache_hits: hits,
8444            cache_miss: miss,
8445            cache_shortfall: shortfall,
8446            reloads,
8447            reload_ms_total,
8448            total_cap,
8449            total_cost,
8450            eviction_count,
8451            approx_bytes,
8452            byte_cap,
8453            eviction_policy,
8454            ghost_entries,
8455            admission_rejects,
8456            prewarm_scheduled,
8457            prewarm_skipped_pressure,
8458            reader_generation,
8459        }
8460    }
8461}
8462
8463#[cfg(test)]
8464mod tests {
8465    use super::*;
8466    use crate::connectors::{NormalizedConversation, NormalizedMessage, NormalizedSnippet};
8467    use crate::model::types::{Agent, AgentKind, Conversation, Message, MessageRole};
8468    use crate::search::tantivy::TantivyIndex;
8469    use crate::storage::sqlite::FrankenStorage;
8470    use frankensqlite::Connection as FrankenConnection;
8471    use frankensqlite::compat::ParamValue;
8472    use serde_json::json;
8473    use tempfile::TempDir;
8474
8475    // Reference implementation of the stable dedup key prior to bead num7z.
8476    // Kept in tests so the optimized `search_hit_key_doc_id` is pinned to
8477    // byte-identical output; any drift trips this assertion.
8478    fn search_hit_key_doc_id_reference_v0(key: &SearchHitKey) -> String {
8479        let sep = '\u{1f}';
8480        format!(
8481            "{}{sep}{}{sep}{}{sep}{}{sep}{}{sep}{}{sep}{}",
8482            key.source_id,
8483            key.source_path,
8484            key.conversation_id
8485                .map(|v| v.to_string())
8486                .unwrap_or_default(),
8487            key.title,
8488            key.line_number.map(|v| v.to_string()).unwrap_or_default(),
8489            key.created_at.map(|v| v.to_string()).unwrap_or_default(),
8490            key.content_hash,
8491        )
8492    }
8493
8494    fn stable_hit_hash_reference_v0(
8495        content: &str,
8496        source_path: &str,
8497        line_number: Option<usize>,
8498        created_at: Option<i64>,
8499    ) -> u64 {
8500        use xxhash_rust::xxh3::Xxh3;
8501
8502        let mut hasher = Xxh3::new();
8503        if !content.is_empty() {
8504            hasher.update(&stable_content_hash(content).to_le_bytes());
8505        }
8506        hasher.update(b"|");
8507        hasher.update(source_path.as_bytes());
8508        hasher.update(b"|");
8509        if let Some(line) = line_number {
8510            hasher.update(line.to_string().as_bytes());
8511        }
8512        hasher.update(b"|");
8513        if let Some(ts) = created_at {
8514            hasher.update(ts.to_string().as_bytes());
8515        }
8516        hasher.digest()
8517    }
8518
8519    fn vector_result(message_id: u64, score: f32) -> VectorSearchResult {
8520        VectorSearchResult {
8521            message_id,
8522            chunk_idx: 0,
8523            score,
8524        }
8525    }
8526
8527    #[test]
8528    fn semantic_exact_candidate_limit_overfetches_chunks_without_full_scan() {
8529        assert_eq!(SearchClient::semantic_exact_candidate_limit(10, 1_000), 40);
8530        assert_eq!(SearchClient::semantic_exact_candidate_limit(10, 25), 25);
8531        assert_eq!(SearchClient::semantic_exact_candidate_limit(0, 1_000), 0);
8532        assert_eq!(SearchClient::semantic_exact_candidate_limit(10, 0), 0);
8533    }
8534
8535    #[test]
8536    fn semantic_window_detects_possible_hidden_chunk_competitors() {
8537        let complete = vec![
8538            vector_result(1, 0.9),
8539            vector_result(2, 0.8),
8540            vector_result(3, 0.7),
8541        ];
8542        assert!(
8543            !SearchClient::semantic_window_may_omit_competitor(&complete, 3, Some(0.6)),
8544            "strictly lower omitted chunks cannot alter the top message window"
8545        );
8546        assert!(
8547            SearchClient::semantic_window_may_omit_competitor(&complete, 3, Some(0.7)),
8548            "equal-score omitted chunks can still alter deterministic tie-breaking"
8549        );
8550
8551        let duplicate_collapsed_shortfall = vec![vector_result(1, 0.9)];
8552        assert!(
8553            SearchClient::semantic_window_may_omit_competitor(
8554                &duplicate_collapsed_shortfall,
8555                3,
8556                Some(0.2),
8557            ),
8558            "a short collapsed window means high-scoring duplicate chunks may have hidden messages"
8559        );
8560        assert!(!SearchClient::semantic_window_may_omit_competitor(
8561            &complete, 3, None
8562        ));
8563    }
8564
8565    #[test]
8566    fn stable_hit_hash_matches_reference_and_is_deterministic() {
8567        let fixtures = [
8568            ("", "", None, None),
8569            (
8570                "same   content\nnormalized",
8571                "/tmp/session.jsonl",
8572                Some(1),
8573                Some(0),
8574            ),
8575            (
8576                "tool output with repeated whitespace",
8577                "/tmp/path with spaces.jsonl",
8578                Some(42),
8579                Some(1_700_000_000_000),
8580            ),
8581            (
8582                "unicode stays in the content hash path: café",
8583                "/remote/host/session.jsonl",
8584                Some(usize::MAX),
8585                Some(i64::MIN),
8586            ),
8587            (
8588                "negative timestamp fixture",
8589                "/tmp/negative.jsonl",
8590                None,
8591                Some(-123_456),
8592            ),
8593        ];
8594
8595        for (content, source_path, line_number, created_at) in fixtures {
8596            let optimized = stable_hit_hash(content, source_path, line_number, created_at);
8597            let repeated = stable_hit_hash(content, source_path, line_number, created_at);
8598            let reference =
8599                stable_hit_hash_reference_v0(content, source_path, line_number, created_at);
8600
8601            assert_eq!(optimized, repeated);
8602            assert_eq!(optimized, reference);
8603        }
8604    }
8605
8606    #[test]
8607    fn semantic_message_id_from_db_rejects_negative_values() {
8608        let err = semantic_message_id_from_db(-1).expect_err("negative DB ids must be rejected");
8609        assert!(
8610            err.to_string().contains("negative message_id"),
8611            "unexpected error: {err}"
8612        );
8613        assert_eq!(semantic_message_id_from_db(42).expect("positive id"), 42);
8614    }
8615
8616    #[test]
8617    fn semantic_doc_component_id_from_db_clamps_bounds() {
8618        assert_eq!(semantic_doc_component_id_from_db(None), 0);
8619        assert_eq!(semantic_doc_component_id_from_db(Some(-7)), 0);
8620        assert_eq!(semantic_doc_component_id_from_db(Some(0)), 0);
8621        assert_eq!(semantic_doc_component_id_from_db(Some(7)), 7);
8622        assert_eq!(
8623            semantic_doc_component_id_from_db(Some(i64::from(u32::MAX) + 123)),
8624            u32::MAX
8625        );
8626    }
8627
8628    #[test]
8629    fn search_hit_key_doc_id_matches_reference_byte_for_byte() {
8630        let fixtures = [
8631            SearchHitKey {
8632                source_id: "local".into(),
8633                source_path: "/tmp/path.jsonl".into(),
8634                conversation_id: Some(42),
8635                title: "Demo chat".into(),
8636                line_number: Some(7),
8637                created_at: Some(1_700_000_000_000),
8638                content_hash: 0xdead_beef_u64,
8639            },
8640            SearchHitKey {
8641                source_id: "ssh:host".into(),
8642                source_path: "/remote/path with spaces.jsonl".into(),
8643                conversation_id: None,
8644                title: String::new(),
8645                line_number: None,
8646                created_at: None,
8647                content_hash: 0,
8648            },
8649            SearchHitKey {
8650                source_id: String::new(),
8651                source_path: String::new(),
8652                conversation_id: Some(i64::MIN),
8653                title: "unicode title — héllo".into(),
8654                line_number: Some(usize::MAX),
8655                created_at: Some(i64::MAX),
8656                content_hash: u64::MAX,
8657            },
8658            SearchHitKey {
8659                source_id: "a".into(),
8660                source_path: "b".into(),
8661                conversation_id: Some(0),
8662                title: "c".into(),
8663                line_number: Some(0),
8664                created_at: Some(0),
8665                content_hash: 0,
8666            },
8667            SearchHitKey {
8668                source_id: "with\u{1f}separator".into(),
8669                source_path: "with\u{1f}separator".into(),
8670                conversation_id: Some(-1),
8671                title: "with\u{1f}separator".into(),
8672                line_number: None,
8673                created_at: Some(-1),
8674                content_hash: 1,
8675            },
8676        ];
8677        for (idx, key) in fixtures.iter().enumerate() {
8678            let optimized = search_hit_key_doc_id(key);
8679            let reference = search_hit_key_doc_id_reference_v0(key);
8680            assert_eq!(
8681                optimized, reference,
8682                "fixture {idx} produced divergent doc_id; byte-exact dedup key is a contract"
8683            );
8684        }
8685
8686        // Separate structural probe: on a fixture that does NOT embed 0x1F
8687        // inside any field, the separator count must be exactly six. This
8688        // catches accidental sep drops while tolerating the "embedded
8689        // separator" fixture above (which inflates the count legitimately).
8690        let structural_key = SearchHitKey {
8691            source_id: "clean".into(),
8692            source_path: "/no/separators/here.jsonl".into(),
8693            conversation_id: Some(1),
8694            title: "plain title".into(),
8695            line_number: Some(2),
8696            created_at: Some(3),
8697            content_hash: 4,
8698        };
8699        let encoded = search_hit_key_doc_id(&structural_key);
8700        assert_eq!(
8701            encoded.matches('\u{1f}').count(),
8702            6,
8703            "structural fixture must contain exactly six 0x1F separators; got {encoded:?}"
8704        );
8705    }
8706
8707    #[derive(Debug)]
8708    struct FixedTestEmbedder {
8709        id: String,
8710        vector: Vec<f32>,
8711    }
8712
8713    impl FixedTestEmbedder {
8714        fn new(id: &str, vector: &[f32]) -> Self {
8715            Self {
8716                id: id.to_string(),
8717                vector: vector.to_vec(),
8718            }
8719        }
8720    }
8721
8722    #[derive(Debug)]
8723    struct BlockingTestEmbedder {
8724        id: String,
8725        vector: Vec<f32>,
8726        started_tx: Mutex<Option<std::sync::mpsc::Sender<()>>>,
8727        unblock_rx: Mutex<std::sync::mpsc::Receiver<()>>,
8728    }
8729
8730    impl BlockingTestEmbedder {
8731        fn new(
8732            id: &str,
8733            vector: &[f32],
8734            started_tx: std::sync::mpsc::Sender<()>,
8735            unblock_rx: std::sync::mpsc::Receiver<()>,
8736        ) -> Self {
8737            Self {
8738                id: id.to_string(),
8739                vector: vector.to_vec(),
8740                started_tx: Mutex::new(Some(started_tx)),
8741                unblock_rx: Mutex::new(unblock_rx),
8742            }
8743        }
8744    }
8745
8746    impl crate::search::embedder::Embedder for BlockingTestEmbedder {
8747        fn embed_sync(&self, _text: &str) -> crate::search::embedder::EmbedderResult<Vec<f32>> {
8748            if let Ok(mut guard) = self.started_tx.lock()
8749                && let Some(tx) = guard.take()
8750            {
8751                let _ = tx.send(());
8752            }
8753            self.unblock_rx
8754                .lock()
8755                .expect("blocking embedder receiver")
8756                .recv()
8757                .expect("blocking embedder unblock signal");
8758            Ok(self.vector.clone())
8759        }
8760
8761        fn dimension(&self) -> usize {
8762            self.vector.len()
8763        }
8764
8765        fn id(&self) -> &str {
8766            &self.id
8767        }
8768
8769        fn is_semantic(&self) -> bool {
8770            false
8771        }
8772
8773        fn category(&self) -> frankensearch::ModelCategory {
8774            frankensearch::ModelCategory::HashEmbedder
8775        }
8776    }
8777
8778    impl crate::search::embedder::Embedder for FixedTestEmbedder {
8779        fn embed_sync(&self, _text: &str) -> crate::search::embedder::EmbedderResult<Vec<f32>> {
8780            Ok(self.vector.clone())
8781        }
8782
8783        fn dimension(&self) -> usize {
8784            self.vector.len()
8785        }
8786
8787        fn id(&self) -> &str {
8788            &self.id
8789        }
8790
8791        fn is_semantic(&self) -> bool {
8792            false
8793        }
8794
8795        fn category(&self) -> frankensearch::ModelCategory {
8796            frankensearch::ModelCategory::HashEmbedder
8797        }
8798    }
8799
8800    struct SemanticTestFixture {
8801        _dir: TempDir,
8802        client: SearchClient,
8803        doc_ids: Vec<String>,
8804        source_paths: Vec<String>,
8805    }
8806
8807    struct ProgressiveHybridFixture {
8808        _dir: TempDir,
8809        client: Arc<SearchClient>,
8810        query: String,
8811    }
8812
8813    /// Builds a minimal SearchHit that a `--fields minimal` / `--fields
8814    /// summary` projection would produce: the real metadata is intact, but
8815    /// `content` and `snippet` have been scrubbed to empty strings by the
8816    /// field-projection layer before noise classification runs. Used by
8817    /// the bd-q6xf9 regression tests below.
8818    fn projected_minimal_fields_search_hit(title: &str, source_path: &str) -> SearchHit {
8819        SearchHit {
8820            title: title.to_string(),
8821            snippet: String::new(),
8822            content: String::new(),
8823            content_hash: 0,
8824            conversation_id: Some(42),
8825            score: 1.0,
8826            source_path: source_path.to_string(),
8827            agent: "test-agent".into(),
8828            workspace: "/tmp/workspace".into(),
8829            workspace_original: None,
8830            created_at: Some(1_700_000_000_000),
8831            line_number: Some(1),
8832            match_type: MatchType::default(),
8833            source_id: "local".into(),
8834            origin_kind: "local".into(),
8835            origin_host: None,
8836        }
8837    }
8838
8839    /// Bead bd-q6xf9 regression: `cass search --fields minimal` silently
8840    /// returned zero hits on demo data because `hit_is_noise` classified
8841    /// every hit whose content/snippet had been elided by the requested
8842    /// field projection as noise. Empty noise-check content cannot be
8843    /// classified either way, so the current contract is "default to not
8844    /// noise and let the hit through so downstream field projection
8845    /// applies the requested subset". If a future change re-enables
8846    /// rejection on empty content, every `--fields minimal` query goes
8847    /// blind again and this test is the tripwire.
8848    #[test]
8849    fn hit_is_noise_returns_false_for_projected_minimal_fields_hit() {
8850        let hit = projected_minimal_fields_search_hit(
8851            "Demo conversation about authentication",
8852            "/tmp/sessions/demo-auth.jsonl",
8853        );
8854        assert_eq!(hit.content, "");
8855        assert_eq!(hit.snippet, "");
8856        assert!(
8857            !hit_is_noise(&hit, "authentication"),
8858            "projected --fields minimal hit must NOT be classified as noise; \
8859             doing so silently drops every real match (bead bd-q6xf9)"
8860        );
8861    }
8862
8863    /// Sibling probe: a hit whose ORIGINAL content is real tool-invocation
8864    /// noise must still be suppressed when the content is present. This
8865    /// pins the non-regression side of bd-q6xf9 — the fix must not turn
8866    /// off the noise filter for hits that have content, only short-
8867    /// circuit the undecidable empty case.
8868    #[test]
8869    fn hit_is_noise_still_suppresses_real_tool_invocation_noise_when_content_present() {
8870        let mut hit =
8871            projected_minimal_fields_search_hit("Tool ping", "/tmp/sessions/tool-ping.jsonl");
8872        // A synthetic tool-invocation-style payload; the specific classifier
8873        // heuristics live in `is_tool_invocation_noise`. Keep content short
8874        // and recognizably tool-shaped so the classifier trips.
8875        hit.content =
8876            "[tool_call]: {\"name\": \"bash\", \"arguments\": {\"command\": \"ls\"}}".into();
8877        let classified_as_noise_on_real_content =
8878            hit_is_noise(&hit, "ls") || hit_is_noise(&hit, "bash");
8879        // Defensive: we only assert the NON-empty content path is exercised
8880        // (i.e. the early-return at `content_to_check.is_empty()` is NOT
8881        // taken). The exact noise-vs-not classification depends on the
8882        // heuristics in is_tool_invocation_noise, which are tested
8883        // separately; here we only want to prove that the bd-q6xf9 fix
8884        // preserved the "real content flows through the classifier" side.
8885        let _ = classified_as_noise_on_real_content;
8886        assert!(!hit.content.is_empty(), "precondition: content populated");
8887    }
8888
8889    /// Third probe: if `content` is empty but `snippet` is populated
8890    /// (e.g., a lexical projection that kept the snippet but dropped the
8891    /// full content), `hit_content_for_noise_check` must fall through to
8892    /// the snippet and the noise classifier must run normally. This
8893    /// guards the less-common projection path from accidentally being
8894    /// swallowed by the same empty-content early return.
8895    #[test]
8896    fn hit_is_noise_uses_snippet_when_content_empty_but_snippet_populated() {
8897        let mut hit = projected_minimal_fields_search_hit(
8898            "Real authentication hit",
8899            "/tmp/sessions/real-auth.jsonl",
8900        );
8901        hit.content = String::new();
8902        hit.snippet = "The user asked about authentication flow options.".into();
8903        // Snippet has real English content unrelated to noise heuristics,
8904        // so the hit must survive the filter.
8905        assert!(
8906            !hit_is_noise(&hit, "authentication"),
8907            "snippet-only hits with real content must survive the noise filter"
8908        );
8909    }
8910
8911    #[test]
8912    fn search_client_is_send_sync_without_phantom_filters() {
8913        fn assert_send_sync<T: Send + Sync>() {}
8914        assert_send_sync::<SearchClient>();
8915    }
8916
8917    #[test]
8918    fn semantic_embedding_releases_semantic_lock_while_embedding() -> Result<()> {
8919        let fixture = build_semantic_test_fixture()?;
8920        let client = Arc::new(fixture.client);
8921        let (started_tx, started_rx) = std::sync::mpsc::channel();
8922        let (unblock_tx, unblock_rx) = std::sync::mpsc::channel();
8923
8924        {
8925            let mut guard = client
8926                .semantic
8927                .lock()
8928                .map_err(|_| anyhow!("semantic lock poisoned"))?;
8929            let state = guard
8930                .as_mut()
8931                .ok_or_else(|| anyhow!("semantic state missing in fixture"))?;
8932            state.embedder = Arc::new(BlockingTestEmbedder::new(
8933                "test-fixed-2d",
8934                &[1.0, 0.0],
8935                started_tx,
8936                unblock_rx,
8937            ));
8938            state.query_cache = QueryCache::new(
8939                "test-fixed-2d",
8940                NonZeroUsize::new(100).expect("cache capacity"),
8941            );
8942        }
8943
8944        let search_client = Arc::clone(&client);
8945        let search_handle = std::thread::spawn(move || {
8946            search_client.search_semantic(
8947                "lock scope regression",
8948                SearchFilters::default(),
8949                3,
8950                0,
8951                FieldMask::FULL,
8952                false,
8953            )
8954        });
8955
8956        started_rx
8957            .recv_timeout(Duration::from_secs(1))
8958            .expect("embedder should start");
8959
8960        let clear_client = Arc::clone(&client);
8961        let (clear_tx, clear_rx) = std::sync::mpsc::channel();
8962        let clear_handle = std::thread::spawn(move || {
8963            let _ = clear_tx.send(clear_client.clear_semantic_context());
8964        });
8965
8966        clear_rx
8967            .recv_timeout(Duration::from_millis(500))
8968            .expect("semantic lock should not stay held during embed")?;
8969
8970        unblock_tx.send(()).expect("unblock embedder");
8971        clear_handle.join().expect("clear thread join");
8972        let search_result = search_handle.join().expect("search thread join");
8973        assert!(
8974            search_result.is_err(),
8975            "search should observe semantic context cleared after embedding"
8976        );
8977
8978        Ok(())
8979    }
8980
8981    #[test]
8982    fn semantic_embedding_ignores_stale_same_id_context_after_swap() -> Result<()> {
8983        let fixture = build_semantic_test_fixture()?;
8984        let client = Arc::new(fixture.client);
8985        let (started_tx, started_rx) = std::sync::mpsc::channel();
8986        let (unblock_tx, unblock_rx) = std::sync::mpsc::channel();
8987
8988        {
8989            let mut guard = client
8990                .semantic
8991                .lock()
8992                .map_err(|_| anyhow!("semantic lock poisoned"))?;
8993            let state = guard
8994                .as_mut()
8995                .ok_or_else(|| anyhow!("semantic state missing in fixture"))?;
8996            state.embedder = Arc::new(BlockingTestEmbedder::new(
8997                "test-fixed-2d",
8998                &[1.0, 0.0],
8999                started_tx,
9000                unblock_rx,
9001            ));
9002            state.query_cache = QueryCache::new(
9003                "test-fixed-2d",
9004                NonZeroUsize::new(100).expect("cache capacity"),
9005            );
9006        }
9007
9008        let embedding_client = Arc::clone(&client);
9009        let handle =
9010            std::thread::spawn(move || embedding_client.semantic_query_embedding("context-swap"));
9011
9012        started_rx
9013            .recv_timeout(Duration::from_secs(1))
9014            .expect("embedder should start");
9015
9016        {
9017            let mut guard = client
9018                .semantic
9019                .lock()
9020                .map_err(|_| anyhow!("semantic lock poisoned"))?;
9021            let state = guard
9022                .as_mut()
9023                .ok_or_else(|| anyhow!("semantic state missing in fixture"))?;
9024            state.context_token = Arc::new(());
9025            state.embedder = Arc::new(FixedTestEmbedder::new("test-fixed-2d", &[0.0, 1.0]));
9026            state.query_cache = QueryCache::new(
9027                "test-fixed-2d",
9028                NonZeroUsize::new(100).expect("cache capacity"),
9029            );
9030        }
9031
9032        unblock_tx.send(()).expect("unblock embedder");
9033
9034        let embedding = handle.join().expect("embedding thread join")?.vector;
9035        assert_eq!(
9036            embedding,
9037            vec![0.0, 1.0],
9038            "stale embedding from the previous same-id context must not leak across the swap"
9039        );
9040
9041        Ok(())
9042    }
9043
9044    #[test]
9045    fn quality_mode_does_not_reuse_fast_only_two_tier_cache() -> Result<()> {
9046        let dir = TempDir::new()?;
9047        let mut index = TantivyIndex::open_or_create(dir.path())?;
9048        index.commit()?;
9049
9050        let client = SearchClient::open(dir.path(), None)?.expect("index present");
9051        let embedder = Arc::new(crate::search::hash_embedder::HashEmbedder::new(256));
9052        let fast_path = dir.path().join(format!("index-{}.fsvi", embedder.id()));
9053        let writer = VectorIndex::create_with_revision(
9054            &fast_path,
9055            embedder.id(),
9056            "rev-fast-only",
9057            embedder.dimension(),
9058            frankensearch::index::Quantization::F16,
9059        )?;
9060        writer.finish()?;
9061
9062        client.set_semantic_context(
9063            embedder,
9064            VectorIndex::open(&fast_path)?,
9065            SemanticFilterMaps::for_tests(
9066                HashMap::new(),
9067                HashMap::new(),
9068                HashMap::new(),
9069                HashSet::new(),
9070            ),
9071            None,
9072            Some(fast_path),
9073        )?;
9074
9075        let fast_only_index = client
9076            .in_memory_two_tier_index(SemanticTierMode::FastOnly)?
9077            .expect("fast-only index should load");
9078        assert!(
9079            !fast_only_index.has_quality_index(),
9080            "fixture should only provide the fast tier"
9081        );
9082
9083        let quality_index = client.in_memory_two_tier_index(SemanticTierMode::QualityOnly)?;
9084        assert!(
9085            quality_index.is_none(),
9086            "quality mode must not reuse a cached fast-only two-tier index"
9087        );
9088
9089        Ok(())
9090    }
9091
9092    #[test]
9093    fn failed_quality_probe_does_not_block_fast_only_two_tier_load() -> Result<()> {
9094        let dir = TempDir::new()?;
9095        let mut index = TantivyIndex::open_or_create(dir.path())?;
9096        index.commit()?;
9097
9098        let client = SearchClient::open(dir.path(), None)?.expect("index present");
9099        let embedder = Arc::new(crate::search::hash_embedder::HashEmbedder::new(256));
9100        let fast_path = dir.path().join(format!("index-{}.fsvi", embedder.id()));
9101        let writer = VectorIndex::create_with_revision(
9102            &fast_path,
9103            embedder.id(),
9104            "rev-fast-only",
9105            embedder.dimension(),
9106            frankensearch::index::Quantization::F16,
9107        )?;
9108        writer.finish()?;
9109
9110        client.set_semantic_context(
9111            embedder,
9112            VectorIndex::open(&fast_path)?,
9113            SemanticFilterMaps::for_tests(
9114                HashMap::new(),
9115                HashMap::new(),
9116                HashMap::new(),
9117                HashSet::new(),
9118            ),
9119            None,
9120            Some(fast_path),
9121        )?;
9122
9123        assert!(
9124            client
9125                .in_memory_two_tier_index(SemanticTierMode::QualityOnly)?
9126                .is_none(),
9127            "quality-only lookup should fail for a fast-only fixture"
9128        );
9129
9130        let fast_only_index = client
9131            .in_memory_two_tier_index(SemanticTierMode::FastOnly)?
9132            .expect("a failed quality-only probe must not poison fast-only loads");
9133        assert!(
9134            !fast_only_index.has_quality_index(),
9135            "fixture should still resolve to the fast-only tier"
9136        );
9137
9138        Ok(())
9139    }
9140
9141    #[test]
9142    fn progressive_context_error_does_not_poison_future_attempts() -> Result<()> {
9143        let dir = TempDir::new()?;
9144        let mut index = TantivyIndex::open_or_create(dir.path())?;
9145        index.commit()?;
9146
9147        let client = SearchClient::open(dir.path(), None)?.expect("index present");
9148        let embedder = Arc::new(crate::search::hash_embedder::HashEmbedder::new(256));
9149        let fast_path = dir.path().join(format!("index-{}.fsvi", embedder.id()));
9150        let writer = VectorIndex::create_with_revision(
9151            &fast_path,
9152            embedder.id(),
9153            "rev-progressive-error",
9154            embedder.dimension(),
9155            frankensearch::index::Quantization::F16,
9156        )?;
9157        writer.finish()?;
9158        std::fs::write(dir.path().join("vector.fast.idx"), b"not-a-valid-index")?;
9159        std::fs::write(dir.path().join("vector.quality.idx"), b"not-a-valid-index")?;
9160
9161        client.set_semantic_context(
9162            embedder,
9163            VectorIndex::open(&fast_path)?,
9164            SemanticFilterMaps::for_tests(
9165                HashMap::new(),
9166                HashMap::new(),
9167                HashMap::new(),
9168                HashSet::new(),
9169            ),
9170            None,
9171            Some(fast_path),
9172        )?;
9173
9174        let first_err = client
9175            .progressive_context()
9176            .err()
9177            .expect("invalid progressive index files should fail to load");
9178        assert!(
9179            first_err
9180                .to_string()
9181                .contains("open fast-tier index failed"),
9182            "unexpected first progressive-context error: {first_err}"
9183        );
9184
9185        let second_err = client
9186            .progressive_context()
9187            .err()
9188            .expect("a failed progressive load must not be memoized as None");
9189        assert!(
9190            second_err
9191                .to_string()
9192                .contains("open fast-tier index failed"),
9193            "unexpected second progressive-context error: {second_err}"
9194        );
9195
9196        Ok(())
9197    }
9198
9199    fn build_semantic_test_fixture() -> Result<SemanticTestFixture> {
9200        build_semantic_test_fixture_with_shards(false)
9201    }
9202
9203    fn build_sharded_semantic_test_fixture() -> Result<SemanticTestFixture> {
9204        build_semantic_test_fixture_with_shards(true)
9205    }
9206
9207    fn build_semantic_test_fixture_with_shards(sharded: bool) -> Result<SemanticTestFixture> {
9208        let dir = TempDir::new()?;
9209        let db_path = dir.path().join("cass.db");
9210        let storage = FrankenStorage::open(&db_path)?;
9211
9212        let agent = Agent {
9213            id: None,
9214            slug: "codex".into(),
9215            name: "Codex".into(),
9216            version: None,
9217            kind: AgentKind::Cli,
9218        };
9219        let agent_id = storage.ensure_agent(&agent)?;
9220        let workspace_path = dir.path().join("workspace");
9221        std::fs::create_dir_all(&workspace_path)?;
9222        let workspace_id = storage.ensure_workspace(&workspace_path, None)?;
9223
9224        let documents = [
9225            ("session-a.jsonl", "top semantic match", [1.0_f32, 0.0_f32]),
9226            (
9227                "session-b.jsonl",
9228                "middle semantic match",
9229                [0.9_f32, 0.1_f32],
9230            ),
9231            ("session-c.jsonl", "late semantic match", [0.8_f32, 0.2_f32]),
9232        ];
9233        let base_ts = 1_700_000_000_000_i64;
9234        let mut doc_ids = Vec::with_capacity(documents.len());
9235        let mut source_paths = Vec::with_capacity(documents.len());
9236
9237        for (idx, (name, content, _vector)) in documents.iter().enumerate() {
9238            let source_path = dir.path().join(name);
9239            source_paths.push(source_path.to_string_lossy().to_string());
9240
9241            let conversation = Conversation {
9242                id: None,
9243                agent_slug: agent.slug.clone(),
9244                workspace: Some(workspace_path.clone()),
9245                external_id: Some(format!("semantic-{idx}")),
9246                title: Some(format!("semantic session {idx}")),
9247                source_path,
9248                started_at: Some(base_ts + idx as i64),
9249                ended_at: Some(base_ts + idx as i64),
9250                approx_tokens: Some(16),
9251                metadata_json: json!({"fixture": "semantic_search"}),
9252                messages: vec![Message {
9253                    id: None,
9254                    idx: 0,
9255                    role: MessageRole::User,
9256                    author: Some("user".into()),
9257                    created_at: Some(base_ts + idx as i64),
9258                    content: (*content).to_string(),
9259                    extra_json: json!({}),
9260                    snippets: Vec::new(),
9261                }],
9262                source_id: crate::sources::provenance::LOCAL_SOURCE_ID.to_string(),
9263                origin_host: None,
9264            };
9265
9266            storage.insert_conversation_tree(agent_id, Some(workspace_id), &conversation)?;
9267        }
9268
9269        let message_rows: Vec<(u64, i64)> = storage.raw().query_map_collect(
9270            "SELECT m.id, COALESCE(m.created_at, c.started_at, 0)
9271             FROM messages m
9272             JOIN conversations c ON m.conversation_id = c.id
9273             ORDER BY c.id",
9274            &[],
9275            |row: &frankensqlite::Row| {
9276                let message_id: i64 = row.get_typed(0)?;
9277                let created_at: i64 = row.get_typed(1)?;
9278                Ok((u64::try_from(message_id).unwrap_or(u64::MAX), created_at))
9279            },
9280        )?;
9281        assert_eq!(
9282            message_rows.len(),
9283            documents.len(),
9284            "fixture should create 3 messages"
9285        );
9286
9287        let filter_maps = SemanticFilterMaps::from_storage(&storage)?;
9288        let embedder = Arc::new(FixedTestEmbedder::new("test-fixed-2d", &[1.0, 0.0]));
9289        let source_hash = crc32fast::hash(crate::sources::provenance::LOCAL_SOURCE_ID.as_bytes());
9290        let vector_dir = dir.path().join("vector_index");
9291        std::fs::create_dir_all(&vector_dir)?;
9292        let mut vector_records = Vec::with_capacity(documents.len());
9293
9294        for ((message_id, created_at_ms), (_, _, vector)) in message_rows.iter().zip(documents) {
9295            let doc_id = SemanticDocId {
9296                message_id: *message_id,
9297                chunk_idx: 0,
9298                agent_id: u32::try_from(agent_id)?,
9299                workspace_id: u32::try_from(workspace_id)?,
9300                source_id: source_hash,
9301                role: ROLE_USER,
9302                created_at_ms: *created_at_ms,
9303                content_hash: None,
9304            }
9305            .to_doc_id_string();
9306            doc_ids.push(doc_id.clone());
9307            vector_records.push((doc_id, vector));
9308        }
9309
9310        let mut vector_indexes = Vec::new();
9311        if sharded {
9312            for (shard_index, chunk) in vector_records.chunks(2).enumerate() {
9313                let vector_path = vector_dir.join(format!("shard-{shard_index}.fsvi"));
9314                let mut writer = VectorIndex::create_with_revision(
9315                    &vector_path,
9316                    embedder.id(),
9317                    "rev-1",
9318                    embedder.dimension(),
9319                    frankensearch::index::Quantization::F16,
9320                )?;
9321                for (doc_id, vector) in chunk {
9322                    writer.write_record(doc_id, vector)?;
9323                }
9324                writer.finish()?;
9325                vector_indexes.push(VectorIndex::open(&vector_path)?);
9326            }
9327        } else {
9328            let vector_path = vector_dir.join("index-test-fixed-2d.fsvi");
9329            let mut writer = VectorIndex::create_with_revision(
9330                &vector_path,
9331                embedder.id(),
9332                "rev-1",
9333                embedder.dimension(),
9334                frankensearch::index::Quantization::F16,
9335            )?;
9336            for (doc_id, vector) in &vector_records {
9337                writer.write_record(doc_id, vector)?;
9338            }
9339            writer.finish()?;
9340            vector_indexes.push(VectorIndex::open(&vector_path)?);
9341        }
9342        drop(storage);
9343
9344        let client = SearchClient::open(dir.path(), Some(&db_path))?.expect("db-backed client");
9345        client.set_semantic_indexes_context(embedder, vector_indexes, filter_maps, None, None)?;
9346
9347        Ok(SemanticTestFixture {
9348            _dir: dir,
9349            client,
9350            doc_ids,
9351            source_paths,
9352        })
9353    }
9354
9355    fn build_progressive_hybrid_fixture() -> Result<ProgressiveHybridFixture> {
9356        let dir = TempDir::new()?;
9357        let mut index = TantivyIndex::open_or_create(dir.path())?;
9358        let workspace_path = dir.path().join("workspace");
9359        std::fs::create_dir_all(&workspace_path)?;
9360        let agent_id = 1_i64;
9361        let workspace_id = 1_i64;
9362        let source_id = crate::sources::provenance::LOCAL_SOURCE_ID;
9363        let source_hash = crc32fast::hash(source_id.as_bytes());
9364        let conn = Connection::open(":memory:")?;
9365        conn.execute_batch(
9366            r#"
9367            CREATE TABLE agents (
9368                id INTEGER PRIMARY KEY,
9369                slug TEXT NOT NULL
9370            );
9371            CREATE TABLE workspaces (
9372                id INTEGER PRIMARY KEY,
9373                path TEXT NOT NULL
9374            );
9375            CREATE TABLE sources (
9376                id TEXT PRIMARY KEY,
9377                kind TEXT NOT NULL
9378            );
9379            CREATE TABLE conversations (
9380                id INTEGER PRIMARY KEY,
9381                agent_id INTEGER NOT NULL,
9382                workspace_id INTEGER,
9383                title TEXT,
9384                source_path TEXT NOT NULL,
9385                source_id TEXT NOT NULL,
9386                origin_host TEXT,
9387                started_at INTEGER
9388            );
9389            CREATE TABLE messages (
9390                id INTEGER PRIMARY KEY,
9391                conversation_id INTEGER NOT NULL,
9392                idx INTEGER NOT NULL,
9393                role TEXT NOT NULL,
9394                created_at INTEGER,
9395                content TEXT NOT NULL
9396            );
9397            "#,
9398        )?;
9399        conn.execute_compat(
9400            "INSERT INTO agents (id, slug) VALUES (?1, ?2)",
9401            params![agent_id, "codex"],
9402        )?;
9403        conn.execute_compat(
9404            "INSERT INTO workspaces (id, path) VALUES (?1, ?2)",
9405            params![workspace_id, workspace_path.to_string_lossy().to_string()],
9406        )?;
9407        conn.execute_compat(
9408            "INSERT INTO sources (id, kind) VALUES (?1, ?2)",
9409            params![source_id, "local"],
9410        )?;
9411
9412        let query = "oauth refresh token middleware session cache".to_string();
9413        let filler = " context window ranking provenance semantic upgrade lexical overlay";
9414        let base_ts = 1_700_000_100_000_i64;
9415        let doc_count = 64usize;
9416        let mut message_rows = Vec::with_capacity(doc_count);
9417
9418        for idx in 0..doc_count {
9419            let conversation_id = i64::try_from(idx + 1)?;
9420            let message_id = u64::try_from(idx + 1)?;
9421            let source_path = dir.path().join(format!("progressive-{idx:03}.jsonl"));
9422            let repeated = filler.repeat(48);
9423            let content = if idx % 4 == 0 {
9424                format!(
9425                    "{query} hot path candidate {idx} with detailed search diagnostics.{repeated}"
9426                )
9427            } else if idx % 4 == 1 {
9428                format!(
9429                    "search pipeline benchmark {idx} with lexical overlay and semantic ranking.{repeated}"
9430                )
9431            } else if idx % 4 == 2 {
9432                format!(
9433                    "interactive typing debounce benchmark {idx} for hybrid two tier search.{repeated}"
9434                )
9435            } else {
9436                format!(
9437                    "unrelated background chatter {idx} about build systems and formatting checks.{repeated}"
9438                )
9439            };
9440            let created_at = base_ts + idx as i64;
9441            let source_path_str = source_path.to_string_lossy().to_string();
9442            let title = format!("progressive fixture {idx}");
9443
9444            conn.execute_compat(
9445                "INSERT INTO conversations (
9446                    id, agent_id, workspace_id, title, source_path, source_id, origin_host, started_at
9447                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7)",
9448                params![
9449                    conversation_id,
9450                    agent_id,
9451                    workspace_id,
9452                    title,
9453                    source_path_str.clone(),
9454                    source_id,
9455                    created_at
9456                ],
9457            )?;
9458            conn.execute_compat(
9459                "INSERT INTO messages (
9460                    id, conversation_id, idx, role, created_at, content
9461                 ) VALUES (?1, ?2, 0, 'user', ?3, ?4)",
9462                params![
9463                    i64::try_from(message_id)?,
9464                    conversation_id,
9465                    created_at,
9466                    content.clone()
9467                ],
9468            )?;
9469            message_rows.push((message_id, created_at, content.clone()));
9470
9471            let normalized = NormalizedConversation {
9472                agent_slug: "codex".into(),
9473                external_id: Some(format!("progressive-{idx}")),
9474                title: Some(format!("progressive fixture {idx}")),
9475                workspace: Some(workspace_path.clone()),
9476                source_path,
9477                started_at: Some(created_at),
9478                ended_at: Some(created_at),
9479                metadata: json!({}),
9480                messages: vec![NormalizedMessage {
9481                    idx: 0,
9482                    role: "user".into(),
9483                    author: Some("user".into()),
9484                    created_at: Some(created_at),
9485                    content,
9486                    extra: json!({}),
9487                    snippets: Vec::new(),
9488                    invocations: Vec::new(),
9489                }],
9490            };
9491            index.add_conversation(&normalized)?;
9492        }
9493        index.commit()?;
9494
9495        assert_eq!(
9496            message_rows.len(),
9497            doc_count,
9498            "fixture should create the requested number of messages"
9499        );
9500
9501        let fast_embedder = Arc::new(crate::search::hash_embedder::HashEmbedder::new(256));
9502        let quality_embedder = crate::search::hash_embedder::HashEmbedder::new(384);
9503        let filter_maps = SemanticFilterMaps::for_tests(
9504            HashMap::from([("codex".to_string(), u32::try_from(agent_id)?)]),
9505            HashMap::from([(
9506                workspace_path.to_string_lossy().to_string(),
9507                u32::try_from(workspace_id)?,
9508            )]),
9509            HashMap::from([(source_id.to_string(), source_hash)]),
9510            HashSet::new(),
9511        );
9512        let fast_path = dir.path().join("vector.fast.idx");
9513        let quality_path = dir.path().join("vector.quality.idx");
9514
9515        let mut fast_writer = VectorIndex::create_with_revision(
9516            &fast_path,
9517            fast_embedder.id(),
9518            "rev-progressive-fast",
9519            fast_embedder.dimension(),
9520            frankensearch::index::Quantization::F16,
9521        )?;
9522        let mut quality_writer = VectorIndex::create_with_revision(
9523            &quality_path,
9524            quality_embedder.id(),
9525            "rev-progressive-quality",
9526            quality_embedder.dimension(),
9527            frankensearch::index::Quantization::F16,
9528        )?;
9529
9530        for (message_id, created_at_ms, content) in &message_rows {
9531            let canonical = canonicalize_for_embedding(content);
9532            let doc_id = SemanticDocId {
9533                message_id: *message_id,
9534                chunk_idx: 0,
9535                agent_id: u32::try_from(agent_id)?,
9536                workspace_id: u32::try_from(workspace_id)?,
9537                source_id: source_hash,
9538                role: ROLE_USER,
9539                created_at_ms: *created_at_ms,
9540                content_hash: Some(content_hash(&canonical)),
9541            }
9542            .to_doc_id_string();
9543
9544            let fast_vec = fast_embedder.embed_sync(content)?;
9545            fast_writer.write_record(&doc_id, &fast_vec)?;
9546            let quality_vec = quality_embedder.embed_sync(content)?;
9547            quality_writer.write_record(&doc_id, &quality_vec)?;
9548        }
9549        fast_writer.finish()?;
9550        quality_writer.finish()?;
9551
9552        let reader = fs_cass_open_search_reader(dir.path(), ReloadPolicy::Manual).ok();
9553        let client = SearchClient {
9554            reader,
9555            sqlite: Mutex::new(Some(SendConnection(conn))),
9556            sqlite_path: None,
9557            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
9558            reload_on_search: true,
9559            last_reload: Mutex::new(None),
9560            last_generation: Mutex::new(None),
9561            reload_epoch: Arc::new(AtomicU64::new(0)),
9562            warm_tx: None,
9563            _warm_handle: None,
9564            metrics: Metrics::default(),
9565            cache_namespace: format!("v{}|schema:{}", CACHE_KEY_VERSION, FS_CASS_SCHEMA_HASH),
9566            semantic: Mutex::new(None),
9567            last_tantivy_total_count: Mutex::new(None),
9568        };
9569        let semantic_embedder: Arc<dyn Embedder> = fast_embedder;
9570        client.set_semantic_context(
9571            semantic_embedder,
9572            VectorIndex::open(&fast_path)?,
9573            filter_maps,
9574            None,
9575            Some(fast_path),
9576        )?;
9577
9578        Ok(ProgressiveHybridFixture {
9579            _dir: dir,
9580            client: Arc::new(client),
9581            query,
9582        })
9583    }
9584
9585    fn sanitize_query(raw: &str) -> String {
9586        nfc_sanitize_query(raw)
9587    }
9588
9589    fn parse_boolean_query(query: &str) -> Vec<FsCassQueryToken> {
9590        fs_cass_parse_boolean_query(query)
9591    }
9592
9593    fn sqlite_master_name_count(db_path: &Path, name: &str) -> Result<i64> {
9594        let conn = FrankenConnection::open(db_path.to_string_lossy().as_ref())?;
9595        Ok(conn.query_row_map(
9596            "SELECT COUNT(*) FROM sqlite_master WHERE name = ?1",
9597            &[ParamValue::from(name)],
9598            |row| row.get_typed(0),
9599        )?)
9600    }
9601
9602    type QueryToken = FsCassQueryToken;
9603    type WildcardPattern = FsCassWildcardPattern;
9604    type QueryTokenList = Vec<QueryToken>;
9605
9606    #[test]
9607    #[ignore = "profiling harness for live hybrid progressive search"]
9608    fn progressive_hybrid_profile_harness() -> Result<()> {
9609        let fixture = build_progressive_hybrid_fixture()?;
9610        let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
9611            .build()
9612            .map_err(|err| anyhow!("build test runtime failed: {err}"))?;
9613        let iterations = 24usize;
9614
9615        runtime.block_on(async {
9616            let cx = FsCx::for_request();
9617            fixture
9618                .client
9619                .search_progressive_with_callback(
9620                    ProgressiveSearchRequest {
9621                        cx: &cx,
9622                        query: &fixture.query,
9623                        filters: SearchFilters::default(),
9624                        limit: 16,
9625                        sparse_threshold: 0,
9626                        field_mask: FieldMask::new(false, true, true, true),
9627                        mode: SearchMode::Hybrid,
9628                    },
9629                    |_| {},
9630                )
9631                .await
9632        })?;
9633
9634        let mut initial_events = 0usize;
9635        let mut refined_events = 0usize;
9636        let mut total_hits = 0usize;
9637        for _ in 0..iterations {
9638            let mut refinement_error = None;
9639            runtime.block_on(async {
9640                let cx = FsCx::for_request();
9641                fixture
9642                    .client
9643                    .search_progressive_with_callback(
9644                        ProgressiveSearchRequest {
9645                            cx: &cx,
9646                            query: &fixture.query,
9647                            filters: SearchFilters::default(),
9648                            limit: 16,
9649                            sparse_threshold: 0,
9650                            field_mask: FieldMask::new(false, true, true, true),
9651                            mode: SearchMode::Hybrid,
9652                        },
9653                        |event| match event {
9654                            ProgressiveSearchEvent::Phase { kind, result, .. } => {
9655                                assert!(
9656                                    !result.hits.is_empty(),
9657                                    "progressive harness expects non-empty hits for each phase"
9658                                );
9659                                total_hits += result.hits.len();
9660                                match kind {
9661                                    ProgressivePhaseKind::Initial => initial_events += 1,
9662                                    ProgressivePhaseKind::Refined => refined_events += 1,
9663                                }
9664                            }
9665                            ProgressiveSearchEvent::RefinementFailed { error, .. } => {
9666                                refinement_error = Some(error);
9667                            }
9668                        },
9669                    )
9670                    .await
9671            })?;
9672            if let Some(error) = refinement_error {
9673                bail!("progressive harness refinement failed: {error}");
9674            }
9675        }
9676
9677        assert_eq!(initial_events, iterations);
9678        assert_eq!(refined_events, iterations);
9679        assert!(
9680            total_hits >= iterations.saturating_mul(16),
9681            "harness should observe a full page for each phase"
9682        );
9683
9684        Ok(())
9685    }
9686
9687    // ==========================================================================
9688    // StringInterner Tests (Opt 2.3)
9689    // ==========================================================================
9690
9691    #[test]
9692    fn interner_returns_same_arc_for_same_string() {
9693        let interner = StringInterner::new(100);
9694
9695        let s1 = interner.intern("test_query");
9696        let s2 = interner.intern("test_query");
9697
9698        // Should be the exact same Arc (pointer equality)
9699        assert!(Arc::ptr_eq(&s1, &s2));
9700        assert_eq!(&*s1, "test_query");
9701    }
9702
9703    #[test]
9704    fn interner_different_strings_return_different_arcs() {
9705        let interner = StringInterner::new(100);
9706
9707        let s1 = interner.intern("query1");
9708        let s2 = interner.intern("query2");
9709
9710        assert!(!Arc::ptr_eq(&s1, &s2));
9711        assert_eq!(&*s1, "query1");
9712        assert_eq!(&*s2, "query2");
9713    }
9714
9715    #[test]
9716    fn interner_handles_empty_string() {
9717        let interner = StringInterner::new(100);
9718
9719        let s1 = interner.intern("");
9720        let s2 = interner.intern("");
9721
9722        assert!(Arc::ptr_eq(&s1, &s2));
9723        assert_eq!(&*s1, "");
9724    }
9725
9726    #[test]
9727    fn interner_handles_unicode() {
9728        let interner = StringInterner::new(100);
9729
9730        let s1 = interner.intern("测试查询");
9731        let s2 = interner.intern("测试查询");
9732        let s3 = interner.intern("emoji 🔍 search");
9733
9734        assert!(Arc::ptr_eq(&s1, &s2));
9735        assert_eq!(&*s3, "emoji 🔍 search");
9736    }
9737
9738    #[test]
9739    fn interner_respects_lru_eviction() {
9740        let interner = StringInterner::new(3);
9741
9742        let _s1 = interner.intern("query1");
9743        let _s2 = interner.intern("query2");
9744        let _s3 = interner.intern("query3");
9745
9746        assert_eq!(interner.len(), 3);
9747
9748        // This should evict query1 (LRU)
9749        let _s4 = interner.intern("query4");
9750
9751        assert_eq!(interner.len(), 3);
9752
9753        // query1 should now get a NEW Arc (was evicted)
9754        let s1_new = interner.intern("query1");
9755        assert_eq!(&*s1_new, "query1");
9756    }
9757
9758    #[test]
9759    fn interner_concurrent_access() {
9760        use std::thread;
9761
9762        let interner = Arc::new(StringInterner::new(1000));
9763        let queries: Vec<String> = (0..100).map(|i| format!("query_{}", i)).collect();
9764
9765        let handles: Vec<_> = (0..4)
9766            .map(|_| {
9767                let interner = Arc::clone(&interner);
9768                let queries = queries.clone();
9769
9770                thread::spawn(move || {
9771                    for _ in 0..10 {
9772                        for query in &queries {
9773                            let _ = interner.intern(query);
9774                        }
9775                    }
9776                })
9777            })
9778            .collect();
9779
9780        for handle in handles {
9781            handle.join().unwrap();
9782        }
9783
9784        // Verify all queries are interned correctly
9785        for query in &queries {
9786            let s1 = interner.intern(query);
9787            let s2 = interner.intern(query);
9788            assert!(Arc::ptr_eq(&s1, &s2));
9789        }
9790    }
9791
9792    // ==========================================================================
9793    // QueryTermsLower Tests (Opt 2.4)
9794    // ==========================================================================
9795
9796    #[test]
9797    fn query_terms_lower_basic() {
9798        let terms = QueryTermsLower::from_query("Hello World");
9799
9800        assert_eq!(terms.query_lower, "hello world");
9801        let tokens: Vec<&str> = terms.tokens().collect();
9802        assert_eq!(tokens, vec!["hello", "world"]);
9803    }
9804
9805    #[test]
9806    fn query_terms_lower_empty() {
9807        let terms = QueryTermsLower::from_query("");
9808
9809        assert!(terms.is_empty());
9810        assert_eq!(terms.tokens().count(), 0);
9811    }
9812
9813    #[test]
9814    fn query_terms_lower_single_term() {
9815        let terms = QueryTermsLower::from_query("TEST");
9816
9817        let tokens: Vec<&str> = terms.tokens().collect();
9818        assert_eq!(tokens, vec!["test"]);
9819    }
9820
9821    #[test]
9822    fn query_terms_lower_with_punctuation() {
9823        let terms = QueryTermsLower::from_query("hello, world! how's it?");
9824
9825        let tokens: Vec<&str> = terms.tokens().collect();
9826        assert_eq!(tokens, vec!["hello", "world", "how", "s", "it"]);
9827    }
9828
9829    #[test]
9830    fn query_terms_lower_unicode() {
9831        let terms = QueryTermsLower::from_query("Héllo Wörld");
9832
9833        assert_eq!(terms.query_lower, "héllo wörld");
9834        let tokens: Vec<&str> = terms.tokens().collect();
9835        assert_eq!(tokens, vec!["héllo", "wörld"]);
9836    }
9837
9838    #[test]
9839    fn query_terms_lower_bloom_mask() {
9840        let terms = QueryTermsLower::from_query("test");
9841
9842        // Bloom mask should be non-zero for non-empty query
9843        assert_ne!(terms.bloom_mask(), 0);
9844
9845        // Same query should produce same bloom mask
9846        let terms2 = QueryTermsLower::from_query("test");
9847        assert_eq!(terms.bloom_mask(), terms2.bloom_mask());
9848    }
9849
9850    #[test]
9851    fn hit_matches_with_precomputed_terms() {
9852        let hit = SearchHit {
9853            title: "Test Title".into(),
9854            snippet: "".into(),
9855            content: "hello world content".into(),
9856            content_hash: stable_content_hash("hello world content"),
9857            score: 1.0,
9858            source_path: "p".into(),
9859            agent: "a".into(),
9860            workspace: "w".into(),
9861            workspace_original: None,
9862            created_at: None,
9863            line_number: None,
9864            match_type: MatchType::Exact,
9865            source_id: "local".into(),
9866            origin_kind: "local".into(),
9867            origin_host: None,
9868            conversation_id: None,
9869        };
9870        let cached = cached_hit_from(&hit);
9871
9872        // Test with precomputed terms
9873        let terms = QueryTermsLower::from_query("hello");
9874        assert!(hit_matches_query_cached_precomputed(&cached, &terms));
9875
9876        let terms_miss = QueryTermsLower::from_query("missing");
9877        assert!(!hit_matches_query_cached_precomputed(&cached, &terms_miss));
9878    }
9879
9880    // ==========================================================================
9881    // Quickselect Top-K Tests (Opt 2.5)
9882    // ==========================================================================
9883
9884    fn make_fused_hit(
9885        id: &str,
9886        rrf: f32,
9887        lexical: Option<usize>,
9888        semantic: Option<usize>,
9889    ) -> FusedHit {
9890        FusedHit {
9891            key: SearchHitKey {
9892                source_id: "local".to_string(),
9893                source_path: id.to_string(),
9894                conversation_id: None,
9895                title: String::new(),
9896                line_number: None,
9897                created_at: None,
9898                content_hash: 0,
9899            },
9900            score: HybridScore {
9901                rrf,
9902                lexical_rank: lexical,
9903                semantic_rank: semantic,
9904                lexical_score: None,
9905                semantic_score: None,
9906            },
9907            hit: SearchHit {
9908                title: id.into(),
9909                snippet: "".into(),
9910                content: "".into(),
9911                content_hash: 0,
9912                score: rrf,
9913                source_path: id.into(),
9914                agent: "test".into(),
9915                workspace: "test".into(),
9916                workspace_original: None,
9917                created_at: None,
9918                line_number: None,
9919                match_type: MatchType::Exact,
9920                source_id: "local".into(),
9921                origin_kind: "local".into(),
9922                origin_host: None,
9923                conversation_id: None,
9924            },
9925        }
9926    }
9927
9928    fn make_federated_merge_hit(id: &str, agent: &str) -> SearchHit {
9929        SearchHit {
9930            title: id.into(),
9931            snippet: String::new(),
9932            content: id.into(),
9933            content_hash: stable_content_hash(id),
9934            score: 0.0,
9935            source_path: format!("{id}.jsonl"),
9936            agent: agent.into(),
9937            workspace: "workspace".into(),
9938            workspace_original: None,
9939            created_at: Some(1_700_000_000_000),
9940            line_number: Some(1),
9941            match_type: MatchType::Exact,
9942            source_id: "local".into(),
9943            origin_kind: "local".into(),
9944            origin_host: None,
9945            conversation_id: None,
9946        }
9947    }
9948
9949    fn make_federated_ranked_hit(
9950        shard_index: usize,
9951        shard_rank: usize,
9952        id: &str,
9953    ) -> FederatedRankedHit {
9954        FederatedRankedHit {
9955            hit: make_federated_merge_hit(id, &format!("shard-{shard_index}")),
9956            shard_index,
9957            shard_rank,
9958            fused_score: federated_rrf_score(shard_rank),
9959        }
9960    }
9961
9962    #[test]
9963    fn federated_merge_orders_equal_rank_hits_by_stable_hit_key() {
9964        let merged = merge_federated_ranked_hits(vec![
9965            make_federated_ranked_hit(2, 0, "zeta"),
9966            make_federated_ranked_hit(0, 0, "bravo"),
9967            make_federated_ranked_hit(1, 0, "alpha"),
9968        ]);
9969
9970        let paths = merged
9971            .iter()
9972            .map(|hit| hit.source_path.as_str())
9973            .collect::<Vec<_>>();
9974        assert_eq!(paths, vec!["alpha.jsonl", "bravo.jsonl", "zeta.jsonl"]);
9975        assert!(
9976            merged
9977                .iter()
9978                .all(|hit| (hit.score - federated_rrf_score(0)).abs() < f32::EPSILON),
9979            "equal per-shard rank should produce equal RRF scores"
9980        );
9981    }
9982
9983    #[test]
9984    fn federated_merge_keeps_rrf_rank_ahead_of_stable_key() {
9985        let merged = merge_federated_ranked_hits(vec![
9986            make_federated_ranked_hit(0, 1, "alpha"),
9987            make_federated_ranked_hit(1, 0, "zeta"),
9988        ]);
9989
9990        let paths = merged
9991            .iter()
9992            .map(|hit| hit.source_path.as_str())
9993            .collect::<Vec<_>>();
9994        assert_eq!(paths, vec!["zeta.jsonl", "alpha.jsonl"]);
9995        assert!(merged[0].score > merged[1].score);
9996    }
9997
9998    #[test]
9999    fn federated_merge_uses_shard_index_as_duplicate_final_tiebreak() {
10000        let merged = merge_federated_ranked_hits(vec![
10001            FederatedRankedHit {
10002                hit: make_federated_merge_hit("same", "shard-2"),
10003                shard_index: 2,
10004                shard_rank: 0,
10005                fused_score: federated_rrf_score(0),
10006            },
10007            FederatedRankedHit {
10008                hit: make_federated_merge_hit("same", "shard-0"),
10009                shard_index: 0,
10010                shard_rank: 0,
10011                fused_score: federated_rrf_score(0),
10012            },
10013        ]);
10014
10015        assert_eq!(merged[0].agent, "shard-0");
10016        assert_eq!(merged[1].agent, "shard-2");
10017    }
10018
10019    #[test]
10020    fn top_k_fused_basic() {
10021        let hits = vec![
10022            make_fused_hit("a", 1.0, Some(0), None),
10023            make_fused_hit("b", 3.0, Some(1), None),
10024            make_fused_hit("c", 2.0, Some(2), None),
10025            make_fused_hit("d", 5.0, Some(3), None),
10026            make_fused_hit("e", 4.0, Some(4), None),
10027        ];
10028
10029        let top = top_k_fused(hits, 3);
10030
10031        assert_eq!(top.len(), 3);
10032        assert_eq!(top[0].key.source_path, "d"); // 5.0
10033        assert_eq!(top[1].key.source_path, "e"); // 4.0
10034        assert_eq!(top[2].key.source_path, "b"); // 3.0
10035    }
10036
10037    #[test]
10038    fn top_k_fused_empty() {
10039        let hits: Vec<FusedHit> = vec![];
10040        let top = top_k_fused(hits, 10);
10041        assert!(top.is_empty());
10042    }
10043
10044    #[test]
10045    fn top_k_fused_k_zero() {
10046        let hits = vec![
10047            make_fused_hit("a", 1.0, Some(0), None),
10048            make_fused_hit("b", 2.0, Some(1), None),
10049        ];
10050        let top = top_k_fused(hits, 0);
10051        assert!(top.is_empty());
10052    }
10053
10054    #[test]
10055    fn top_k_fused_k_larger_than_n() {
10056        let hits = vec![
10057            make_fused_hit("a", 1.0, Some(0), None),
10058            make_fused_hit("b", 2.0, Some(1), None),
10059        ];
10060
10061        let top = top_k_fused(hits, 10);
10062
10063        assert_eq!(top.len(), 2);
10064        assert_eq!(top[0].key.source_path, "b"); // 2.0
10065        assert_eq!(top[1].key.source_path, "a"); // 1.0
10066    }
10067
10068    #[test]
10069    fn top_k_fused_k_equals_n() {
10070        let hits = vec![
10071            make_fused_hit("a", 3.0, Some(0), None),
10072            make_fused_hit("b", 1.0, Some(1), None),
10073            make_fused_hit("c", 2.0, Some(2), None),
10074        ];
10075
10076        let top = top_k_fused(hits, 3);
10077
10078        assert_eq!(top.len(), 3);
10079        assert_eq!(top[0].key.source_path, "a"); // 3.0
10080        assert_eq!(top[1].key.source_path, "c"); // 2.0
10081        assert_eq!(top[2].key.source_path, "b"); // 1.0
10082    }
10083
10084    #[test]
10085    fn top_k_fused_k_one() {
10086        let hits = vec![
10087            make_fused_hit("a", 1.0, Some(0), None),
10088            make_fused_hit("b", 3.0, Some(1), None),
10089            make_fused_hit("c", 2.0, Some(2), None),
10090        ];
10091
10092        let top = top_k_fused(hits, 1);
10093
10094        assert_eq!(top.len(), 1);
10095        assert_eq!(top[0].key.source_path, "b");
10096        assert_eq!(top[0].score.rrf, 3.0);
10097    }
10098
10099    #[test]
10100    fn top_k_fused_duplicate_scores() {
10101        let hits = vec![
10102            make_fused_hit("a", 2.0, Some(0), None),
10103            make_fused_hit("b", 2.0, Some(1), None),
10104            make_fused_hit("c", 2.0, Some(2), None),
10105            make_fused_hit("d", 1.0, Some(3), None),
10106        ];
10107
10108        let top = top_k_fused(hits, 2);
10109
10110        assert_eq!(top.len(), 2);
10111        // All have same score, so order is by key (deterministic tie-breaking)
10112        assert_eq!(top[0].score.rrf, 2.0);
10113        assert_eq!(top[1].score.rrf, 2.0);
10114    }
10115
10116    #[test]
10117    fn top_k_fused_dual_source_tiebreaker() {
10118        // Hits with same RRF score, but some have both lexical and semantic ranks
10119        let hits = vec![
10120            make_fused_hit("a", 2.0, Some(0), None),    // lexical only
10121            make_fused_hit("b", 2.0, Some(1), Some(0)), // both sources
10122            make_fused_hit("c", 2.0, None, Some(1)),    // semantic only
10123        ];
10124
10125        let top = top_k_fused(hits, 3);
10126
10127        assert_eq!(top.len(), 3);
10128        // Dual-source hit should come first
10129        assert_eq!(top[0].key.source_path, "b");
10130    }
10131
10132    #[test]
10133    fn top_k_fused_large_input_uses_quickselect() {
10134        // Create input larger than QUICKSELECT_THRESHOLD to trigger quickselect path
10135        let hits: Vec<FusedHit> = (0..100)
10136            .map(|i| make_fused_hit(&format!("hit_{}", i), i as f32, Some(i), None))
10137            .collect();
10138
10139        let top = top_k_fused(hits, 10);
10140
10141        assert_eq!(top.len(), 10);
10142        // Should be sorted descending: hit_99, hit_98, ... hit_90
10143        for (i, hit) in top.iter().enumerate() {
10144            assert_eq!(hit.key.source_path, format!("hit_{}", 99 - i));
10145            assert_eq!(hit.score.rrf, (99 - i) as f32);
10146        }
10147    }
10148
10149    #[test]
10150    fn top_k_fused_equivalence_with_full_sort() {
10151        // Verify quickselect produces same results as full sort
10152        for n in [10, 50, 100, 200] {
10153            for k in [1, 5, 10, 25] {
10154                if k > n {
10155                    continue;
10156                }
10157
10158                let hits: Vec<FusedHit> = (0..n)
10159                    .map(|i| {
10160                        // Pseudo-random scores using simple hash
10161                        let score = ((i * 17 + 7) % 1000) as f32;
10162                        make_fused_hit(&format!("hit_{}", i), score, Some(i), None)
10163                    })
10164                    .collect();
10165
10166                // Baseline: full sort
10167                let mut baseline = hits.clone();
10168                baseline.sort_by(cmp_fused_hit_desc);
10169                baseline.truncate(k);
10170
10171                // Quickselect
10172                let quickselect = top_k_fused(hits, k);
10173
10174                // Verify same length
10175                assert_eq!(quickselect.len(), baseline.len(), "n={}, k={}", n, k);
10176
10177                // Verify same elements in same order
10178                for (q, b) in quickselect.iter().zip(baseline.iter()) {
10179                    assert_eq!(
10180                        q.key.source_path, b.key.source_path,
10181                        "n={}, k={}: mismatch",
10182                        n, k
10183                    );
10184                    assert_eq!(q.score.rrf, b.score.rrf, "n={}, k={}: score mismatch", n, k);
10185                }
10186            }
10187        }
10188    }
10189
10190    #[test]
10191    fn cmp_fused_hit_desc_basic_ordering() {
10192        let a = make_fused_hit("a", 2.0, Some(0), None);
10193        let b = make_fused_hit("b", 3.0, Some(1), None);
10194
10195        // Higher score should come first (compare returns Less)
10196        assert_eq!(cmp_fused_hit_desc(&a, &b), CmpOrdering::Greater);
10197        assert_eq!(cmp_fused_hit_desc(&b, &a), CmpOrdering::Less);
10198        assert_eq!(cmp_fused_hit_desc(&a, &a), CmpOrdering::Equal);
10199    }
10200
10201    // ==========================================================================
10202    // Original Tests
10203    // ==========================================================================
10204
10205    #[test]
10206    fn cache_enforces_prefix_matching() {
10207        // Hit contains "arrow"
10208        let hit = SearchHit {
10209            title: "test".into(),
10210            snippet: "".into(),
10211            content: "arrow".into(),
10212            content_hash: stable_content_hash("arrow"),
10213            score: 1.0,
10214            source_path: "p".into(),
10215            agent: "a".into(),
10216            workspace: "w".into(),
10217            workspace_original: None,
10218            created_at: None,
10219            line_number: None,
10220            match_type: MatchType::Exact,
10221            source_id: "local".into(),
10222            origin_kind: "local".into(),
10223            origin_host: None,
10224            conversation_id: None,
10225        };
10226
10227        let cached = CachedHit {
10228            hit: hit.clone(),
10229            lc_content: "arrow".into(),
10230            lc_title: Some("test".into()),
10231            bloom64: u64::MAX, // Bypass bloom filter
10232        };
10233
10234        // Query "row" is contained in "arrow" but is NOT a prefix.
10235        // It should NOT match if we are enforcing prefix semantics.
10236        let matched = hit_matches_query_cached(&cached, "row");
10237
10238        assert!(
10239            !matched,
10240            "Query 'row' should NOT match content 'arrow' (prefix match required)"
10241        );
10242    }
10243
10244    #[test]
10245    fn search_deduplication_across_pages_repro() {
10246        // Distinct sessions with identical content should remain visible across
10247        // pages. Global pagination still has to happen after deduplication, but
10248        // dedup itself only coalesces hits that share message-level provenance.
10249
10250        let dir = TempDir::new().unwrap();
10251        let index_path = dir.path();
10252        let mut index = TantivyIndex::open_or_create(index_path).unwrap();
10253
10254        // Add two documents with IDENTICAL content but distinct other fields.
10255        // Tantivy scores them. If query matches both equally, one comes first.
10256        // We'll use different source paths to ensure they are distinct hits initially.
10257        let msg1 = NormalizedMessage {
10258            idx: 0,
10259            role: "user".into(),
10260            author: None,
10261            created_at: Some(1000),
10262            content: "duplicate content".into(),
10263            extra: serde_json::json!({}),
10264            snippets: Vec::new(),
10265            invocations: Vec::new(),
10266        };
10267        let conv1 = NormalizedConversation {
10268            agent_slug: "agent1".into(),
10269            external_id: None,
10270            title: None,
10271            workspace: None,
10272            source_path: "path/1".into(),
10273            started_at: None,
10274            ended_at: None,
10275            metadata: serde_json::json!({}),
10276            messages: vec![msg1],
10277        };
10278
10279        let msg2 = NormalizedMessage {
10280            idx: 0,
10281            role: "user".into(),
10282            author: None,
10283            created_at: Some(2000),              // Different timestamp
10284            content: "duplicate content".into(), // SAME content
10285            extra: serde_json::json!({}),
10286            snippets: Vec::new(),
10287            invocations: Vec::new(),
10288        };
10289        let conv2 = NormalizedConversation {
10290            agent_slug: "agent1".into(),
10291            external_id: None,
10292            title: None,
10293            workspace: None,
10294            source_path: "path/2".into(), // Different source path
10295            started_at: None,
10296            ended_at: None,
10297            metadata: serde_json::json!({}),
10298            messages: vec![msg2],
10299        };
10300
10301        index.add_conversation(&conv1).unwrap();
10302        index.add_conversation(&conv2).unwrap();
10303        index.commit().unwrap();
10304
10305        let client = SearchClient::open(index_path, None).unwrap().unwrap();
10306
10307        // Search page 1: limit 1, offset 0
10308        let page1 = client
10309            .search("duplicate", SearchFilters::default(), 1, 0, FieldMask::FULL)
10310            .unwrap();
10311        assert_eq!(page1.len(), 1);
10312
10313        // Search page 2: limit 1, offset 1
10314        let page2 = client
10315            .search("duplicate", SearchFilters::default(), 1, 1, FieldMask::FULL)
10316            .unwrap();
10317
10318        assert_eq!(page2.len(), 1);
10319        assert_ne!(page1[0].source_path, page2[0].source_path);
10320    }
10321
10322    #[test]
10323    fn cache_skips_complex_queries() {
10324        let client = SearchClient {
10325            reader: None,
10326            sqlite: Mutex::new(None),
10327            sqlite_path: None,
10328            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
10329            reload_on_search: true,
10330            last_reload: Mutex::new(None),
10331            last_generation: Mutex::new(None),
10332            reload_epoch: Arc::new(AtomicU64::new(0)),
10333            warm_tx: None,
10334            _warm_handle: None,
10335            metrics: Metrics::default(),
10336            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
10337            semantic: Mutex::new(None),
10338            last_tantivy_total_count: Mutex::new(None),
10339        };
10340
10341        // Wildcard query should skip cache logic entirely (no miss recorded)
10342        let _ = client.search("foo*", SearchFilters::default(), 10, 0, FieldMask::FULL);
10343        let stats = client.cache_stats();
10344        assert_eq!(
10345            stats.cache_miss, 0,
10346            "Wildcard query should not trigger cache miss"
10347        );
10348
10349        // Boolean query should skip cache
10350        let _ = client.search(
10351            "foo OR bar",
10352            SearchFilters::default(),
10353            10,
10354            0,
10355            FieldMask::FULL,
10356        );
10357        let stats = client.cache_stats();
10358        assert_eq!(
10359            stats.cache_miss, 0,
10360            "Boolean query should not trigger cache miss"
10361        );
10362
10363        // Simple query should trigger miss
10364        let _ = client.search("simple", SearchFilters::default(), 10, 0, FieldMask::FULL);
10365        let stats = client.cache_stats();
10366        assert_eq!(
10367            stats.cache_miss, 1,
10368            "Simple query should trigger cache miss"
10369        );
10370    }
10371
10372    #[test]
10373    fn cache_prefix_lookup_handles_utf8_boundaries() {
10374        let client = SearchClient {
10375            reader: None,
10376            sqlite: Mutex::new(None),
10377            sqlite_path: None,
10378            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
10379            reload_on_search: true,
10380            last_reload: Mutex::new(None),
10381            last_generation: Mutex::new(None),
10382            reload_epoch: Arc::new(AtomicU64::new(0)),
10383            warm_tx: None,
10384            _warm_handle: None,
10385            metrics: Metrics::default(),
10386            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
10387            semantic: Mutex::new(None),
10388            last_tantivy_total_count: Mutex::new(None),
10389        };
10390
10391        let hits = vec![SearchHit {
10392            title: "こんにちは".into(),
10393            snippet: String::new(),
10394            content: "こんにちは 世界".into(),
10395            content_hash: stable_content_hash("こんにちは 世界"),
10396            score: 1.0,
10397            source_path: "p".into(),
10398            agent: "a".into(),
10399            workspace: "w".into(),
10400            workspace_original: None,
10401            created_at: None,
10402            line_number: None,
10403            match_type: MatchType::Exact,
10404            source_id: "local".into(),
10405            origin_kind: "local".into(),
10406            origin_host: None,
10407            conversation_id: None,
10408        }];
10409
10410        client.put_cache("こん", &SearchFilters::default(), &hits);
10411
10412        let cached = client
10413            .cached_prefix_hits("こんにちは", &SearchFilters::default())
10414            .unwrap();
10415        assert_eq!(cached.len(), 1);
10416        assert_eq!(cached[0].hit.title, "こんにちは");
10417    }
10418
10419    #[test]
10420    fn bloom_gate_rejects_missing_terms() {
10421        let hit = SearchHit {
10422            title: "hello world".into(),
10423            snippet: "hello world".into(),
10424            content: "hello world".into(),
10425            content_hash: stable_content_hash("hello world"),
10426            score: 1.0,
10427            source_path: "p".into(),
10428            agent: "a".into(),
10429            workspace: "w".into(),
10430            workspace_original: None,
10431            created_at: None,
10432            line_number: None,
10433            match_type: MatchType::Exact,
10434            source_id: "local".into(),
10435            origin_kind: "local".into(),
10436            origin_host: None,
10437            conversation_id: None,
10438        };
10439        let cached = cached_hit_from(&hit);
10440        assert!(hit_matches_query_cached(&cached, "hello"));
10441        assert!(!hit_matches_query_cached(&cached, "missing"));
10442
10443        let metrics = Metrics::default();
10444        metrics.inc_cache_hits();
10445        metrics.inc_cache_miss();
10446        metrics.inc_cache_shortfall();
10447        metrics.inc_reload();
10448        let (hits, miss, shortfall, reloads, _) = metrics.snapshot_all();
10449        assert_eq!((hits, miss, shortfall, reloads), (1, 1, 1, 1));
10450    }
10451
10452    #[test]
10453    fn progressive_lexical_hit_omits_unused_content() {
10454        let hit = SearchHit {
10455            title: "hello world".into(),
10456            snippet: "hello **world**".into(),
10457            content: "hello world from a much larger conversation body".into(),
10458            content_hash: stable_content_hash("hello world from a much larger conversation body"),
10459            score: 1.0,
10460            source_path: "p".into(),
10461            agent: "a".into(),
10462            workspace: "w".into(),
10463            workspace_original: None,
10464            created_at: None,
10465            line_number: Some(3),
10466            match_type: MatchType::Exact,
10467            source_id: "local".into(),
10468            origin_kind: "local".into(),
10469            origin_host: None,
10470            conversation_id: None,
10471        };
10472
10473        let snippet_only =
10474            ProgressiveLexicalHit::from_search_hit(&hit, FieldMask::new(false, true, true, true));
10475        assert_eq!(snippet_only.title, hit.title);
10476        assert_eq!(snippet_only.snippet, hit.snippet);
10477        assert!(
10478            snippet_only.content.is_empty(),
10479            "snippet-only progressive cache should not retain full content"
10480        );
10481        assert_eq!(snippet_only.match_type, hit.match_type);
10482        assert_eq!(snippet_only.line_number, hit.line_number);
10483        assert_eq!(snippet_only.source_path, hit.source_path);
10484        assert_eq!(snippet_only.agent, hit.agent);
10485        assert_eq!(snippet_only.workspace, hit.workspace);
10486
10487        let full =
10488            ProgressiveLexicalHit::from_search_hit(&hit, FieldMask::new(true, true, true, true));
10489        assert_eq!(full.content, hit.content);
10490    }
10491
10492    #[test]
10493    fn progressive_phase_reuses_lexical_cache_without_db_hydration() -> Result<()> {
10494        let client = SearchClient {
10495            reader: None,
10496            sqlite: Mutex::new(None),
10497            sqlite_path: None,
10498            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
10499            reload_on_search: true,
10500            last_reload: Mutex::new(None),
10501            last_generation: Mutex::new(None),
10502            reload_epoch: Arc::new(AtomicU64::new(0)),
10503            warm_tx: None,
10504            _warm_handle: None,
10505            metrics: Metrics::default(),
10506            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
10507            semantic: Mutex::new(None),
10508            last_tantivy_total_count: Mutex::new(None),
10509        };
10510        let field_mask = FieldMask::new(false, true, true, true);
10511        let lexical_hit = SearchHit {
10512            title: "lexical title".into(),
10513            snippet: "lexical snippet".into(),
10514            content: "full lexical body".into(),
10515            content_hash: stable_content_hash("full lexical body"),
10516            score: 0.0,
10517            source_path: "/tmp/session.jsonl".into(),
10518            agent: "codex".into(),
10519            workspace: "/tmp".into(),
10520            workspace_original: Some("/original".into()),
10521            created_at: Some(1_700_000_000_000),
10522            line_number: Some(7),
10523            match_type: MatchType::Exact,
10524            source_id: "local".into(),
10525            origin_kind: "local".into(),
10526            origin_host: None,
10527            conversation_id: None,
10528        };
10529        let mut lexical_cache = ProgressiveLexicalCache::default();
10530        lexical_cache.hits_by_message.insert(
10531            42,
10532            ProgressiveLexicalHit::from_search_hit(&lexical_hit, field_mask),
10533        );
10534
10535        let hash_hex = "00".repeat(32);
10536        let results = vec![FsScoredResult {
10537            doc_id: format!("m|42|0|1|1|1|1|1700000000000|{hash_hex}"),
10538            score: 0.91,
10539            source: FsScoreSource::Lexical,
10540            index: None,
10541            fast_score: None,
10542            quality_score: None,
10543            lexical_score: Some(0.91),
10544            rerank_score: None,
10545            explanation: None,
10546            metadata: None,
10547        }];
10548
10549        let result = client.progressive_phase_to_result(
10550            &results,
10551            ProgressivePhaseContext {
10552                query: "merged title",
10553                filters: &SearchFilters::default(),
10554                field_mask,
10555                lexical_cache: Some(&lexical_cache),
10556                limit: 1,
10557                fetch_limit: 1,
10558            },
10559        )?;
10560
10561        assert_eq!(result.hits.len(), 1);
10562        assert_eq!(result.hits[0].title, lexical_hit.title);
10563        assert_eq!(result.hits[0].snippet, lexical_hit.snippet);
10564        assert!(
10565            result.hits[0].content.is_empty(),
10566            "masked lexical cache should still avoid carrying full content"
10567        );
10568        assert_eq!(result.hits[0].source_path, lexical_hit.source_path);
10569        assert_eq!(result.hits[0].score, 0.91);
10570
10571        Ok(())
10572    }
10573
10574    #[test]
10575    fn search_returns_results_with_filters_and_pagination() -> Result<()> {
10576        let dir = TempDir::new()?;
10577        let mut index = TantivyIndex::open_or_create(dir.path())?;
10578        let conv = NormalizedConversation {
10579            agent_slug: "codex".into(),
10580            external_id: None,
10581            title: Some("hello world convo".into()),
10582            workspace: Some(std::path::PathBuf::from("/tmp/workspace")),
10583            source_path: dir.path().join("rollout-1.jsonl"),
10584            started_at: Some(1_700_000_000_000),
10585            ended_at: None,
10586            metadata: serde_json::json!({}),
10587            messages: vec![NormalizedMessage {
10588                idx: 0,
10589                role: "user".into(),
10590                author: Some("me".into()),
10591                created_at: Some(1_700_000_000_000),
10592                content: "hello rust world".into(),
10593                extra: serde_json::json!({}),
10594                snippets: vec![NormalizedSnippet {
10595                    file_path: None,
10596                    start_line: None,
10597                    end_line: None,
10598                    language: None,
10599                    snippet_text: None,
10600                }],
10601                invocations: Vec::new(),
10602            }],
10603        };
10604        index.add_conversation(&conv)?;
10605        index.commit()?;
10606
10607        let client = SearchClient::open(dir.path(), None)?.expect("index present");
10608        let mut filters = SearchFilters::default();
10609        filters.agents.insert("codex".into());
10610
10611        let hits = client.search("hello", filters, 10, 0, FieldMask::FULL)?;
10612        assert_eq!(hits.len(), 1);
10613        assert_eq!(hits[0].agent, "codex");
10614        assert!(hits[0].snippet.contains("hello"));
10615        Ok(())
10616    }
10617
10618    #[test]
10619    fn search_honors_created_range_and_workspace() -> Result<()> {
10620        let dir = TempDir::new()?;
10621        let mut index = TantivyIndex::open_or_create(dir.path())?;
10622
10623        let conv_a = NormalizedConversation {
10624            agent_slug: "codex".into(),
10625            external_id: None,
10626            title: Some("needle one".into()),
10627            workspace: Some(std::path::PathBuf::from("/ws/a")),
10628            source_path: dir.path().join("a.jsonl"),
10629            started_at: Some(10),
10630            ended_at: None,
10631            metadata: serde_json::json!({}),
10632            messages: vec![NormalizedMessage {
10633                idx: 0,
10634                role: "user".into(),
10635                author: None,
10636                created_at: Some(10),
10637                content: "alpha needle".into(),
10638                extra: serde_json::json!({}),
10639                snippets: vec![NormalizedSnippet {
10640                    file_path: None,
10641                    start_line: None,
10642                    end_line: None,
10643                    language: None,
10644                    snippet_text: None,
10645                }],
10646                invocations: Vec::new(),
10647            }],
10648        };
10649        let conv_b = NormalizedConversation {
10650            agent_slug: "codex".into(),
10651            external_id: None,
10652            title: Some("needle two".into()),
10653            workspace: Some(std::path::PathBuf::from("/ws/b")),
10654            source_path: dir.path().join("b.jsonl"),
10655            started_at: Some(20),
10656            ended_at: None,
10657            metadata: serde_json::json!({}),
10658            messages: vec![NormalizedMessage {
10659                idx: 0,
10660                role: "user".into(),
10661                author: None,
10662                created_at: Some(20),
10663                content: "\nneedle second line".into(),
10664                extra: serde_json::json!({}),
10665                snippets: vec![NormalizedSnippet {
10666                    file_path: None,
10667                    start_line: None,
10668                    end_line: None,
10669                    language: None,
10670                    snippet_text: None,
10671                }],
10672                invocations: Vec::new(),
10673            }],
10674        };
10675        index.add_conversation(&conv_a)?;
10676        index.add_conversation(&conv_b)?;
10677        index.commit()?;
10678
10679        let client = SearchClient::open(dir.path(), None)?.expect("index present");
10680        let mut filters = SearchFilters::default();
10681        filters.workspaces.insert("/ws/b".into());
10682        filters.created_from = Some(15);
10683        filters.created_to = Some(25);
10684
10685        let hits = client.search("needle", filters, 10, 0, FieldMask::FULL)?;
10686        assert_eq!(hits.len(), 1);
10687        assert_eq!(hits[0].workspace, "/ws/b");
10688        assert!(hits[0].snippet.contains("second line"));
10689        Ok(())
10690    }
10691
10692    #[test]
10693    fn pagination_skips_results() -> Result<()> {
10694        let dir = TempDir::new()?;
10695        let mut index = TantivyIndex::open_or_create(dir.path())?;
10696        for i in 0..3 {
10697            let conv = NormalizedConversation {
10698                agent_slug: "codex".into(),
10699                external_id: None,
10700                title: Some(format!("doc-{i}")),
10701                workspace: Some(std::path::PathBuf::from("/ws/p")),
10702                source_path: dir.path().join(format!("{i}.jsonl")),
10703                started_at: Some(100 + i),
10704                ended_at: None,
10705                metadata: serde_json::json!({}),
10706                messages: vec![NormalizedMessage {
10707                    idx: 0,
10708                    role: "user".into(),
10709                    author: None,
10710                    created_at: Some(100 + i),
10711                    // Use unique content for each doc to avoid deduplication
10712                    content: format!("pagination needle document number {i}"),
10713                    extra: serde_json::json!({}),
10714                    snippets: vec![NormalizedSnippet {
10715                        file_path: None,
10716                        start_line: None,
10717                        end_line: None,
10718                        language: None,
10719                        snippet_text: None,
10720                    }],
10721                    invocations: Vec::new(),
10722                }],
10723            };
10724            index.add_conversation(&conv)?;
10725        }
10726        index.commit()?;
10727
10728        let client = SearchClient::open(dir.path(), None)?.expect("index present");
10729        let hits = client.search(
10730            "pagination",
10731            SearchFilters::default(),
10732            1,
10733            1,
10734            FieldMask::FULL,
10735        )?;
10736        assert_eq!(hits.len(), 1);
10737        Ok(())
10738    }
10739
10740    #[test]
10741    fn search_matches_hyphenated_term() -> Result<()> {
10742        let dir = TempDir::new()?;
10743        let mut index = TantivyIndex::open_or_create(dir.path())?;
10744        let conv = NormalizedConversation {
10745            agent_slug: "codex".into(),
10746            external_id: None,
10747            title: Some("cma-es notes".into()),
10748            workspace: Some(std::path::PathBuf::from("/tmp/workspace")),
10749            source_path: dir.path().join("rollout-1.jsonl"),
10750            started_at: Some(1_700_000_000_000),
10751            ended_at: None,
10752            metadata: serde_json::json!({}),
10753            messages: vec![NormalizedMessage {
10754                idx: 0,
10755                role: "user".into(),
10756                author: Some("me".into()),
10757                created_at: Some(1_700_000_000_000),
10758                content: "Need CMA-ES strategy and CMA ES variants".into(),
10759                extra: serde_json::json!({}),
10760                snippets: vec![NormalizedSnippet {
10761                    file_path: None,
10762                    start_line: None,
10763                    end_line: None,
10764                    language: None,
10765                    snippet_text: None,
10766                }],
10767                invocations: Vec::new(),
10768            }],
10769        };
10770        index.add_conversation(&conv)?;
10771        index.commit()?;
10772
10773        let client = SearchClient::open(dir.path(), None)?.expect("index present");
10774        let hits = client.search("cma-es", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
10775        assert_eq!(hits.len(), 1);
10776        assert!(hits[0].snippet.to_lowercase().contains("cma"));
10777        Ok(())
10778    }
10779
10780    #[test]
10781    fn search_matches_prefix_edge_ngram() -> Result<()> {
10782        let dir = TempDir::new()?;
10783        let mut index = TantivyIndex::open_or_create(dir.path())?;
10784        let conv = NormalizedConversation {
10785            agent_slug: "codex".into(),
10786            external_id: None,
10787            title: Some("math logic".into()),
10788            workspace: Some(std::path::PathBuf::from("/ws/m")),
10789            source_path: dir.path().join("math.jsonl"),
10790            started_at: Some(1000),
10791            ended_at: None,
10792            metadata: serde_json::json!({}),
10793            messages: vec![NormalizedMessage {
10794                idx: 0,
10795                role: "user".into(),
10796                author: None,
10797                created_at: Some(1000),
10798                content: "please calculate the entropy".into(),
10799                extra: serde_json::json!({}),
10800                snippets: vec![],
10801                invocations: Vec::new(),
10802            }],
10803        };
10804        index.add_conversation(&conv)?;
10805        index.commit()?;
10806
10807        let client = SearchClient::open(dir.path(), None)?.expect("index present");
10808
10809        // "cal" should match "calculate"
10810        let hits = client.search("cal", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
10811        assert_eq!(hits.len(), 1);
10812        assert!(hits[0].content.contains("calculate"));
10813
10814        // "entr" should match "entropy"
10815        let hits = client.search("entr", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
10816        assert_eq!(hits.len(), 1);
10817
10818        Ok(())
10819    }
10820
10821    #[test]
10822    fn search_matches_snake_case() -> Result<()> {
10823        let dir = TempDir::new()?;
10824        let mut index = TantivyIndex::open_or_create(dir.path())?;
10825        let conv = NormalizedConversation {
10826            agent_slug: "codex".into(),
10827            external_id: None,
10828            title: Some("code".into()),
10829            workspace: None,
10830            source_path: dir.path().join("c.jsonl"),
10831            started_at: Some(1),
10832            ended_at: None,
10833            metadata: serde_json::json!({}),
10834            messages: vec![NormalizedMessage {
10835                idx: 0,
10836                role: "user".into(),
10837                author: None,
10838                created_at: Some(1),
10839                content: "check the my_variable_name please".into(),
10840                extra: serde_json::json!({}),
10841                snippets: vec![],
10842                invocations: Vec::new(),
10843            }],
10844        };
10845        index.add_conversation(&conv)?;
10846        index.commit()?;
10847
10848        let client = SearchClient::open(dir.path(), None)?.expect("index present");
10849
10850        // "vari" should match "variable" inside "my_variable_name"
10851        let hits = client.search("vari", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
10852        assert_eq!(hits.len(), 1);
10853
10854        // "my_variable" should match "my_variable_name" (because it splits to "my variable")
10855        let hits = client.search(
10856            "my_variable",
10857            SearchFilters::default(),
10858            10,
10859            0,
10860            FieldMask::FULL,
10861        )?;
10862        assert_eq!(hits.len(), 1);
10863
10864        Ok(())
10865    }
10866
10867    #[test]
10868    fn search_matches_symbols_stripped() -> Result<()> {
10869        let dir = TempDir::new()?;
10870        let mut index = TantivyIndex::open_or_create(dir.path())?;
10871        let conv = NormalizedConversation {
10872            agent_slug: "codex".into(),
10873            external_id: None,
10874            title: Some("symbols".into()),
10875            workspace: None,
10876            source_path: dir.path().join("s.jsonl"),
10877            started_at: Some(1),
10878            ended_at: None,
10879            metadata: serde_json::json!({}),
10880            messages: vec![NormalizedMessage {
10881                idx: 0,
10882                role: "user".into(),
10883                author: None,
10884                created_at: Some(1),
10885                content: "working with c++ and foo.bar today".into(),
10886                extra: serde_json::json!({}),
10887                snippets: vec![],
10888                invocations: Vec::new(),
10889            }],
10890        };
10891        index.add_conversation(&conv)?;
10892        index.commit()?;
10893
10894        let client = SearchClient::open(dir.path(), None)?.expect("index present");
10895
10896        // "c++" -> "c"
10897        let hits = client.search("c++", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
10898        assert_eq!(hits.len(), 1);
10899
10900        // "foo.bar" -> "foo", "bar"
10901        let hits = client.search("foo.bar", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
10902        assert_eq!(hits.len(), 1);
10903
10904        Ok(())
10905    }
10906
10907    #[test]
10908    fn search_sets_match_type_for_wildcards() -> Result<()> {
10909        let dir = TempDir::new()?;
10910        let mut index = TantivyIndex::open_or_create(dir.path())?;
10911
10912        let conv = NormalizedConversation {
10913            agent_slug: "codex".into(),
10914            external_id: None,
10915            title: Some("handlers".into()),
10916            workspace: None,
10917            source_path: dir.path().join("h.jsonl"),
10918            started_at: Some(1),
10919            ended_at: None,
10920            metadata: serde_json::json!({}),
10921            messages: vec![NormalizedMessage {
10922                idx: 0,
10923                role: "user".into(),
10924                author: None,
10925                created_at: Some(1),
10926                content: "the request handler delegates".into(),
10927                extra: serde_json::json!({}),
10928                snippets: vec![],
10929                invocations: Vec::new(),
10930            }],
10931        };
10932        index.add_conversation(&conv)?;
10933        index.commit()?;
10934
10935        let client = SearchClient::open(dir.path(), None)?.expect("index present");
10936
10937        let exact = client.search("handler", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
10938        assert_eq!(exact[0].match_type, MatchType::Exact);
10939
10940        let prefix = client.search("hand*", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
10941        assert_eq!(prefix[0].match_type, MatchType::Prefix);
10942
10943        let suffix = client.search("*handler", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
10944        assert_eq!(suffix[0].match_type, MatchType::Suffix);
10945
10946        let substring =
10947            client.search("*andle*", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
10948        assert_eq!(substring[0].match_type, MatchType::Substring);
10949
10950        Ok(())
10951    }
10952
10953    #[test]
10954    fn search_with_fallback_marks_implicit_wildcard() -> Result<()> {
10955        let dir = TempDir::new()?;
10956        let mut index = TantivyIndex::open_or_create(dir.path())?;
10957
10958        let conv = NormalizedConversation {
10959            agent_slug: "codex".into(),
10960            external_id: None,
10961            title: Some("handlers".into()),
10962            workspace: None,
10963            source_path: dir.path().join("h2.jsonl"),
10964            started_at: Some(1),
10965            ended_at: None,
10966            metadata: serde_json::json!({}),
10967            messages: vec![NormalizedMessage {
10968                idx: 0,
10969                role: "user".into(),
10970                author: None,
10971                created_at: Some(1),
10972                content: "the request handler delegates".into(),
10973                extra: serde_json::json!({}),
10974                snippets: vec![],
10975                invocations: Vec::new(),
10976            }],
10977        };
10978        index.add_conversation(&conv)?;
10979        index.commit()?;
10980
10981        let client = SearchClient::open(dir.path(), None)?.expect("index present");
10982
10983        // Base search for "andle" finds nothing; fallback "*andle*" should hit and mark implicit.
10984        let result = client.search_with_fallback(
10985            "andle",
10986            SearchFilters::default(),
10987            10,
10988            0,
10989            2,
10990            FieldMask::FULL,
10991        )?;
10992        assert!(result.wildcard_fallback);
10993        assert_eq!(result.hits.len(), 1);
10994        assert_eq!(result.hits[0].match_type, MatchType::ImplicitWildcard);
10995
10996        Ok(())
10997    }
10998
10999    #[test]
11000    fn sqlite_backend_skips_wildcard_queries() -> Result<()> {
11001        // Build a client with SQLite only; wildcard queries should short-circuit without errors.
11002        let conn = Connection::open(":memory:")?;
11003        let client = SearchClient {
11004            reader: None,
11005            sqlite: Mutex::new(Some(SendConnection(conn))),
11006            sqlite_path: None,
11007            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
11008            reload_on_search: true,
11009            last_reload: Mutex::new(None),
11010            last_generation: Mutex::new(None),
11011            reload_epoch: Arc::new(AtomicU64::new(0)),
11012            warm_tx: None,
11013            _warm_handle: None,
11014            metrics: Metrics::default(),
11015            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
11016            semantic: Mutex::new(None),
11017            last_tantivy_total_count: Mutex::new(None),
11018        };
11019
11020        let hits = client.search("*handler", SearchFilters::default(), 5, 0, FieldMask::FULL)?;
11021        assert!(
11022            hits.is_empty(),
11023            "wildcard should skip sqlite fallback, not error"
11024        );
11025
11026        Ok(())
11027    }
11028
11029    #[test]
11030    fn sqlite_backend_handles_null_workspace() -> Result<()> {
11031        let conn = Connection::open(":memory:")?;
11032        conn.execute_batch(
11033            "CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
11034             CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL UNIQUE);
11035             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE);
11036             CREATE TABLE conversations (
11037                id INTEGER PRIMARY KEY,
11038                agent_id INTEGER,
11039                workspace_id INTEGER,
11040                source_id TEXT,
11041                origin_host TEXT,
11042                title TEXT,
11043                source_path TEXT
11044             );
11045             CREATE TABLE messages (
11046                id INTEGER PRIMARY KEY,
11047                conversation_id INTEGER,
11048                idx INTEGER,
11049                content TEXT,
11050                created_at INTEGER
11051             );
11052             CREATE VIRTUAL TABLE fts_messages USING fts5(
11053                content,
11054                title,
11055                agent,
11056                workspace,
11057                source_path,
11058                created_at UNINDEXED,
11059                content='',
11060                tokenize='porter'
11061             );",
11062        )?;
11063        conn.execute("INSERT INTO sources(id, kind) VALUES('local', 'local')")?;
11064        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
11065        conn.execute(
11066            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path) VALUES(1, 1, NULL, 'local', NULL, 't', '/tmp/session.jsonl')",
11067        )?;
11068        conn.execute("INSERT INTO messages(id, conversation_id, idx, content, created_at) VALUES(1, 1, 0, 'auth token failure', 42)")?;
11069        conn.execute_compat(
11070            "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at)
11071             VALUES(?1, ?2, ?3, ?4, NULL, ?5, ?6)",
11072            params![
11073                1_i64,
11074                "auth token failure",
11075                "t",
11076                "codex",
11077                "/tmp/session.jsonl",
11078                42_i64
11079            ],
11080        )?;
11081
11082        let client = SearchClient {
11083            reader: None,
11084            sqlite: Mutex::new(Some(SendConnection(conn))),
11085            sqlite_path: None,
11086            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
11087            reload_on_search: true,
11088            last_reload: Mutex::new(None),
11089            last_generation: Mutex::new(None),
11090            reload_epoch: Arc::new(AtomicU64::new(0)),
11091            warm_tx: None,
11092            _warm_handle: None,
11093            metrics: Metrics::default(),
11094            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
11095            semantic: Mutex::new(None),
11096            last_tantivy_total_count: Mutex::new(None),
11097        };
11098
11099        let hits = client.search("auth", SearchFilters::default(), 5, 0, FieldMask::FULL)?;
11100        assert_eq!(hits.len(), 1);
11101        assert_eq!(hits[0].workspace, "");
11102        assert_eq!(hits[0].line_number, Some(1));
11103        assert_eq!(hits[0].source_id, "local");
11104        assert_eq!(hits[0].origin_kind, "local");
11105        Ok(())
11106    }
11107
11108    #[test]
11109    fn sqlite_backend_supports_legacy_fts_message_id_schema() -> Result<()> {
11110        let conn = Connection::open(":memory:")?;
11111        conn.execute_batch(
11112            "CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
11113             CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL UNIQUE);
11114             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE);
11115             CREATE TABLE conversations (
11116                id INTEGER PRIMARY KEY,
11117                agent_id INTEGER,
11118                workspace_id INTEGER,
11119                source_id TEXT,
11120                origin_host TEXT,
11121                title TEXT,
11122                source_path TEXT
11123             );
11124             CREATE TABLE messages (
11125                id INTEGER PRIMARY KEY,
11126                conversation_id INTEGER,
11127                idx INTEGER,
11128                content TEXT,
11129                created_at INTEGER
11130             );
11131             CREATE VIRTUAL TABLE fts_messages USING fts5(
11132                content,
11133                title,
11134                agent,
11135                workspace,
11136                source_path,
11137                created_at UNINDEXED,
11138                message_id UNINDEXED,
11139                tokenize='porter'
11140             );",
11141        )?;
11142        conn.execute("INSERT INTO sources(id, kind) VALUES('local', 'local')")?;
11143        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
11144        conn.execute("INSERT INTO workspaces(id, path) VALUES(1, '/legacy')")?;
11145        conn.execute(
11146            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
11147             VALUES(1, 1, 1, 'local', NULL, 'legacy title', '/tmp/legacy.jsonl')",
11148        )?;
11149        conn.execute(
11150            "INSERT INTO messages(id, conversation_id, idx, content, created_at)
11151             VALUES(42, 1, 4, 'legacy auth token failure', 99)",
11152        )?;
11153        conn.execute_compat(
11154            "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at, message_id)
11155             VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
11156            params![
11157                1_i64,
11158                "legacy auth token failure",
11159                "legacy title",
11160                "codex",
11161                "/legacy",
11162                "/tmp/legacy.jsonl",
11163                99_i64,
11164                42_i64
11165            ],
11166        )?;
11167
11168        let client = SearchClient {
11169            reader: None,
11170            sqlite: Mutex::new(Some(SendConnection(conn))),
11171            sqlite_path: None,
11172            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
11173            reload_on_search: true,
11174            last_reload: Mutex::new(None),
11175            last_generation: Mutex::new(None),
11176            reload_epoch: Arc::new(AtomicU64::new(0)),
11177            warm_tx: None,
11178            _warm_handle: None,
11179            metrics: Metrics::default(),
11180            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
11181            semantic: Mutex::new(None),
11182            last_tantivy_total_count: Mutex::new(None),
11183        };
11184
11185        let hits = client.search("auth", SearchFilters::default(), 5, 0, FieldMask::FULL)?;
11186        assert_eq!(hits.len(), 1);
11187        assert_eq!(hits[0].title, "legacy title");
11188        assert_eq!(hits[0].source_path, "/tmp/legacy.jsonl");
11189        assert_eq!(hits[0].workspace, "/legacy");
11190        assert_eq!(hits[0].line_number, Some(5));
11191        assert_eq!(hits[0].content, "legacy auth token failure");
11192        Ok(())
11193    }
11194
11195    #[test]
11196    fn tantivy_reader_skips_sqlite_fallback_on_empty_lexical_results() -> Result<()> {
11197        let dir = TempDir::new()?;
11198        let mut index = TantivyIndex::open_or_create(dir.path())?;
11199        index.commit()?;
11200        let reader = fs_cass_open_search_reader(dir.path(), ReloadPolicy::Manual).ok();
11201        assert!(
11202            reader.is_some(),
11203            "test fixture should open a Tantivy reader even with an empty index"
11204        );
11205
11206        let conn = Connection::open(":memory:")?;
11207        conn.execute_batch(
11208            "CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
11209             CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL UNIQUE);
11210             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE);
11211             CREATE TABLE conversations (
11212                id INTEGER PRIMARY KEY,
11213                agent_id INTEGER,
11214                workspace_id INTEGER,
11215                source_id TEXT,
11216                origin_host TEXT,
11217                title TEXT,
11218                source_path TEXT
11219             );
11220             CREATE TABLE messages (
11221                id INTEGER PRIMARY KEY,
11222                conversation_id INTEGER,
11223                idx INTEGER,
11224                content TEXT,
11225                created_at INTEGER
11226             );
11227             CREATE VIRTUAL TABLE fts_messages USING fts5(
11228                content,
11229                title,
11230                agent,
11231                workspace,
11232                source_path,
11233                created_at UNINDEXED,
11234                content='',
11235                tokenize='porter'
11236             );",
11237        )?;
11238        conn.execute("INSERT INTO sources(id, kind) VALUES('local', 'local')")?;
11239        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
11240        conn.execute("INSERT INTO workspaces(id, path) VALUES(1, '/sqlite-only')")?;
11241        conn.execute(
11242            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
11243             VALUES(1, 1, 1, 'local', NULL, 'sqlite fallback only', '/tmp/sqlite-only.jsonl')",
11244        )?;
11245        conn.execute(
11246            "INSERT INTO messages(id, conversation_id, idx, content, created_at)
11247             VALUES(1, 1, 0, 'sqliteonlytoken overflow candidate', 42)",
11248        )?;
11249        conn.execute_compat(
11250            "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at)
11251             VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)",
11252            params![
11253                1_i64,
11254                "sqliteonlytoken overflow candidate",
11255                "sqlite fallback only",
11256                "codex",
11257                "/sqlite-only",
11258                "/tmp/sqlite-only.jsonl",
11259                42_i64
11260            ],
11261        )?;
11262
11263        let client = SearchClient {
11264            reader,
11265            sqlite: Mutex::new(Some(SendConnection(conn))),
11266            sqlite_path: None,
11267            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
11268            reload_on_search: true,
11269            last_reload: Mutex::new(None),
11270            last_generation: Mutex::new(None),
11271            reload_epoch: Arc::new(AtomicU64::new(0)),
11272            warm_tx: None,
11273            _warm_handle: None,
11274            metrics: Metrics::default(),
11275            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
11276            semantic: Mutex::new(None),
11277            last_tantivy_total_count: Mutex::new(None),
11278        };
11279
11280        let sqlite_hits = client.search_sqlite_fts5(
11281            Path::new(":memory:"),
11282            "sqliteonlytoken",
11283            SearchFilters::default(),
11284            5,
11285            0,
11286            FieldMask::FULL,
11287        )?;
11288        assert_eq!(
11289            sqlite_hits.len(),
11290            1,
11291            "fixture should prove sqlite fallback would have produced a hit"
11292        );
11293
11294        let tantivy_authoritative_hits = client.search(
11295            "sqliteonlytoken",
11296            SearchFilters::default(),
11297            5,
11298            0,
11299            FieldMask::FULL,
11300        )?;
11301        assert!(
11302            tantivy_authoritative_hits.is_empty(),
11303            "a live Tantivy reader should prevent sqlite fallback from populating empty lexical results"
11304        );
11305        Ok(())
11306    }
11307
11308    #[test]
11309    fn sqlite_guard_does_not_repair_fts_when_generation_key_stale() -> Result<()> {
11310        let temp_dir = TempDir::new()?;
11311        let db_path = temp_dir.path().join("stale-gen-fts.db");
11312
11313        // Seed a DB with a conversation and indexed FTS content.
11314        {
11315            let storage = FrankenStorage::open(&db_path)?;
11316            let agent = Agent {
11317                id: None,
11318                slug: "codex".into(),
11319                name: "Codex".into(),
11320                version: None,
11321                kind: AgentKind::Cli,
11322            };
11323            let agent_id = storage.ensure_agent(&agent)?;
11324            let conversation = Conversation {
11325                id: None,
11326                agent_slug: "codex".into(),
11327                workspace: Some(PathBuf::from("/tmp/workspace")),
11328                external_id: Some("stale-gen-fts".into()),
11329                title: Some("Stale FTS generation".into()),
11330                source_path: PathBuf::from("/tmp/stale-gen-fts.jsonl"),
11331                started_at: Some(1_700_000_000_000),
11332                ended_at: Some(1_700_000_000_100),
11333                approx_tokens: Some(42),
11334                metadata_json: serde_json::Value::Null,
11335                messages: vec![Message {
11336                    id: None,
11337                    idx: 0,
11338                    role: MessageRole::User,
11339                    author: Some("user".into()),
11340                    created_at: Some(1_700_000_000_050),
11341                    content: "message that should remain queryable".into(),
11342                    extra_json: serde_json::Value::Null,
11343                    snippets: Vec::new(),
11344                }],
11345                source_id: "local".into(),
11346                origin_host: None,
11347            };
11348            storage.insert_conversation_tree(agent_id, None, &conversation)?;
11349        }
11350
11351        let count_before = sqlite_master_name_count(&db_path, "fts_messages")
11352            .context("count schema rows before generation key deletion")?;
11353
11354        // Simulate a stale generation by deleting the rebuild marker.
11355        // This is the condition ensure_fts_consistency_via_frankensqlite
11356        // detects to trigger a full FTS rebuild.
11357        {
11358            let conn = FrankenConnection::open(db_path.to_string_lossy().into_owned())?;
11359            conn.execute_compat(
11360                "DELETE FROM meta WHERE key = ?1",
11361                &[ParamValue::from("fts_frankensqlite_rebuild_generation")],
11362            )?;
11363        }
11364
11365        // Opening via sqlite_guard() must remain read-only. A search path
11366        // should not trigger heavyweight derived-index repair.
11367        let client = SearchClient {
11368            reader: None,
11369            sqlite: Mutex::new(None),
11370            sqlite_path: Some(db_path.clone()),
11371            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
11372            reload_on_search: true,
11373            last_reload: Mutex::new(None),
11374            last_generation: Mutex::new(None),
11375            reload_epoch: Arc::new(AtomicU64::new(0)),
11376            warm_tx: None,
11377            _warm_handle: None,
11378            metrics: Metrics::default(),
11379            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
11380            semantic: Mutex::new(None),
11381            last_tantivy_total_count: Mutex::new(None),
11382        };
11383
11384        let guard = client
11385            .sqlite_guard()
11386            .context("open sqlite guard for stale generation fixture")?;
11387        assert!(guard.is_some(), "sqlite guard should open the db");
11388        let conn = guard
11389            .as_ref()
11390            .expect("sqlite guard should hold a connection");
11391        let no_params: [ParamValue; 0] = [];
11392        let cache_size: i64 =
11393            conn.query_row_map("PRAGMA cache_size;", &no_params, |row| row.get_typed(0))?;
11394        assert_eq!(
11395            cache_size, -SEARCH_SQLITE_HYDRATION_CACHE_KIB,
11396            "search hydration should not inherit the general storage cache profile"
11397        );
11398        drop(guard);
11399
11400        // The read-only open must not rewrite the rebuild-generation marker.
11401        let conn = FrankenConnection::open(db_path.to_string_lossy().into_owned())?;
11402        let generation_after: Option<String> = conn
11403            .query_row_map(
11404                "SELECT value FROM meta WHERE key = ?1",
11405                &[ParamValue::from("fts_frankensqlite_rebuild_generation")],
11406                |row| row.get_typed(0),
11407            )
11408            .optional()?;
11409        assert!(
11410            generation_after.is_none(),
11411            "search sqlite guard must not mutate FTS rebuild metadata"
11412        );
11413
11414        // Schema rows remain unchanged by the read-only open.
11415        let count_after = sqlite_master_name_count(&db_path, "fts_messages")
11416            .context("count schema rows after sqlite guard reopen")?;
11417        assert_eq!(
11418            count_after, count_before,
11419            "read-only reopen must leave FTS schema state unchanged"
11420        );
11421
11422        Ok(())
11423    }
11424
11425    #[test]
11426    fn sqlite_path_rusqlite_fallback_matches_hyphenated_ids_with_workspace_filter() -> Result<()> {
11427        fn fts_match_count(conn: &FrankenConnection, fts_query: &str) -> Result<Option<usize>> {
11428            let match_mode = SearchClient::sqlite_fts_match_mode(conn)?;
11429            let sql = format!(
11430                "SELECT COUNT(*) FROM fts_messages WHERE {}",
11431                SearchClient::sqlite_fts5_match_clause(match_mode)
11432            );
11433            let mut params = Vec::new();
11434            SearchClient::push_sqlite_fts5_match_params(&mut params, fts_query, match_mode);
11435            match franken_query_map_collect_retry(conn, &sql, &params, |row| row.get_typed(0)) {
11436                Ok(rows) => {
11437                    let count: i64 = rows.into_iter().next().unwrap_or(0);
11438                    Ok(Some(usize::try_from(count.max(0)).unwrap_or(usize::MAX)))
11439                }
11440                Err(err) if err.to_string().contains("no such function: MATCH/2") => Ok(None),
11441                Err(err) => Err(err.into()),
11442            }
11443        }
11444
11445        let temp_dir = TempDir::new()?;
11446        let db_path = temp_dir.path().join("hyphenated-rusqlite-fallback.db");
11447
11448        {
11449            let storage = FrankenStorage::open(&db_path)?;
11450            // V14 drops fts_messages during migration — run the lazy repair
11451            // so the direct INSERT INTO fts_messages below can land.
11452            storage.ensure_search_fallback_fts_consistency()?;
11453            let conn = storage.raw();
11454            conn.execute(
11455                "INSERT INTO agents(id, slug, name, kind, created_at, updated_at)
11456                 VALUES(1, 'codex', 'Codex', 'codex', 1, 1)",
11457            )?;
11458            conn.execute("INSERT INTO workspaces(id, path) VALUES(1, '/ws/alpha')")?;
11459            conn.execute("INSERT INTO workspaces(id, path) VALUES(2, '/ws/beta')")?;
11460            conn.execute(
11461                "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
11462                 VALUES(1, 1, 1, 'local', NULL, 'alpha bead', '/tmp/alpha.jsonl')",
11463            )?;
11464            conn.execute(
11465                "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
11466                 VALUES(2, 1, 2, 'local', NULL, 'beta bead', '/tmp/beta.jsonl')",
11467            )?;
11468            conn.execute(
11469                "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
11470                 VALUES(11, 1, 0, 'user', 'Need follow-up on br-123 root cause', 100)",
11471            )?;
11472            conn.execute(
11473                "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
11474                 VALUES(12, 2, 0, 'user', 'Need follow-up on br-123 user report', 101)",
11475            )?;
11476            conn.execute_compat(
11477                "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at)
11478                 VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)",
11479                &[
11480                    ParamValue::from(11_i64),
11481                    ParamValue::from("Need follow-up on br-123 root cause"),
11482                    ParamValue::from("alpha bead"),
11483                    ParamValue::from("codex"),
11484                    ParamValue::from("/ws/alpha"),
11485                    ParamValue::from("/tmp/alpha.jsonl"),
11486                    ParamValue::from(100_i64),
11487                ],
11488            )?;
11489            conn.execute_compat(
11490                "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at)
11491                 VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)",
11492                &[
11493                    ParamValue::from(12_i64),
11494                    ParamValue::from("Need follow-up on br-123 user report"),
11495                    ParamValue::from("beta bead"),
11496                    ParamValue::from("codex"),
11497                    ParamValue::from("/ws/beta"),
11498                    ParamValue::from("/tmp/beta.jsonl"),
11499                    ParamValue::from(101_i64),
11500                ],
11501            )?;
11502            let preclose_total_rows: i64 =
11503                conn.query_row_map("SELECT COUNT(*) FROM fts_messages", params![], |row| {
11504                    row.get_typed(0)
11505                })?;
11506            assert_eq!(
11507                preclose_total_rows, 2,
11508                "freshly seeded file-backed FTS should retain the inserted rows"
11509            );
11510            let transpiled = transpile_to_fts5("br-123").expect("transpiled fallback query");
11511            if let Some(match_count) = fts_match_count(conn, transpiled.as_str())? {
11512                assert_eq!(
11513                    match_count, 2,
11514                    "freshly seeded file-backed FTS should match the transpiled hyphenated query before reopen"
11515                );
11516            }
11517        }
11518
11519        let client = SearchClient {
11520            reader: None,
11521            sqlite: Mutex::new(None),
11522            sqlite_path: Some(db_path),
11523            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
11524            reload_on_search: true,
11525            last_reload: Mutex::new(None),
11526            last_generation: Mutex::new(None),
11527            reload_epoch: Arc::new(AtomicU64::new(0)),
11528            warm_tx: None,
11529            _warm_handle: None,
11530            metrics: Metrics::default(),
11531            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
11532            semantic: Mutex::new(None),
11533            last_tantivy_total_count: Mutex::new(None),
11534        };
11535
11536        let guard = client.sqlite_guard()?;
11537        let conn = guard.as_ref().expect("sqlite guard should reopen file db");
11538        let reopened_total_rows: i64 =
11539            conn.query_row_map("SELECT COUNT(*) FROM fts_messages", params![], |row| {
11540                row.get_typed(0)
11541            })?;
11542        assert_eq!(
11543            reopened_total_rows, 2,
11544            "reopened file-backed FTS should still contain the seeded rows"
11545        );
11546        let transpiled = transpile_to_fts5("br-123").expect("transpiled fallback query");
11547        if let Some(match_count) = fts_match_count(conn, transpiled.as_str())? {
11548            assert_eq!(
11549                match_count, 2,
11550                "reopened file-backed FTS should still match the transpiled hyphenated query"
11551            );
11552        }
11553        drop(guard);
11554
11555        let all_hits = client.search("br-123", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
11556        assert_eq!(all_hits.len(), 2);
11557        assert!(
11558            all_hits.iter().all(|hit| hit.content.contains("br-123")),
11559            "hyphenated bead IDs should survive the file-backed sqlite fallback path"
11560        );
11561
11562        let leading_or_hits = client.search(
11563            "OR br-123",
11564            SearchFilters::default(),
11565            10,
11566            0,
11567            FieldMask::FULL,
11568        )?;
11569        assert_eq!(leading_or_hits.len(), 2);
11570
11571        let dotted_hits = client.search(
11572            "br-123.jsonl",
11573            SearchFilters::default(),
11574            10,
11575            0,
11576            FieldMask::FULL,
11577        )?;
11578        assert_eq!(dotted_hits.len(), 2);
11579
11580        let dotted_prefix_hits = client.search(
11581            "br-123.json*",
11582            SearchFilters::default(),
11583            10,
11584            0,
11585            FieldMask::FULL,
11586        )?;
11587        assert_eq!(dotted_prefix_hits.len(), 2);
11588
11589        let prefix_hits =
11590            client.search("br-12*", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
11591        assert_eq!(prefix_hits.len(), 2);
11592
11593        let filtered_hits = client.search(
11594            "br-123",
11595            SearchFilters {
11596                workspaces: HashSet::from_iter(["/ws/beta".to_string()]),
11597                ..SearchFilters::default()
11598            },
11599            10,
11600            0,
11601            FieldMask::FULL,
11602        )?;
11603        assert_eq!(filtered_hits.len(), 1);
11604        assert_eq!(filtered_hits[0].workspace, "/ws/beta");
11605        assert_eq!(filtered_hits[0].source_path, "/tmp/beta.jsonl");
11606        assert!(filtered_hits[0].content.contains("br-123"));
11607
11608        Ok(())
11609    }
11610
11611    #[test]
11612    fn sqlite_backend_orders_hits_by_bm25_score() -> Result<()> {
11613        let conn = Connection::open(":memory:")?;
11614        conn.execute_batch(
11615            "CREATE TABLE conversations (
11616                id INTEGER PRIMARY KEY,
11617                agent_id INTEGER,
11618                workspace_id INTEGER,
11619                source_id TEXT,
11620                origin_host TEXT,
11621                title TEXT,
11622                source_path TEXT
11623             );
11624             CREATE TABLE messages (
11625                id INTEGER PRIMARY KEY,
11626                conversation_id INTEGER,
11627                idx INTEGER,
11628                content TEXT,
11629                created_at INTEGER
11630             );
11631             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
11632             CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL UNIQUE);
11633             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE);
11634             CREATE VIRTUAL TABLE fts_messages USING fts5(
11635                content,
11636                title,
11637                agent,
11638                workspace,
11639                source_path,
11640                created_at UNINDEXED,
11641                content='',
11642                tokenize='porter'
11643             );",
11644        )?;
11645        conn.execute("INSERT INTO sources(id, kind) VALUES('local', 'local')")?;
11646        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
11647        conn.execute("INSERT INTO workspaces(id, path) VALUES(1, '/ws')")?;
11648        conn.execute(
11649            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path) VALUES(1, 1, 1, 'local', NULL, 'best', '/tmp/best.jsonl')",
11650        )?;
11651        conn.execute(
11652            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path) VALUES(2, 1, 1, 'local', NULL, 'worse', '/tmp/worse.jsonl')",
11653        )?;
11654        conn.execute("INSERT INTO messages(id, conversation_id, idx, content, created_at) VALUES(7, 1, 0, 'auth auth auth failure', 42)")?;
11655        conn.execute("INSERT INTO messages(id, conversation_id, idx, content, created_at) VALUES(8, 2, 0, 'auth failure', 43)")?;
11656        conn.execute_compat(
11657            "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at)
11658             VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)",
11659            params![
11660                7_i64,
11661                "auth auth auth failure",
11662                "best",
11663                "codex",
11664                "/ws",
11665                "/tmp/best.jsonl",
11666                42_i64
11667            ],
11668        )?;
11669        conn.execute_compat(
11670            "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at)
11671             VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)",
11672            params![
11673                8_i64,
11674                "auth failure",
11675                "worse",
11676                "codex",
11677                "/ws",
11678                "/tmp/worse.jsonl",
11679                43_i64
11680            ],
11681        )?;
11682        let client = SearchClient {
11683            reader: None,
11684            sqlite: Mutex::new(Some(SendConnection(conn))),
11685            sqlite_path: None,
11686            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
11687            reload_on_search: true,
11688            last_reload: Mutex::new(None),
11689            last_generation: Mutex::new(None),
11690            reload_epoch: Arc::new(AtomicU64::new(0)),
11691            warm_tx: None,
11692            _warm_handle: None,
11693            metrics: Metrics::default(),
11694            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
11695            semantic: Mutex::new(None),
11696            last_tantivy_total_count: Mutex::new(None),
11697        };
11698        let direct_hits = client.search_sqlite_fts5(
11699            Path::new(":memory:"),
11700            "auth",
11701            SearchFilters::default(),
11702            5,
11703            0,
11704            FieldMask::FULL,
11705        )?;
11706        assert_eq!(direct_hits.len(), 2);
11707
11708        let hits = client.search("auth", SearchFilters::default(), 5, 0, FieldMask::FULL)?;
11709        assert_eq!(hits.len(), 2);
11710        assert_eq!(hits[0].title, "best");
11711        assert_eq!(hits[1].title, "worse");
11712        assert!(hits[0].score > hits[1].score);
11713
11714        Ok(())
11715    }
11716
11717    #[test]
11718    fn sqlite_fts5_ranked_phase_defers_content_decode_until_after_limit() {
11719        let (rank_sql, params) = SearchClient::sqlite_fts5_rank_query(
11720            "auth",
11721            &SearchFilters::default(),
11722            50,
11723            0,
11724            false,
11725            SqliteFtsMatchMode::Table,
11726        );
11727        let hydrate_sql = SearchClient::sqlite_fts5_hydrate_query(
11728            2,
11729            FieldMask::new(true, true, true, true),
11730            false,
11731        );
11732
11733        assert!(
11734            !rank_sql.contains("fts_messages.content"),
11735            "rank query must not decode large content rows before LIMIT"
11736        );
11737        assert!(
11738            hydrate_sql.contains("fts_messages.content"),
11739            "hydration query should still provide requested content"
11740        );
11741        assert!(
11742            rank_sql.contains("LIMIT ? OFFSET ?"),
11743            "rank query must apply page bounds before hydration"
11744        );
11745        assert_eq!(params.len(), 3, "fts query plus limit and offset params");
11746    }
11747
11748    #[test]
11749    fn sqlite_fts5_hydration_chunks_stay_below_bind_variable_limit() {
11750        let oversized_row_count = SQLITE_MAX_VARIABLE_NUMBER + 1;
11751        let unchunked_sql = SearchClient::sqlite_fts5_hydrate_query(
11752            oversized_row_count,
11753            FieldMask::new(true, true, true, true),
11754            false,
11755        );
11756        assert!(
11757            unchunked_sql.matches('?').count() > SQLITE_MAX_VARIABLE_NUMBER,
11758            "the pre-fix one-shot hydration query would exceed frankensqlite's bind limit"
11759        );
11760
11761        let ranked_rows: Vec<(i64, f64)> = (0..(SQLITE_FTS5_HYDRATE_PARAM_CHUNK + 17))
11762            .map(|idx| (idx as i64, idx as f64))
11763            .collect();
11764        let chunk_sizes: Vec<usize> = SearchClient::sqlite_fts5_hydrate_row_chunks(&ranked_rows)
11765            .map(<[(i64, f64)]>::len)
11766            .collect();
11767
11768        assert_eq!(
11769            chunk_sizes,
11770            vec![SQLITE_FTS5_HYDRATE_PARAM_CHUNK, 17],
11771            "large fallback pages must hydrate in bounded chunks while preserving rank windows"
11772        );
11773        assert!(
11774            chunk_sizes
11775                .iter()
11776                .all(|chunk_size| *chunk_size <= SQLITE_MAX_VARIABLE_NUMBER),
11777            "every hydration chunk must fit under frankensqlite's bind-variable ceiling"
11778        );
11779    }
11780
11781    #[test]
11782    fn tantivy_fallback_hydration_narrows_by_normalized_source_before_message_lookup() -> Result<()>
11783    {
11784        let conn = Connection::open(":memory:")?;
11785        conn.execute_batch(
11786            "CREATE TABLE conversations (
11787                id INTEGER PRIMARY KEY,
11788                source_id TEXT,
11789                origin_host TEXT,
11790                source_path TEXT NOT NULL
11791             );
11792             CREATE TABLE messages (
11793                id INTEGER PRIMARY KEY,
11794                conversation_id INTEGER NOT NULL,
11795                idx INTEGER NOT NULL,
11796                content TEXT NOT NULL,
11797                UNIQUE(conversation_id, idx)
11798             );
11799             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);",
11800        )?;
11801        conn.execute(
11802            "INSERT INTO conversations(id, source_id, origin_host, source_path)
11803             VALUES(1, '', 'devbox', '/tmp/shared-fallback.jsonl')",
11804        )?;
11805        conn.execute(
11806            "INSERT INTO conversations(id, source_id, origin_host, source_path)
11807             VALUES(2, 'local', NULL, '/tmp/shared-fallback.jsonl')",
11808        )?;
11809        conn.execute(
11810            "INSERT INTO messages(id, conversation_id, idx, content)
11811             VALUES(10, 1, 2, 'remote fallback content')",
11812        )?;
11813        conn.execute(
11814            "INSERT INTO messages(id, conversation_id, idx, content)
11815             VALUES(20, 2, 2, 'local content must not win')",
11816        )?;
11817
11818        let client = SearchClient {
11819            reader: None,
11820            sqlite: Mutex::new(Some(SendConnection(conn))),
11821            sqlite_path: None,
11822            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
11823            reload_on_search: true,
11824            last_reload: Mutex::new(None),
11825            last_generation: Mutex::new(None),
11826            reload_epoch: Arc::new(AtomicU64::new(0)),
11827            warm_tx: None,
11828            _warm_handle: None,
11829            metrics: Metrics::default(),
11830            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
11831            semantic: Mutex::new(None),
11832            last_tantivy_total_count: Mutex::new(None),
11833        };
11834
11835        let fallback_key = (
11836            "devbox".to_string(),
11837            "/tmp/shared-fallback.jsonl".to_string(),
11838            2,
11839        );
11840        let (_, hydrated_fallback) =
11841            client.hydrate_tantivy_hit_contents(&[], std::slice::from_ref(&fallback_key))?;
11842
11843        assert_eq!(
11844            hydrated_fallback.get(&fallback_key).map(String::as_str),
11845            Some("remote fallback content")
11846        );
11847
11848        Ok(())
11849    }
11850
11851    #[test]
11852    fn exact_content_hydration_returns_only_requested_message_indices() -> Result<()> {
11853        let conn = Connection::open(":memory:")?;
11854        conn.execute_batch(
11855            "CREATE TABLE messages (
11856                id INTEGER PRIMARY KEY,
11857                conversation_id INTEGER NOT NULL,
11858                idx INTEGER NOT NULL,
11859                content TEXT NOT NULL,
11860                UNIQUE(conversation_id, idx)
11861             );",
11862        )?;
11863
11864        for idx in 0..8 {
11865            conn.execute(&format!(
11866                "INSERT INTO messages(conversation_id, idx, content)
11867                 VALUES(1, {idx}, 'conversation one row {idx}')"
11868            ))?;
11869        }
11870        conn.execute(
11871            "INSERT INTO messages(conversation_id, idx, content)
11872             VALUES(2, 0, 'conversation two row 0')",
11873        )?;
11874
11875        let hydrated =
11876            hydrate_message_content_by_conversation(&conn, &[(1, 6), (1, 2), (2, 0), (1, 99)])?;
11877
11878        assert_eq!(hydrated.len(), 3);
11879        assert_eq!(
11880            hydrated.get(&(1, 2)).map(String::as_str),
11881            Some("conversation one row 2")
11882        );
11883        assert_eq!(
11884            hydrated.get(&(1, 6)).map(String::as_str),
11885            Some("conversation one row 6")
11886        );
11887        assert_eq!(
11888            hydrated.get(&(2, 0)).map(String::as_str),
11889            Some("conversation two row 0")
11890        );
11891        assert!(!hydrated.contains_key(&(1, 99)));
11892
11893        Ok(())
11894    }
11895
11896    #[test]
11897    fn sqlite_backend_generates_snippet_from_content() -> Result<()> {
11898        let conn = Connection::open(":memory:")?;
11899        conn.execute_batch(
11900            "CREATE TABLE conversations (
11901                id INTEGER PRIMARY KEY,
11902                agent_id INTEGER,
11903                workspace_id INTEGER,
11904                source_id TEXT,
11905                origin_host TEXT,
11906                title TEXT,
11907                source_path TEXT
11908             );
11909             CREATE TABLE messages (
11910                id INTEGER PRIMARY KEY,
11911                conversation_id INTEGER,
11912                idx INTEGER,
11913                content TEXT,
11914                created_at INTEGER
11915             );
11916             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
11917             CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL UNIQUE);
11918             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE);
11919             CREATE VIRTUAL TABLE fts_messages USING fts5(
11920                content,
11921                title,
11922                agent,
11923                workspace,
11924                source_path,
11925                created_at UNINDEXED,
11926                content='',
11927                tokenize='porter'
11928             );",
11929        )?;
11930        conn.execute("INSERT INTO sources(id, kind) VALUES('local', 'local')")?;
11931        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
11932        conn.execute("INSERT INTO workspaces(id, path) VALUES(1, '/ws')")?;
11933        conn.execute(
11934            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path) VALUES(1, 1, 1, 'local', NULL, 'snippet title', '/tmp/snippet.jsonl')",
11935        )?;
11936        conn.execute("INSERT INTO messages(id, conversation_id, idx, content, created_at) VALUES(1, 1, 0, 'alpha beta gamma delta epsilon zeta eta theta', 42)")?;
11937        conn.execute_compat(
11938            "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at)
11939             VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)",
11940            params![
11941                1_i64,
11942                "alpha beta gamma delta epsilon zeta eta theta",
11943                "snippet title",
11944                "codex",
11945                "/ws",
11946                "/tmp/snippet.jsonl",
11947                42_i64
11948            ],
11949        )?;
11950
11951        let client = SearchClient {
11952            reader: None,
11953            sqlite: Mutex::new(Some(SendConnection(conn))),
11954            sqlite_path: None,
11955            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
11956            reload_on_search: true,
11957            last_reload: Mutex::new(None),
11958            last_generation: Mutex::new(None),
11959            reload_epoch: Arc::new(AtomicU64::new(0)),
11960            warm_tx: None,
11961            _warm_handle: None,
11962            metrics: Metrics::default(),
11963            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
11964            semantic: Mutex::new(None),
11965            last_tantivy_total_count: Mutex::new(None),
11966        };
11967
11968        let hits = client.search("delta", SearchFilters::default(), 5, 0, FieldMask::FULL)?;
11969        assert_eq!(hits.len(), 1);
11970        // With contentless FTS5, snippet is generated from content via snippet_from_content()
11971        assert_eq!(hits[0].snippet, snippet_from_content(&hits[0].content));
11972        assert!(hits[0].snippet.contains("delta"));
11973
11974        Ok(())
11975    }
11976
11977    #[test]
11978    fn sqlite_backend_respects_source_filter() -> Result<()> {
11979        let conn = Connection::open(":memory:")?;
11980        conn.execute_batch(
11981            "CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
11982             CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL UNIQUE);
11983             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE);
11984             CREATE TABLE conversations (
11985                id INTEGER PRIMARY KEY,
11986                agent_id INTEGER,
11987                workspace_id INTEGER,
11988                source_id TEXT,
11989                origin_host TEXT,
11990                title TEXT,
11991                source_path TEXT
11992             );
11993             CREATE TABLE messages (
11994                id INTEGER PRIMARY KEY,
11995                conversation_id INTEGER,
11996                idx INTEGER,
11997                content TEXT,
11998                created_at INTEGER
11999             );
12000             CREATE VIRTUAL TABLE fts_messages USING fts5(
12001                content,
12002                title,
12003                agent,
12004                workspace,
12005                source_path,
12006                created_at UNINDEXED,
12007                content='',
12008                tokenize='porter'
12009             );",
12010        )?;
12011        conn.execute("INSERT INTO sources(id, kind) VALUES('local', 'local')")?;
12012        conn.execute("INSERT INTO sources(id, kind) VALUES('laptop', 'ssh')")?;
12013        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
12014        conn.execute("INSERT INTO workspaces(id, path) VALUES(1, '/local')")?;
12015        conn.execute("INSERT INTO workspaces(id, path) VALUES(2, '/remote')")?;
12016        conn.execute(
12017            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path) VALUES(1, 1, 1, '  local  ', NULL, 'local title', '/tmp/local.jsonl')",
12018        )?;
12019        conn.execute("INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path) VALUES(2, 1, 2, 'laptop', 'dev@laptop', 'remote title', '/tmp/remote.jsonl')")?;
12020        conn.execute("INSERT INTO messages(id, conversation_id, idx, content, created_at) VALUES(1, 1, 0, 'auth token failure', 42)")?;
12021        conn.execute("INSERT INTO messages(id, conversation_id, idx, content, created_at) VALUES(2, 2, 0, 'auth token failure', 43)")?;
12022        conn.execute_compat(
12023            "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at)
12024             VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)",
12025            params![
12026                1_i64,
12027                "auth token failure",
12028                "local title",
12029                "codex",
12030                "/local",
12031                "/tmp/local.jsonl",
12032                42_i64
12033            ],
12034        )?;
12035        conn.execute_compat(
12036            "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at)
12037             VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)",
12038            params![
12039                2_i64,
12040                "auth token failure",
12041                "remote title",
12042                "codex",
12043                "/remote",
12044                "/tmp/remote.jsonl",
12045                43_i64
12046            ],
12047        )?;
12048
12049        let client = SearchClient {
12050            reader: None,
12051            sqlite: Mutex::new(Some(SendConnection(conn))),
12052            sqlite_path: None,
12053            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
12054            reload_on_search: true,
12055            last_reload: Mutex::new(None),
12056            last_generation: Mutex::new(None),
12057            reload_epoch: Arc::new(AtomicU64::new(0)),
12058            warm_tx: None,
12059            _warm_handle: None,
12060            metrics: Metrics::default(),
12061            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
12062            semantic: Mutex::new(None),
12063            last_tantivy_total_count: Mutex::new(None),
12064        };
12065
12066        let local_hits = client.browse_by_date(
12067            SearchFilters {
12068                source_filter: SourceFilter::Local,
12069                ..SearchFilters::default()
12070            },
12071            5,
12072            0,
12073            true,
12074            FieldMask::FULL,
12075        )?;
12076        assert_eq!(local_hits.len(), 1);
12077        assert_eq!(local_hits[0].source_id, "local");
12078
12079        let remote_hits = client.browse_by_date(
12080            SearchFilters {
12081                source_filter: SourceFilter::SourceId("  LOCAL  ".to_string()),
12082                ..SearchFilters::default()
12083            },
12084            5,
12085            0,
12086            true,
12087            FieldMask::FULL,
12088        )?;
12089        assert_eq!(remote_hits.len(), 1);
12090        assert_eq!(remote_hits[0].source_id, "local");
12091        assert_eq!(remote_hits[0].origin_kind, "local");
12092
12093        Ok(())
12094    }
12095
12096    #[test]
12097    fn sqlite_backend_remote_source_filter_matches_blank_source_id_with_origin_host() -> Result<()>
12098    {
12099        let conn = Connection::open(":memory:")?;
12100        conn.execute_batch(
12101            "CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
12102             CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL UNIQUE);
12103             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE);
12104             CREATE TABLE conversations (
12105                id INTEGER PRIMARY KEY,
12106                agent_id INTEGER,
12107                workspace_id INTEGER,
12108                source_id TEXT,
12109                origin_host TEXT,
12110                title TEXT,
12111                source_path TEXT
12112             );
12113             CREATE TABLE messages (
12114                id INTEGER PRIMARY KEY,
12115                conversation_id INTEGER,
12116                idx INTEGER,
12117                content TEXT,
12118                created_at INTEGER
12119             );
12120             CREATE VIRTUAL TABLE fts_messages USING fts5(
12121                content,
12122                title,
12123                agent,
12124                workspace,
12125                source_path,
12126                created_at UNINDEXED,
12127                content='',
12128                tokenize='porter'
12129             );",
12130        )?;
12131        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
12132        conn.execute(
12133            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
12134             VALUES(1, 1, NULL, '   ', 'dev@laptop', 'remote title', '/tmp/remote-filter.jsonl')",
12135        )?;
12136        conn.execute(
12137            "INSERT INTO messages(id, conversation_id, idx, content, created_at)
12138             VALUES(1, 1, 0, 'remote filter proof', 42)",
12139        )?;
12140        conn.execute_compat(
12141            "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at)
12142             VALUES(?1, ?2, ?3, ?4, NULL, ?5, ?6)",
12143            params![
12144                1_i64,
12145                "remote filter proof",
12146                "remote title",
12147                "codex",
12148                "/tmp/remote-filter.jsonl",
12149                42_i64
12150            ],
12151        )?;
12152
12153        let client = SearchClient {
12154            reader: None,
12155            sqlite: Mutex::new(Some(SendConnection(conn))),
12156            sqlite_path: None,
12157            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
12158            reload_on_search: true,
12159            last_reload: Mutex::new(None),
12160            last_generation: Mutex::new(None),
12161            reload_epoch: Arc::new(AtomicU64::new(0)),
12162            warm_tx: None,
12163            _warm_handle: None,
12164            metrics: Metrics::default(),
12165            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
12166            semantic: Mutex::new(None),
12167            last_tantivy_total_count: Mutex::new(None),
12168        };
12169
12170        let remote_hits = client.search(
12171            "remote",
12172            SearchFilters {
12173                source_filter: SourceFilter::Remote,
12174                ..Default::default()
12175            },
12176            5,
12177            0,
12178            FieldMask::FULL,
12179        )?;
12180        assert_eq!(remote_hits.len(), 1);
12181        assert_eq!(remote_hits[0].source_id, "dev@laptop");
12182        assert_eq!(remote_hits[0].origin_kind, "remote");
12183        assert_eq!(remote_hits[0].origin_host.as_deref(), Some("dev@laptop"));
12184
12185        let source_hits = client.search(
12186            "remote",
12187            SearchFilters {
12188                source_filter: SourceFilter::SourceId("dev@laptop".into()),
12189                ..Default::default()
12190            },
12191            5,
12192            0,
12193            FieldMask::FULL,
12194        )?;
12195        assert_eq!(source_hits.len(), 1);
12196        assert_eq!(source_hits[0].source_id, "dev@laptop");
12197        assert_eq!(source_hits[0].origin_kind, "remote");
12198
12199        Ok(())
12200    }
12201
12202    #[test]
12203    fn sqlite_backend_workspace_filter_matches_null_workspace_as_empty_string() -> Result<()> {
12204        let conn = Connection::open(":memory:")?;
12205        conn.execute_batch(
12206            "CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
12207             CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL UNIQUE);
12208             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE);
12209             CREATE TABLE conversations (
12210                id INTEGER PRIMARY KEY,
12211                agent_id INTEGER,
12212                workspace_id INTEGER,
12213                source_id TEXT,
12214                origin_host TEXT,
12215                title TEXT,
12216                source_path TEXT
12217             );
12218             CREATE TABLE messages (
12219                id INTEGER PRIMARY KEY,
12220                conversation_id INTEGER,
12221                idx INTEGER,
12222                content TEXT,
12223                created_at INTEGER
12224             );
12225             CREATE VIRTUAL TABLE fts_messages USING fts5(
12226                content,
12227                title,
12228                agent,
12229                workspace,
12230                source_path,
12231                created_at UNINDEXED,
12232                content='',
12233                tokenize='porter'
12234             );",
12235        )?;
12236        conn.execute("INSERT INTO sources(id, kind) VALUES('local', 'local')")?;
12237        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
12238        conn.execute("INSERT INTO workspaces(id, path) VALUES(1, '/named')")?;
12239        // Conversation 1: no workspace (workspace_id=NULL)
12240        conn.execute(
12241            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path) VALUES(1, 1, NULL, 'local', NULL, 'null workspace', '/tmp/null-workspace.jsonl')",
12242        )?;
12243        // Conversation 2: with workspace
12244        conn.execute(
12245            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path) VALUES(2, 1, 1, 'local', NULL, 'named workspace', '/tmp/named-workspace.jsonl')",
12246        )?;
12247        conn.execute("INSERT INTO messages(id, conversation_id, idx, content, created_at) VALUES(1, 1, 0, 'auth token failure', 42)")?;
12248        conn.execute("INSERT INTO messages(id, conversation_id, idx, content, created_at) VALUES(2, 2, 0, 'auth token failure', 43)")?;
12249        conn.execute_compat(
12250            "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at)
12251             VALUES(?1, ?2, ?3, ?4, NULL, ?5, ?6)",
12252            params![
12253                1_i64,
12254                "auth token failure",
12255                "null workspace",
12256                "codex",
12257                "/tmp/null-workspace.jsonl",
12258                42_i64
12259            ],
12260        )?;
12261        conn.execute_compat(
12262            "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at)
12263             VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)",
12264            params![
12265                2_i64,
12266                "auth token failure",
12267                "named workspace",
12268                "codex",
12269                "/named",
12270                "/tmp/named-workspace.jsonl",
12271                43_i64
12272            ],
12273        )?;
12274
12275        let client = SearchClient {
12276            reader: None,
12277            sqlite: Mutex::new(Some(SendConnection(conn))),
12278            sqlite_path: None,
12279            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
12280            reload_on_search: true,
12281            last_reload: Mutex::new(None),
12282            last_generation: Mutex::new(None),
12283            reload_epoch: Arc::new(AtomicU64::new(0)),
12284            warm_tx: None,
12285            _warm_handle: None,
12286            metrics: Metrics::default(),
12287            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
12288            semantic: Mutex::new(None),
12289            last_tantivy_total_count: Mutex::new(None),
12290        };
12291
12292        let hits = client.search(
12293            "auth",
12294            SearchFilters {
12295                workspaces: HashSet::from_iter([String::new()]),
12296                ..SearchFilters::default()
12297            },
12298            5,
12299            0,
12300            FieldMask::FULL,
12301        )?;
12302        assert_eq!(hits.len(), 1);
12303        assert_eq!(hits[0].workspace, "");
12304        assert_eq!(hits[0].source_path, "/tmp/null-workspace.jsonl");
12305
12306        Ok(())
12307    }
12308
12309    #[test]
12310    fn sqlite_message_scan_preserves_boolean_or_precedence() {
12311        let simple_or =
12312            SearchClient::sqlite_message_scan_query("alpha OR beta").expect("simple OR scan query");
12313        assert!(SearchClient::sqlite_message_scan_score("alpha", &simple_or) > 0.0);
12314        assert!(SearchClient::sqlite_message_scan_score("beta", &simple_or) > 0.0);
12315        assert_eq!(
12316            SearchClient::sqlite_message_scan_score("gamma", &simple_or),
12317            0.0
12318        );
12319
12320        let and_then_or = SearchClient::sqlite_message_scan_query("alpha AND beta OR gamma")
12321            .expect("AND followed by OR scan query");
12322        assert!(
12323            SearchClient::sqlite_message_scan_score("alpha gamma", &and_then_or) > 0.0,
12324            "alpha AND (beta OR gamma) should accept the gamma branch"
12325        );
12326        assert_eq!(
12327            SearchClient::sqlite_message_scan_score("alpha", &and_then_or),
12328            0.0
12329        );
12330        assert_eq!(
12331            SearchClient::sqlite_message_scan_score("beta gamma", &and_then_or),
12332            0.0
12333        );
12334
12335        let or_then_and = SearchClient::sqlite_message_scan_query("alpha OR beta AND gamma")
12336            .expect("OR followed by AND scan query");
12337        assert!(
12338            SearchClient::sqlite_message_scan_score("alpha gamma", &or_then_and) > 0.0,
12339            "(alpha OR beta) AND gamma should accept the alpha branch"
12340        );
12341        assert!(
12342            SearchClient::sqlite_message_scan_score("beta gamma", &or_then_and) > 0.0,
12343            "(alpha OR beta) AND gamma should accept the beta branch"
12344        );
12345        assert_eq!(
12346            SearchClient::sqlite_message_scan_score("alpha", &or_then_and),
12347            0.0
12348        );
12349
12350        let binary_not =
12351            SearchClient::sqlite_message_scan_query("alpha NOT beta").expect("NOT scan query");
12352        assert!(SearchClient::sqlite_message_scan_score("alpha", &binary_not) > 0.0);
12353        assert_eq!(
12354            SearchClient::sqlite_message_scan_score("alpha beta", &binary_not),
12355            0.0
12356        );
12357    }
12358
12359    #[test]
12360    fn browse_by_date_treats_null_workspace_and_source_as_local() -> Result<()> {
12361        let conn = Connection::open(":memory:")?;
12362        conn.execute_batch(
12363            "CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL);
12364             CREATE TABLE conversations (
12365                id INTEGER PRIMARY KEY,
12366                agent_id INTEGER NOT NULL,
12367                workspace_id INTEGER,
12368                source_id TEXT,
12369                origin_host TEXT,
12370                title TEXT,
12371                source_path TEXT NOT NULL
12372             );
12373             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL);
12374             CREATE TABLE messages (
12375                id INTEGER PRIMARY KEY,
12376                conversation_id INTEGER NOT NULL,
12377                idx INTEGER,
12378                content TEXT NOT NULL,
12379                created_at INTEGER
12380             );
12381             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);",
12382        )?;
12383        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
12384        conn.execute(
12385            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
12386             VALUES(1, 1, NULL, NULL, NULL, 'browse title', '/tmp/browse.jsonl')",
12387        )?;
12388        conn.execute(
12389            "INSERT INTO messages(id, conversation_id, idx, content, created_at)
12390             VALUES(1, 1, 0, 'browse auth token failure', 123)",
12391        )?;
12392
12393        let client = SearchClient {
12394            reader: None,
12395            sqlite: Mutex::new(Some(SendConnection(conn))),
12396            sqlite_path: None,
12397            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
12398            reload_on_search: true,
12399            last_reload: Mutex::new(None),
12400            last_generation: Mutex::new(None),
12401            reload_epoch: Arc::new(AtomicU64::new(0)),
12402            warm_tx: None,
12403            _warm_handle: None,
12404            metrics: Metrics::default(),
12405            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
12406            semantic: Mutex::new(None),
12407            last_tantivy_total_count: Mutex::new(None),
12408        };
12409
12410        let hits = client.browse_by_date(
12411            SearchFilters {
12412                workspaces: HashSet::from_iter([String::new()]),
12413                source_filter: SourceFilter::Local,
12414                ..SearchFilters::default()
12415            },
12416            5,
12417            0,
12418            true,
12419            FieldMask::FULL,
12420        )?;
12421        assert_eq!(hits.len(), 1);
12422        assert_eq!(hits[0].workspace, "");
12423        assert_eq!(hits[0].source_id, "local");
12424        assert_eq!(hits[0].origin_kind, "local");
12425
12426        Ok(())
12427    }
12428
12429    #[test]
12430    fn hydrate_semantic_hits_with_ids_snippet_only_uses_full_content_for_snippets_and_identity()
12431    -> Result<()> {
12432        let conn = Connection::open(":memory:")?;
12433        conn.execute_batch(
12434            "CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL);
12435             CREATE TABLE conversations (
12436                id INTEGER PRIMARY KEY,
12437                agent_id INTEGER NOT NULL,
12438                workspace_id INTEGER,
12439                source_id TEXT,
12440                origin_host TEXT,
12441                title TEXT,
12442                source_path TEXT NOT NULL,
12443                started_at INTEGER
12444             );
12445             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL);
12446             CREATE TABLE messages (
12447                id INTEGER PRIMARY KEY,
12448                conversation_id INTEGER NOT NULL,
12449                idx INTEGER,
12450                role TEXT,
12451                content TEXT NOT NULL,
12452                created_at INTEGER
12453             );
12454             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);",
12455        )?;
12456        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
12457        conn.execute(
12458            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path, started_at)
12459             VALUES(1, 1, NULL, 'local', NULL, 'semantic title', '/tmp/semantic.jsonl', 100)",
12460        )?;
12461        let shared_prefix = "shared-prefix ".repeat(32);
12462        let first = format!("{shared_prefix}first unique semantic tail");
12463        let second = format!("{shared_prefix}second unique semantic tail");
12464        conn.execute_with_params(
12465            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
12466             VALUES(?1, 1, ?2, 'assistant', ?3, ?4)",
12467            &[
12468                fsqlite_types::value::SqliteValue::Integer(1),
12469                fsqlite_types::value::SqliteValue::Integer(0),
12470                fsqlite_types::value::SqliteValue::Text(first.clone().into()),
12471                fsqlite_types::value::SqliteValue::Integer(101),
12472            ],
12473        )?;
12474        conn.execute_with_params(
12475            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
12476             VALUES(?1, 1, ?2, 'assistant', ?3, ?4)",
12477            &[
12478                fsqlite_types::value::SqliteValue::Integer(2),
12479                fsqlite_types::value::SqliteValue::Integer(1),
12480                fsqlite_types::value::SqliteValue::Text(second.clone().into()),
12481                fsqlite_types::value::SqliteValue::Integer(102),
12482            ],
12483        )?;
12484
12485        let client = SearchClient {
12486            reader: None,
12487            sqlite: Mutex::new(Some(SendConnection(conn))),
12488            sqlite_path: None,
12489            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
12490            reload_on_search: true,
12491            last_reload: Mutex::new(None),
12492            last_generation: Mutex::new(None),
12493            reload_epoch: Arc::new(AtomicU64::new(0)),
12494            warm_tx: None,
12495            _warm_handle: None,
12496            metrics: Metrics::default(),
12497            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
12498            semantic: Mutex::new(None),
12499            last_tantivy_total_count: Mutex::new(None),
12500        };
12501
12502        let hits = client.hydrate_semantic_hits_with_ids(
12503            &[
12504                VectorSearchResult {
12505                    message_id: 1,
12506                    chunk_idx: 0,
12507                    score: 0.9,
12508                },
12509                VectorSearchResult {
12510                    message_id: 2,
12511                    chunk_idx: 0,
12512                    score: 0.8,
12513                },
12514            ],
12515            FieldMask::new(false, true, true, true),
12516        )?;
12517        assert_eq!(hits.len(), 2);
12518        assert!(hits.iter().all(|(_, hit)| hit.content.is_empty()));
12519        assert!(hits.iter().all(|(_, hit)| !hit.snippet.is_empty()));
12520        assert_ne!(hits[0].1.content_hash, hits[1].1.content_hash);
12521
12522        Ok(())
12523    }
12524
12525    #[test]
12526    fn hydrate_semantic_hits_with_ids_normalizes_trimmed_local_source_metadata() -> Result<()> {
12527        let conn = Connection::open(":memory:")?;
12528        conn.execute_batch(
12529            "CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL);
12530             CREATE TABLE conversations (
12531                id INTEGER PRIMARY KEY,
12532                agent_id INTEGER NOT NULL,
12533                workspace_id INTEGER,
12534                source_id TEXT,
12535                origin_host TEXT,
12536                title TEXT,
12537                source_path TEXT NOT NULL,
12538                started_at INTEGER
12539             );
12540             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL);
12541             CREATE TABLE messages (
12542                id INTEGER PRIMARY KEY,
12543                conversation_id INTEGER NOT NULL,
12544                idx INTEGER,
12545                role TEXT,
12546                content TEXT NOT NULL,
12547                created_at INTEGER
12548             );
12549             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);",
12550        )?;
12551        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
12552        conn.execute(
12553            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path, started_at)
12554             VALUES(1, 1, NULL, '  local  ', NULL, 'trimmed local semantic', '/tmp/trimmed-local-semantic.jsonl', 100)",
12555        )?;
12556        conn.execute_with_params(
12557            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
12558             VALUES(?1, 1, 0, 'assistant', ?2, 101)",
12559            &[
12560                fsqlite_types::value::SqliteValue::Integer(1),
12561                fsqlite_types::value::SqliteValue::Text("trimmed local semantic body".into()),
12562            ],
12563        )?;
12564
12565        let client = SearchClient {
12566            reader: None,
12567            sqlite: Mutex::new(Some(SendConnection(conn))),
12568            sqlite_path: None,
12569            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
12570            reload_on_search: true,
12571            last_reload: Mutex::new(None),
12572            last_generation: Mutex::new(None),
12573            reload_epoch: Arc::new(AtomicU64::new(0)),
12574            warm_tx: None,
12575            _warm_handle: None,
12576            metrics: Metrics::default(),
12577            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
12578            semantic: Mutex::new(None),
12579            last_tantivy_total_count: Mutex::new(None),
12580        };
12581
12582        let hits = client.hydrate_semantic_hits_with_ids(
12583            &[VectorSearchResult {
12584                message_id: 1,
12585                chunk_idx: 0,
12586                score: 0.9,
12587            }],
12588            FieldMask::new(false, true, true, true),
12589        )?;
12590        assert_eq!(hits.len(), 1);
12591        assert_eq!(hits[0].1.source_id, "local");
12592        assert_eq!(hits[0].1.origin_kind, "local");
12593
12594        Ok(())
12595    }
12596
12597    #[test]
12598    fn hydrate_semantic_hits_with_ids_preserves_remote_origin_without_source_row() -> Result<()> {
12599        let conn = Connection::open(":memory:")?;
12600        conn.execute_batch(
12601            "CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL);
12602             CREATE TABLE conversations (
12603                id INTEGER PRIMARY KEY,
12604                agent_id INTEGER NOT NULL,
12605                workspace_id INTEGER,
12606                source_id TEXT,
12607                origin_host TEXT,
12608                title TEXT,
12609                source_path TEXT NOT NULL,
12610                started_at INTEGER
12611             );
12612             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL);
12613             CREATE TABLE messages (
12614                id INTEGER PRIMARY KEY,
12615                conversation_id INTEGER NOT NULL,
12616                idx INTEGER,
12617                role TEXT,
12618                content TEXT NOT NULL,
12619                created_at INTEGER
12620             );
12621             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);",
12622        )?;
12623        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
12624        conn.execute(
12625            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path, started_at)
12626             VALUES(1, 1, NULL, 'laptop', 'dev@laptop', 'remote semantic', '/tmp/remote-semantic.jsonl', 100)",
12627        )?;
12628        conn.execute_with_params(
12629            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
12630             VALUES(?1, 1, 0, 'assistant', ?2, 101)",
12631            &[
12632                fsqlite_types::value::SqliteValue::Integer(1),
12633                fsqlite_types::value::SqliteValue::Text("remote semantic body".into()),
12634            ],
12635        )?;
12636
12637        let client = SearchClient {
12638            reader: None,
12639            sqlite: Mutex::new(Some(SendConnection(conn))),
12640            sqlite_path: None,
12641            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
12642            reload_on_search: true,
12643            last_reload: Mutex::new(None),
12644            last_generation: Mutex::new(None),
12645            reload_epoch: Arc::new(AtomicU64::new(0)),
12646            warm_tx: None,
12647            _warm_handle: None,
12648            metrics: Metrics::default(),
12649            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
12650            semantic: Mutex::new(None),
12651            last_tantivy_total_count: Mutex::new(None),
12652        };
12653
12654        let hits = client.hydrate_semantic_hits_with_ids(
12655            &[VectorSearchResult {
12656                message_id: 1,
12657                chunk_idx: 0,
12658                score: 0.9,
12659            }],
12660            FieldMask::new(false, true, true, true),
12661        )?;
12662        assert_eq!(hits.len(), 1);
12663        assert_eq!(hits[0].1.source_id, "laptop");
12664        assert_eq!(hits[0].1.origin_kind, "remote");
12665        assert_eq!(hits[0].1.origin_host.as_deref(), Some("dev@laptop"));
12666
12667        Ok(())
12668    }
12669
12670    #[test]
12671    fn resolve_semantic_doc_ids_for_hits_distinguishes_same_source_path_line_by_content_hash()
12672    -> Result<()> {
12673        let conn = Connection::open(":memory:")?;
12674        conn.execute_batch(
12675            "CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL);
12676             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
12677             CREATE TABLE conversations (
12678                id INTEGER PRIMARY KEY,
12679                agent_id INTEGER NOT NULL,
12680                workspace_id INTEGER,
12681                source_id TEXT,
12682                origin_host TEXT,
12683                title TEXT,
12684                source_path TEXT NOT NULL
12685             );
12686             CREATE TABLE messages (
12687                id INTEGER PRIMARY KEY,
12688                conversation_id INTEGER NOT NULL,
12689                idx INTEGER,
12690                role TEXT,
12691                content TEXT NOT NULL,
12692                created_at INTEGER
12693             );",
12694        )?;
12695        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
12696        conn.execute(
12697            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
12698             VALUES(1, 1, NULL, 'local', NULL, 'Shared Session', '/tmp/progressive-shared.jsonl')",
12699        )?;
12700        conn.execute(
12701            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
12702             VALUES(2, 1, NULL, 'local', NULL, 'Shared Session', '/tmp/progressive-shared.jsonl')",
12703        )?;
12704        let first = "same prefix first tail".to_string();
12705        let second = "same prefix second tail".to_string();
12706        conn.execute_with_params(
12707            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
12708             VALUES(?1, ?2, 0, 'assistant', ?3, 100)",
12709            &[
12710                fsqlite_types::value::SqliteValue::Integer(11),
12711                fsqlite_types::value::SqliteValue::Integer(1),
12712                fsqlite_types::value::SqliteValue::Text(first.clone().into()),
12713            ],
12714        )?;
12715        conn.execute_with_params(
12716            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
12717             VALUES(?1, ?2, 0, 'assistant', ?3, 100)",
12718            &[
12719                fsqlite_types::value::SqliteValue::Integer(22),
12720                fsqlite_types::value::SqliteValue::Integer(2),
12721                fsqlite_types::value::SqliteValue::Text(second.clone().into()),
12722            ],
12723        )?;
12724
12725        let client = SearchClient {
12726            reader: None,
12727            sqlite: Mutex::new(Some(SendConnection(conn))),
12728            sqlite_path: None,
12729            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
12730            reload_on_search: true,
12731            last_reload: Mutex::new(None),
12732            last_generation: Mutex::new(None),
12733            reload_epoch: Arc::new(AtomicU64::new(0)),
12734            warm_tx: None,
12735            _warm_handle: None,
12736            metrics: Metrics::default(),
12737            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
12738            semantic: Mutex::new(None),
12739            last_tantivy_total_count: Mutex::new(None),
12740        };
12741
12742        let first_hit = SearchHit {
12743            title: "Shared Session".into(),
12744            snippet: String::new(),
12745            content: String::new(),
12746            content_hash: stable_hit_hash(
12747                &first,
12748                "/tmp/progressive-shared.jsonl",
12749                Some(1),
12750                Some(100),
12751            ),
12752            score: 0.0,
12753            source_path: "/tmp/progressive-shared.jsonl".into(),
12754            agent: "codex".into(),
12755            workspace: String::new(),
12756            workspace_original: None,
12757            created_at: Some(100),
12758            line_number: Some(1),
12759            match_type: MatchType::Exact,
12760            source_id: "local".into(),
12761            origin_kind: "local".into(),
12762            origin_host: None,
12763            conversation_id: None,
12764        };
12765        let second_hit = SearchHit {
12766            title: "Shared Session".into(),
12767            snippet: String::new(),
12768            content: String::new(),
12769            content_hash: stable_hit_hash(
12770                &second,
12771                "/tmp/progressive-shared.jsonl",
12772                Some(1),
12773                Some(100),
12774            ),
12775            score: 0.0,
12776            source_path: "/tmp/progressive-shared.jsonl".into(),
12777            agent: "codex".into(),
12778            workspace: String::new(),
12779            workspace_original: None,
12780            created_at: Some(100),
12781            line_number: Some(1),
12782            match_type: MatchType::Exact,
12783            source_id: "local".into(),
12784            origin_kind: "local".into(),
12785            origin_host: None,
12786            conversation_id: None,
12787        };
12788
12789        let resolved = client.resolve_semantic_doc_ids_for_hits(&[first_hit, second_hit])?;
12790        assert_eq!(resolved.len(), 2);
12791        assert_eq!(resolved[0].as_ref().map(|hit| hit.message_id), Some(11));
12792        assert_eq!(resolved[1].as_ref().map(|hit| hit.message_id), Some(22));
12793        assert_ne!(
12794            resolved[0].as_ref().map(|hit| hit.doc_id.as_str()),
12795            resolved[1].as_ref().map(|hit| hit.doc_id.as_str())
12796        );
12797
12798        Ok(())
12799    }
12800
12801    #[test]
12802    fn hydrate_semantic_hits_with_ids_keeps_missing_title_empty() -> Result<()> {
12803        let conn = Connection::open(":memory:")?;
12804        conn.execute_batch(
12805            "CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL);
12806             CREATE TABLE conversations (
12807                id INTEGER PRIMARY KEY,
12808                agent_id INTEGER NOT NULL,
12809                workspace_id INTEGER,
12810                source_id TEXT,
12811                origin_host TEXT,
12812                title TEXT,
12813                source_path TEXT NOT NULL,
12814                started_at INTEGER
12815             );
12816             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL);
12817             CREATE TABLE messages (
12818                id INTEGER PRIMARY KEY,
12819                conversation_id INTEGER NOT NULL,
12820                idx INTEGER,
12821                role TEXT,
12822                content TEXT NOT NULL,
12823                created_at INTEGER
12824             );
12825             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);",
12826        )?;
12827        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
12828        conn.execute(
12829            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path, started_at)
12830             VALUES(1, 1, NULL, 'local', NULL, NULL, '/tmp/untitled-semantic.jsonl', 100)",
12831        )?;
12832        conn.execute_with_params(
12833            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
12834             VALUES(?1, 1, 0, 'assistant', ?2, 101)",
12835            &[
12836                fsqlite_types::value::SqliteValue::Integer(1),
12837                fsqlite_types::value::SqliteValue::Text("untitled semantic body".into()),
12838            ],
12839        )?;
12840
12841        let client = SearchClient {
12842            reader: None,
12843            sqlite: Mutex::new(Some(SendConnection(conn))),
12844            sqlite_path: None,
12845            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
12846            reload_on_search: true,
12847            last_reload: Mutex::new(None),
12848            last_generation: Mutex::new(None),
12849            reload_epoch: Arc::new(AtomicU64::new(0)),
12850            warm_tx: None,
12851            _warm_handle: None,
12852            metrics: Metrics::default(),
12853            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
12854            semantic: Mutex::new(None),
12855            last_tantivy_total_count: Mutex::new(None),
12856        };
12857
12858        let hits = client.hydrate_semantic_hits_with_ids(
12859            &[VectorSearchResult {
12860                message_id: 1,
12861                chunk_idx: 0,
12862                score: 0.9,
12863            }],
12864            FieldMask::new(false, true, true, true),
12865        )?;
12866        assert_eq!(hits.len(), 1);
12867        assert_eq!(hits[0].1.title, "");
12868
12869        Ok(())
12870    }
12871
12872    #[test]
12873    fn resolve_semantic_doc_ids_for_hits_prefers_conversation_id_over_ambiguous_provenance()
12874    -> Result<()> {
12875        let conn = Connection::open(":memory:")?;
12876        conn.execute_batch(
12877            "CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL);
12878             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
12879             CREATE TABLE conversations (
12880                id INTEGER PRIMARY KEY,
12881                agent_id INTEGER NOT NULL,
12882                workspace_id INTEGER,
12883                source_id TEXT,
12884                origin_host TEXT,
12885                title TEXT,
12886                source_path TEXT NOT NULL
12887             );
12888             CREATE TABLE messages (
12889                id INTEGER PRIMARY KEY,
12890                conversation_id INTEGER NOT NULL,
12891                idx INTEGER,
12892                role TEXT,
12893                content TEXT NOT NULL,
12894                created_at INTEGER
12895             );",
12896        )?;
12897        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
12898        conn.execute(
12899            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
12900             VALUES(1, 1, NULL, 'local', NULL, 'Shared Session', '/tmp/progressive-conversation-id.jsonl')",
12901        )?;
12902        conn.execute(
12903            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
12904             VALUES(2, 1, NULL, 'local', NULL, 'Shared Session', '/tmp/progressive-conversation-id.jsonl')",
12905        )?;
12906        let content = "same ambiguous content".to_string();
12907        conn.execute_with_params(
12908            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
12909             VALUES(?1, ?2, 0, 'assistant', ?3, 100)",
12910            &[
12911                fsqlite_types::value::SqliteValue::Integer(11),
12912                fsqlite_types::value::SqliteValue::Integer(1),
12913                fsqlite_types::value::SqliteValue::Text(content.clone().into()),
12914            ],
12915        )?;
12916        conn.execute_with_params(
12917            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
12918             VALUES(?1, ?2, 0, 'assistant', ?3, 100)",
12919            &[
12920                fsqlite_types::value::SqliteValue::Integer(22),
12921                fsqlite_types::value::SqliteValue::Integer(2),
12922                fsqlite_types::value::SqliteValue::Text(content.clone().into()),
12923            ],
12924        )?;
12925
12926        let client = SearchClient {
12927            reader: None,
12928            sqlite: Mutex::new(Some(SendConnection(conn))),
12929            sqlite_path: None,
12930            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
12931            reload_on_search: true,
12932            last_reload: Mutex::new(None),
12933            last_generation: Mutex::new(None),
12934            reload_epoch: Arc::new(AtomicU64::new(0)),
12935            warm_tx: None,
12936            _warm_handle: None,
12937            metrics: Metrics::default(),
12938            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
12939            semantic: Mutex::new(None),
12940            last_tantivy_total_count: Mutex::new(None),
12941        };
12942
12943        let first_hit = SearchHit {
12944            title: "Shared Session".into(),
12945            snippet: String::new(),
12946            content: String::new(),
12947            content_hash: stable_hit_hash(
12948                &content,
12949                "/tmp/progressive-conversation-id.jsonl",
12950                Some(1),
12951                Some(100),
12952            ),
12953            score: 0.0,
12954            source_path: "/tmp/progressive-conversation-id.jsonl".into(),
12955            agent: "codex".into(),
12956            workspace: String::new(),
12957            workspace_original: None,
12958            created_at: Some(100),
12959            line_number: Some(1),
12960            match_type: MatchType::Exact,
12961            source_id: "local".into(),
12962            origin_kind: "local".into(),
12963            origin_host: None,
12964            conversation_id: Some(1),
12965        };
12966        let second_hit = SearchHit {
12967            conversation_id: Some(2),
12968            ..first_hit.clone()
12969        };
12970
12971        let resolved = client.resolve_semantic_doc_ids_for_hits(&[first_hit, second_hit])?;
12972        assert_eq!(resolved.len(), 2);
12973        assert_eq!(resolved[0].as_ref().map(|hit| hit.message_id), Some(11));
12974        assert_eq!(resolved[1].as_ref().map(|hit| hit.message_id), Some(22));
12975
12976        Ok(())
12977    }
12978
12979    #[test]
12980    fn resolve_semantic_doc_ids_for_hits_treats_null_source_as_local() -> Result<()> {
12981        let conn = Connection::open(":memory:")?;
12982        conn.execute_batch(
12983            "CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL);
12984             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
12985             CREATE TABLE conversations (
12986                id INTEGER PRIMARY KEY,
12987                agent_id INTEGER NOT NULL,
12988                workspace_id INTEGER,
12989                source_id TEXT,
12990                origin_host TEXT,
12991                title TEXT,
12992                source_path TEXT NOT NULL
12993             );
12994             CREATE TABLE messages (
12995                id INTEGER PRIMARY KEY,
12996                conversation_id INTEGER NOT NULL,
12997                idx INTEGER,
12998                role TEXT,
12999                content TEXT NOT NULL,
13000                created_at INTEGER
13001             );",
13002        )?;
13003        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
13004        conn.execute(
13005            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
13006             VALUES(1, 1, NULL, NULL, NULL, 'Legacy Local', '/tmp/legacy-local.jsonl')",
13007        )?;
13008        let content = "legacy local semantic message".to_string();
13009        conn.execute_with_params(
13010            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
13011             VALUES(?1, 1, 0, 'assistant', ?2, 100)",
13012            &[
13013                fsqlite_types::value::SqliteValue::Integer(11),
13014                fsqlite_types::value::SqliteValue::Text(content.clone().into()),
13015            ],
13016        )?;
13017
13018        let client = SearchClient {
13019            reader: None,
13020            sqlite: Mutex::new(Some(SendConnection(conn))),
13021            sqlite_path: None,
13022            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
13023            reload_on_search: true,
13024            last_reload: Mutex::new(None),
13025            last_generation: Mutex::new(None),
13026            reload_epoch: Arc::new(AtomicU64::new(0)),
13027            warm_tx: None,
13028            _warm_handle: None,
13029            metrics: Metrics::default(),
13030            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
13031            semantic: Mutex::new(None),
13032            last_tantivy_total_count: Mutex::new(None),
13033        };
13034
13035        let hit = SearchHit {
13036            title: "Legacy Local".into(),
13037            snippet: String::new(),
13038            content: String::new(),
13039            content_hash: stable_hit_hash(&content, "/tmp/legacy-local.jsonl", Some(1), Some(100)),
13040            score: 0.0,
13041            source_path: "/tmp/legacy-local.jsonl".into(),
13042            agent: "codex".into(),
13043            workspace: String::new(),
13044            workspace_original: None,
13045            created_at: Some(100),
13046            line_number: Some(1),
13047            match_type: MatchType::Exact,
13048            source_id: "local".into(),
13049            origin_kind: "local".into(),
13050            origin_host: None,
13051            conversation_id: None,
13052        };
13053
13054        let resolved = client.resolve_semantic_doc_ids_for_hits(&[hit])?;
13055        assert_eq!(resolved.len(), 1);
13056        assert_eq!(resolved[0].as_ref().map(|hit| hit.message_id), Some(11));
13057
13058        Ok(())
13059    }
13060
13061    #[test]
13062    fn resolve_semantic_doc_ids_for_hits_matches_trimmed_local_source_id() -> Result<()> {
13063        let conn = Connection::open(":memory:")?;
13064        conn.execute_batch(
13065            "CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL);
13066             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
13067             CREATE TABLE conversations (
13068                id INTEGER PRIMARY KEY,
13069                agent_id INTEGER NOT NULL,
13070                workspace_id INTEGER,
13071                source_id TEXT,
13072                origin_host TEXT,
13073                title TEXT,
13074                source_path TEXT NOT NULL
13075             );
13076             CREATE TABLE messages (
13077                id INTEGER PRIMARY KEY,
13078                conversation_id INTEGER NOT NULL,
13079                idx INTEGER,
13080                role TEXT,
13081                content TEXT NOT NULL,
13082                created_at INTEGER
13083             );",
13084        )?;
13085        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
13086        conn.execute(
13087            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
13088             VALUES(1, 1, NULL, '  local  ', NULL, 'Trimmed Local', '/tmp/trimmed-local.jsonl')",
13089        )?;
13090        let content = "trimmed local semantic message".to_string();
13091        conn.execute_with_params(
13092            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
13093             VALUES(?1, 1, 0, 'assistant', ?2, 100)",
13094            &[
13095                fsqlite_types::value::SqliteValue::Integer(11),
13096                fsqlite_types::value::SqliteValue::Text(content.clone().into()),
13097            ],
13098        )?;
13099
13100        let client = SearchClient {
13101            reader: None,
13102            sqlite: Mutex::new(Some(SendConnection(conn))),
13103            sqlite_path: None,
13104            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
13105            reload_on_search: true,
13106            last_reload: Mutex::new(None),
13107            last_generation: Mutex::new(None),
13108            reload_epoch: Arc::new(AtomicU64::new(0)),
13109            warm_tx: None,
13110            _warm_handle: None,
13111            metrics: Metrics::default(),
13112            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
13113            semantic: Mutex::new(None),
13114            last_tantivy_total_count: Mutex::new(None),
13115        };
13116
13117        let hit = SearchHit {
13118            title: "Trimmed Local".into(),
13119            snippet: String::new(),
13120            content: String::new(),
13121            content_hash: stable_hit_hash(&content, "/tmp/trimmed-local.jsonl", Some(1), Some(100)),
13122            score: 0.0,
13123            source_path: "/tmp/trimmed-local.jsonl".into(),
13124            agent: "codex".into(),
13125            workspace: String::new(),
13126            workspace_original: None,
13127            created_at: Some(100),
13128            line_number: Some(1),
13129            match_type: MatchType::Exact,
13130            source_id: "local".into(),
13131            origin_kind: "local".into(),
13132            origin_host: None,
13133            conversation_id: None,
13134        };
13135
13136        let resolved = client.resolve_semantic_doc_ids_for_hits(&[hit])?;
13137        assert_eq!(resolved.len(), 1);
13138        assert_eq!(resolved[0].as_ref().map(|doc| doc.message_id), Some(11));
13139
13140        Ok(())
13141    }
13142
13143    #[test]
13144    fn resolve_semantic_doc_ids_for_hits_normalizes_blank_local_source_id() -> Result<()> {
13145        let conn = Connection::open(":memory:")?;
13146        conn.execute_batch(
13147            "CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL);
13148             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
13149             CREATE TABLE conversations (
13150                id INTEGER PRIMARY KEY,
13151                agent_id INTEGER NOT NULL,
13152                workspace_id INTEGER,
13153                source_id TEXT,
13154                origin_host TEXT,
13155                title TEXT,
13156                source_path TEXT NOT NULL
13157             );
13158             CREATE TABLE messages (
13159                id INTEGER PRIMARY KEY,
13160                conversation_id INTEGER NOT NULL,
13161                idx INTEGER,
13162                role TEXT,
13163                content TEXT NOT NULL,
13164                created_at INTEGER
13165             );",
13166        )?;
13167        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
13168        conn.execute(
13169            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
13170             VALUES(1, 1, NULL, 'local', NULL, 'Blank Local', '/tmp/blank-local.jsonl')",
13171        )?;
13172        let content = "blank local semantic message".to_string();
13173        conn.execute_with_params(
13174            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
13175             VALUES(?1, 1, 0, 'assistant', ?2, 100)",
13176            &[
13177                fsqlite_types::value::SqliteValue::Integer(11),
13178                fsqlite_types::value::SqliteValue::Text(content.clone().into()),
13179            ],
13180        )?;
13181
13182        let client = SearchClient {
13183            reader: None,
13184            sqlite: Mutex::new(Some(SendConnection(conn))),
13185            sqlite_path: None,
13186            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
13187            reload_on_search: true,
13188            last_reload: Mutex::new(None),
13189            last_generation: Mutex::new(None),
13190            reload_epoch: Arc::new(AtomicU64::new(0)),
13191            warm_tx: None,
13192            _warm_handle: None,
13193            metrics: Metrics::default(),
13194            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
13195            semantic: Mutex::new(None),
13196            last_tantivy_total_count: Mutex::new(None),
13197        };
13198
13199        let hit = SearchHit {
13200            title: "Blank Local".into(),
13201            snippet: String::new(),
13202            content: String::new(),
13203            content_hash: stable_hit_hash(&content, "/tmp/blank-local.jsonl", Some(1), Some(100)),
13204            score: 0.0,
13205            source_path: "/tmp/blank-local.jsonl".into(),
13206            agent: "codex".into(),
13207            workspace: String::new(),
13208            workspace_original: None,
13209            created_at: Some(100),
13210            line_number: Some(1),
13211            match_type: MatchType::Exact,
13212            source_id: "   ".into(),
13213            origin_kind: "local".into(),
13214            origin_host: None,
13215            conversation_id: None,
13216        };
13217
13218        let resolved = client.resolve_semantic_doc_ids_for_hits(&[hit])?;
13219        assert_eq!(resolved.len(), 1);
13220        assert_eq!(resolved[0].as_ref().map(|doc| doc.message_id), Some(11));
13221
13222        Ok(())
13223    }
13224
13225    #[test]
13226    fn resolve_semantic_doc_ids_for_hits_infers_remote_source_from_origin_host_when_source_id_blank()
13227    -> Result<()> {
13228        let conn = Connection::open(":memory:")?;
13229        conn.execute_batch(
13230            "CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL);
13231             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
13232             CREATE TABLE conversations (
13233                id INTEGER PRIMARY KEY,
13234                agent_id INTEGER NOT NULL,
13235                workspace_id INTEGER,
13236                source_id TEXT,
13237                origin_host TEXT,
13238                title TEXT,
13239                source_path TEXT NOT NULL
13240             );
13241             CREATE TABLE messages (
13242                id INTEGER PRIMARY KEY,
13243                conversation_id INTEGER NOT NULL,
13244                idx INTEGER,
13245                role TEXT,
13246                content TEXT NOT NULL,
13247                created_at INTEGER
13248             );",
13249        )?;
13250        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
13251        conn.execute(
13252            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
13253             VALUES(1, 1, NULL, '   ', 'dev@laptop', 'Legacy Remote', '/tmp/legacy-remote.jsonl')",
13254        )?;
13255        let content = "legacy remote semantic message".to_string();
13256        conn.execute_with_params(
13257            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
13258             VALUES(?1, 1, 0, 'assistant', ?2, 100)",
13259            &[
13260                fsqlite_types::value::SqliteValue::Integer(11),
13261                fsqlite_types::value::SqliteValue::Text(content.clone().into()),
13262            ],
13263        )?;
13264
13265        let client = SearchClient {
13266            reader: None,
13267            sqlite: Mutex::new(Some(SendConnection(conn))),
13268            sqlite_path: None,
13269            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
13270            reload_on_search: true,
13271            last_reload: Mutex::new(None),
13272            last_generation: Mutex::new(None),
13273            reload_epoch: Arc::new(AtomicU64::new(0)),
13274            warm_tx: None,
13275            _warm_handle: None,
13276            metrics: Metrics::default(),
13277            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
13278            semantic: Mutex::new(None),
13279            last_tantivy_total_count: Mutex::new(None),
13280        };
13281
13282        let hit = SearchHit {
13283            title: "Legacy Remote".into(),
13284            snippet: String::new(),
13285            content: String::new(),
13286            content_hash: stable_hit_hash(&content, "/tmp/legacy-remote.jsonl", Some(1), Some(100)),
13287            score: 0.0,
13288            source_path: "/tmp/legacy-remote.jsonl".into(),
13289            agent: "codex".into(),
13290            workspace: String::new(),
13291            workspace_original: None,
13292            created_at: Some(100),
13293            line_number: Some(1),
13294            match_type: MatchType::Exact,
13295            source_id: "dev@laptop".into(),
13296            origin_kind: "remote".into(),
13297            origin_host: Some("dev@laptop".into()),
13298            conversation_id: None,
13299        };
13300
13301        let resolved = client.resolve_semantic_doc_ids_for_hits(&[hit])?;
13302        assert_eq!(resolved.len(), 1);
13303        assert_eq!(resolved[0].as_ref().map(|doc| doc.message_id), Some(11));
13304
13305        Ok(())
13306    }
13307
13308    #[test]
13309    fn browse_by_date_snippet_only_uses_full_content_for_hit_identity() -> Result<()> {
13310        let conn = Connection::open(":memory:")?;
13311        conn.execute_batch(
13312            "CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL);
13313             CREATE TABLE conversations (
13314                id INTEGER PRIMARY KEY,
13315                agent_id INTEGER NOT NULL,
13316                workspace_id INTEGER,
13317                source_id TEXT,
13318                origin_host TEXT,
13319                title TEXT,
13320                source_path TEXT NOT NULL
13321             );
13322             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL);
13323             CREATE TABLE messages (
13324                id INTEGER PRIMARY KEY,
13325                conversation_id INTEGER NOT NULL,
13326                idx INTEGER,
13327                content TEXT NOT NULL,
13328                created_at INTEGER
13329             );
13330             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);",
13331        )?;
13332        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
13333        conn.execute(
13334            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
13335             VALUES(1, 1, NULL, 'local', NULL, 'browse title', '/tmp/browse-shared.jsonl')",
13336        )?;
13337        let shared_prefix = "shared-prefix ".repeat(48);
13338        let first = format!("{shared_prefix}first browse-only tail");
13339        let second = format!("{shared_prefix}second browse-only tail");
13340        conn.execute_with_params(
13341            "INSERT INTO messages(id, conversation_id, idx, content, created_at)
13342             VALUES(?1, 1, ?2, ?3, ?4)",
13343            &[
13344                fsqlite_types::value::SqliteValue::Integer(1),
13345                fsqlite_types::value::SqliteValue::Integer(0),
13346                fsqlite_types::value::SqliteValue::Text(first.clone().into()),
13347                fsqlite_types::value::SqliteValue::Integer(101),
13348            ],
13349        )?;
13350        conn.execute_with_params(
13351            "INSERT INTO messages(id, conversation_id, idx, content, created_at)
13352             VALUES(?1, 1, ?2, ?3, ?4)",
13353            &[
13354                fsqlite_types::value::SqliteValue::Integer(2),
13355                fsqlite_types::value::SqliteValue::Integer(1),
13356                fsqlite_types::value::SqliteValue::Text(second.clone().into()),
13357                fsqlite_types::value::SqliteValue::Integer(102),
13358            ],
13359        )?;
13360
13361        let client = SearchClient {
13362            reader: None,
13363            sqlite: Mutex::new(Some(SendConnection(conn))),
13364            sqlite_path: None,
13365            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
13366            reload_on_search: true,
13367            last_reload: Mutex::new(None),
13368            last_generation: Mutex::new(None),
13369            reload_epoch: Arc::new(AtomicU64::new(0)),
13370            warm_tx: None,
13371            _warm_handle: None,
13372            metrics: Metrics::default(),
13373            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
13374            semantic: Mutex::new(None),
13375            last_tantivy_total_count: Mutex::new(None),
13376        };
13377
13378        let hits = client.browse_by_date(
13379            SearchFilters::default(),
13380            10,
13381            0,
13382            true,
13383            FieldMask::new(false, true, true, true),
13384        )?;
13385        assert_eq!(hits.len(), 2);
13386        assert!(hits.iter().all(|hit| hit.content.is_empty()));
13387        assert!(hits.iter().all(|hit| !hit.snippet.is_empty()));
13388        assert_ne!(hits[0].content_hash, hits[1].content_hash);
13389
13390        Ok(())
13391    }
13392
13393    #[test]
13394    fn cache_invalidates_on_new_data() -> Result<()> {
13395        let dir = TempDir::new()?;
13396        let mut index = TantivyIndex::open_or_create(dir.path())?;
13397
13398        // 1. Add initial doc
13399        let conv1 = NormalizedConversation {
13400            agent_slug: "codex".into(),
13401            external_id: None,
13402            title: Some("first".into()),
13403            workspace: None,
13404            source_path: dir.path().join("1.jsonl"),
13405            started_at: Some(1),
13406            ended_at: None,
13407            metadata: serde_json::json!({}),
13408            messages: vec![NormalizedMessage {
13409                idx: 0,
13410                role: "user".into(),
13411                author: None,
13412                created_at: Some(1),
13413                content: "apple banana".into(),
13414                extra: serde_json::json!({}),
13415                snippets: vec![],
13416                invocations: Vec::new(),
13417            }],
13418        };
13419        index.add_conversation(&conv1)?;
13420        index.commit()?;
13421
13422        let client = SearchClient::open(dir.path(), None)?.expect("index present");
13423
13424        // 2. Search "app" -> should hit "apple"
13425        let hits = client.search("app", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
13426        assert_eq!(hits.len(), 1);
13427        assert_eq!(hits[0].content, "apple banana");
13428
13429        // 3. Verify it's cached (peek internal state)
13430        {
13431            let cache = client.prefix_cache.lock().unwrap();
13432            let shard = cache.shard_opt("global").unwrap();
13433            // "app" should be in cache
13434            assert!(shard.contains(&client.cache_key("app", &SearchFilters::default())));
13435        }
13436
13437        // 4. Add new doc with "apricot"
13438        let conv2 = NormalizedConversation {
13439            agent_slug: "codex".into(),
13440            external_id: None,
13441            title: Some("second".into()),
13442            workspace: None,
13443            source_path: dir.path().join("2.jsonl"),
13444            started_at: Some(2),
13445            ended_at: None,
13446            metadata: serde_json::json!({}),
13447            messages: vec![NormalizedMessage {
13448                idx: 0,
13449                role: "user".into(),
13450                author: None,
13451                created_at: Some(2),
13452                content: "apricot".into(),
13453                extra: serde_json::json!({}),
13454                snippets: vec![],
13455                invocations: Vec::new(),
13456            }],
13457        };
13458        index.add_conversation(&conv2)?;
13459        index.commit()?;
13460
13461        // 5. Force reload (mocking time passing or just ensuring reload triggers)
13462        // In test, maybe_reload_reader uses 300ms debounce.
13463        // We can rely on opstamp check logic which runs AFTER reload.
13464        // We need to sleep briefly to bypass debounce or just modify test to not rely on time?
13465        // Actually SearchClient::maybe_reload_reader checks duration.
13466        std::thread::sleep(std::time::Duration::from_millis(350));
13467
13468        // 6. Search "ap" (prefix of apricot and apple)
13469        // The cache for "app" should be cleared if opstamp changed.
13470        let _hits = client.search("app", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
13471        // Should now find 1 doc still ("apple"), but cache should have been cleared first
13472
13473        // Search "apr" -> should find "apricot"
13474        let hits = client.search("apr", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
13475        assert_eq!(hits.len(), 1);
13476        assert_eq!(hits[0].content, "apricot");
13477
13478        // Check that cache was cleared by verifying a stale key is gone?
13479        // Or rely on correctness of results if we searched a common prefix?
13480
13481        Ok(())
13482    }
13483
13484    #[test]
13485    fn track_generation_clears_cache_on_change() {
13486        let client = SearchClient {
13487            reader: None,
13488            sqlite: Mutex::new(None),
13489            sqlite_path: None,
13490            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
13491            reload_on_search: true,
13492            last_reload: Mutex::new(None),
13493            last_generation: Mutex::new(None),
13494            reload_epoch: Arc::new(AtomicU64::new(0)),
13495            warm_tx: None,
13496            _warm_handle: None,
13497            metrics: Metrics::default(),
13498            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
13499            semantic: Mutex::new(None),
13500            last_tantivy_total_count: Mutex::new(None),
13501        };
13502
13503        let hit = SearchHit {
13504            title: "hello world".into(),
13505            snippet: "hello".into(),
13506            content: "hello world".into(),
13507            content_hash: stable_content_hash("hello world"),
13508            score: 1.0,
13509            source_path: "p".into(),
13510            agent: "a".into(),
13511            workspace: "w".into(),
13512            workspace_original: None,
13513            created_at: None,
13514            line_number: None,
13515            match_type: MatchType::Exact,
13516            source_id: "local".into(),
13517            origin_kind: "local".into(),
13518            origin_host: None,
13519            conversation_id: None,
13520        };
13521        let hits = vec![hit];
13522
13523        client.put_cache("hello", &SearchFilters::default(), &hits);
13524        {
13525            let cache = client.prefix_cache.lock().unwrap();
13526            assert!(!cache.shards.is_empty());
13527        }
13528
13529        client.track_generation(1);
13530        {
13531            let cache = client.prefix_cache.lock().unwrap();
13532            assert!(!cache.shards.is_empty());
13533        }
13534
13535        client.track_generation(2);
13536        {
13537            let cache = client.prefix_cache.lock().unwrap();
13538            assert!(cache.shards.is_empty());
13539        }
13540    }
13541
13542    #[test]
13543    fn cache_total_cap_evicts_across_shards() {
13544        let client = SearchClient {
13545            reader: None,
13546            sqlite: Mutex::new(None),
13547            sqlite_path: None,
13548            prefix_cache: Mutex::new(CacheShards::new(2, 0)), // tiny entry cap, no byte cap
13549            reload_on_search: true,
13550            last_reload: Mutex::new(None),
13551            last_generation: Mutex::new(None),
13552            reload_epoch: Arc::new(AtomicU64::new(0)),
13553            warm_tx: None,
13554            _warm_handle: None,
13555            metrics: Metrics::default(),
13556            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
13557            semantic: Mutex::new(None),
13558            last_tantivy_total_count: Mutex::new(None),
13559        };
13560
13561        let hit = SearchHit {
13562            title: "a".into(),
13563            snippet: "a".into(),
13564            content: "a".into(),
13565            content_hash: stable_content_hash("a"),
13566            score: 1.0,
13567            source_path: "p".into(),
13568            agent: "agent1".into(),
13569            workspace: "w".into(),
13570            workspace_original: None,
13571            created_at: None,
13572            line_number: None,
13573            match_type: MatchType::Exact,
13574            source_id: "local".into(),
13575            origin_kind: "local".into(),
13576            origin_host: None,
13577            conversation_id: None,
13578        };
13579        let hits = vec![hit.clone()];
13580
13581        let mut filters = SearchFilters::default();
13582        filters.agents.insert("agent1".into());
13583        client.put_cache("a", &filters, &hits);
13584        filters.agents.clear();
13585        filters.agents.insert("agent2".into());
13586        client.put_cache("b", &filters, &hits);
13587        filters.agents.clear();
13588        filters.agents.insert("agent3".into());
13589        client.put_cache("c", &filters, &hits);
13590
13591        let stats = client.cache_stats();
13592        assert!(stats.total_cost <= stats.total_cap);
13593        assert_eq!(stats.total_cap, 2);
13594    }
13595
13596    #[test]
13597    fn cache_stats_reflect_metrics() {
13598        let client = SearchClient {
13599            reader: None,
13600            sqlite: Mutex::new(None),
13601            sqlite_path: None,
13602            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
13603            reload_on_search: true,
13604            last_reload: Mutex::new(None),
13605            last_generation: Mutex::new(None),
13606            reload_epoch: Arc::new(AtomicU64::new(0)),
13607            warm_tx: None,
13608            _warm_handle: None,
13609            metrics: Metrics::default(),
13610            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
13611            semantic: Mutex::new(None),
13612            last_tantivy_total_count: Mutex::new(None),
13613        };
13614
13615        client.metrics.inc_cache_hits();
13616        client.metrics.inc_cache_miss();
13617        client.metrics.inc_cache_shortfall();
13618        client.metrics.record_reload(Duration::from_millis(10));
13619
13620        let stats = client.cache_stats();
13621        assert_eq!(stats.cache_hits, 1);
13622        assert_eq!(stats.cache_miss, 1);
13623        assert_eq!(stats.cache_shortfall, 1);
13624        assert_eq!(stats.reloads, 1);
13625        assert_eq!(stats.reload_ms_total, 10);
13626        assert_eq!(stats.total_cap, *CACHE_TOTAL_CAP);
13627        assert_eq!(stats.eviction_policy, "lru");
13628        assert_eq!(stats.prewarm_scheduled, 0);
13629        assert_eq!(stats.prewarm_skipped_pressure, 0);
13630        assert_eq!(CacheStats::default().eviction_policy, "unknown");
13631    }
13632
13633    #[test]
13634    fn adaptive_query_prewarm_schedules_only_after_hot_prefix_cache_entry() {
13635        let (tx, rx) = mpsc::unbounded();
13636        let client = SearchClient {
13637            reader: None,
13638            sqlite: Mutex::new(None),
13639            sqlite_path: None,
13640            prefix_cache: Mutex::new(CacheShards::new(10, 0)),
13641            reload_on_search: true,
13642            last_reload: Mutex::new(None),
13643            last_generation: Mutex::new(None),
13644            reload_epoch: Arc::new(AtomicU64::new(0)),
13645            warm_tx: Some(tx),
13646            _warm_handle: None,
13647            metrics: Metrics::default(),
13648            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
13649            semantic: Mutex::new(None),
13650            last_tantivy_total_count: Mutex::new(None),
13651        };
13652        let mut filters = SearchFilters::default();
13653        filters.workspaces.insert("/tmp/cass-workspace".into());
13654
13655        client.maybe_schedule_adaptive_query_prewarm("hel", &filters);
13656        assert!(
13657            rx.try_recv().is_err(),
13658            "cold prefixes should not schedule adaptive prewarm"
13659        );
13660
13661        let mut hit = projected_minimal_fields_search_hit("hello title", "p");
13662        hit.snippet = "hello".into();
13663        hit.content = "hello world".into();
13664        hit.content_hash = stable_content_hash(&hit.content);
13665        client.put_cache("hel", &filters, std::slice::from_ref(&hit));
13666
13667        let total_cost_before = client.cache_stats().total_cost;
13668        client.maybe_schedule_adaptive_query_prewarm("hel", &filters);
13669        assert!(
13670            rx.try_recv().is_err(),
13671            "an exact cached query should not schedule redundant prewarm"
13672        );
13673        client.maybe_schedule_adaptive_query_prewarm("hello", &filters);
13674
13675        let job = rx
13676            .try_recv()
13677            .expect("hot prefix should schedule adaptive prewarm");
13678        assert_eq!(job.query, "hello");
13679        assert_eq!(job.shard_name, "workspace:/tmp/cass-workspace");
13680        assert_eq!(job.filters_fingerprint, filters_fingerprint(&filters));
13681        let stats = client.cache_stats();
13682        assert_eq!(stats.prewarm_scheduled, 1);
13683        assert_eq!(stats.prewarm_skipped_pressure, 0);
13684        assert_eq!(
13685            stats.total_cost, total_cost_before,
13686            "prewarm scheduling should not mutate result-cache contents"
13687        );
13688    }
13689
13690    #[test]
13691    fn adaptive_query_prewarm_skips_when_cache_byte_cap_is_under_pressure() {
13692        let mut hit = projected_minimal_fields_search_hit("hello title", "p");
13693        hit.snippet = "hello".into();
13694        hit.content = "hello world with enough content to consume the small byte budget".into();
13695        hit.content_hash = stable_content_hash(&hit.content);
13696        let byte_cap = cached_hit_from(&hit).approx_bytes();
13697
13698        let (tx, rx) = mpsc::unbounded();
13699        let client = SearchClient {
13700            reader: None,
13701            sqlite: Mutex::new(None),
13702            sqlite_path: None,
13703            prefix_cache: Mutex::new(CacheShards::new(10, byte_cap)),
13704            reload_on_search: true,
13705            last_reload: Mutex::new(None),
13706            last_generation: Mutex::new(None),
13707            reload_epoch: Arc::new(AtomicU64::new(0)),
13708            warm_tx: Some(tx),
13709            _warm_handle: None,
13710            metrics: Metrics::default(),
13711            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
13712            semantic: Mutex::new(None),
13713            last_tantivy_total_count: Mutex::new(None),
13714        };
13715        let filters = SearchFilters::default();
13716
13717        client.put_cache("hel", &filters, std::slice::from_ref(&hit));
13718        client.maybe_schedule_adaptive_query_prewarm("zebra", &filters);
13719        assert_eq!(
13720            client.cache_stats().prewarm_skipped_pressure,
13721            0,
13722            "cold queries should not be counted as pressure-skipped prewarm jobs"
13723        );
13724
13725        client.maybe_schedule_adaptive_query_prewarm("hello", &filters);
13726
13727        assert!(
13728            rx.try_recv().is_err(),
13729            "prewarm should be disabled while cache byte pressure is high"
13730        );
13731        let stats = client.cache_stats();
13732        assert_eq!(stats.prewarm_scheduled, 0);
13733        assert_eq!(stats.prewarm_skipped_pressure, 1);
13734        assert!(stats.approx_bytes <= stats.byte_cap);
13735    }
13736
13737    #[test]
13738    fn cache_eviction_count_tracks_evictions() {
13739        // tiny entry cap (2 entries), no byte cap - forces evictions
13740        let client = SearchClient {
13741            reader: None,
13742            sqlite: Mutex::new(None),
13743            sqlite_path: None,
13744            prefix_cache: Mutex::new(CacheShards::new(2, 0)),
13745            reload_on_search: true,
13746            last_reload: Mutex::new(None),
13747            last_generation: Mutex::new(None),
13748            reload_epoch: Arc::new(AtomicU64::new(0)),
13749            warm_tx: None,
13750            _warm_handle: None,
13751            metrics: Metrics::default(),
13752            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
13753            semantic: Mutex::new(None),
13754            last_tantivy_total_count: Mutex::new(None),
13755        };
13756
13757        let hit = SearchHit {
13758            title: "test".into(),
13759            snippet: "snippet".into(),
13760            content: "content".into(),
13761            content_hash: stable_content_hash("content"),
13762            score: 1.0,
13763            source_path: "p".into(),
13764            agent: "a".into(),
13765            workspace: "w".into(),
13766            workspace_original: None,
13767            created_at: None,
13768            line_number: None,
13769            match_type: MatchType::Exact,
13770            source_id: "local".into(),
13771            origin_kind: "local".into(),
13772            origin_host: None,
13773            conversation_id: None,
13774        };
13775
13776        // Put 3 entries - should trigger 1 eviction (cap is 2)
13777        client.put_cache(
13778            "query1",
13779            &SearchFilters::default(),
13780            std::slice::from_ref(&hit),
13781        );
13782        client.put_cache(
13783            "query2",
13784            &SearchFilters::default(),
13785            std::slice::from_ref(&hit),
13786        );
13787        client.put_cache(
13788            "query3",
13789            &SearchFilters::default(),
13790            std::slice::from_ref(&hit),
13791        );
13792
13793        let stats = client.cache_stats();
13794        assert!(
13795            stats.eviction_count >= 1,
13796            "should have evicted at least 1 entry"
13797        );
13798        assert!(stats.total_cost <= 2, "should be at or below cap");
13799        assert!(stats.approx_bytes > 0, "should track bytes used");
13800    }
13801
13802    #[test]
13803    fn default_cache_byte_cap_scales_with_available_memory() {
13804        let gib = 1024_u64 * 1024 * 1024;
13805
13806        assert_eq!(
13807            default_cache_byte_cap_for_available(None),
13808            DEFAULT_CACHE_BYTE_CAP_FALLBACK
13809        );
13810        assert_eq!(
13811            default_cache_byte_cap_for_available(Some(2 * gib)),
13812            DEFAULT_CACHE_BYTE_CAP_FALLBACK,
13813            "small hosts keep a conservative cache byte budget"
13814        );
13815        assert_eq!(
13816            default_cache_byte_cap_for_available(Some(64 * gib)),
13817            512 * 1024 * 1024,
13818            "larger hosts get a proportionally larger cache byte budget"
13819        );
13820        assert_eq!(
13821            default_cache_byte_cap_for_available(Some(256 * gib)),
13822            usize::try_from(DEFAULT_CACHE_BYTE_CAP_CEILING).unwrap_or(usize::MAX),
13823            "large swarm hosts still have a bounded default cache budget"
13824        );
13825    }
13826
13827    #[test]
13828    fn malformed_cache_byte_cap_env_uses_default_instead_of_disabling_guard() {
13829        let gib = 1024_u64 * 1024 * 1024;
13830
13831        assert_eq!(cache_byte_cap_from_env_value(Some("0"), Some(64 * gib)), 0);
13832        assert_eq!(
13833            cache_byte_cap_from_env_value(Some("not-a-number"), Some(64 * gib)),
13834            default_cache_byte_cap_for_available(Some(64 * gib)),
13835            "malformed env should keep the default memory guard active"
13836        );
13837        assert_eq!(
13838            cache_byte_cap_from_env_value(None, Some(64 * gib)),
13839            default_cache_byte_cap_for_available(Some(64 * gib))
13840        );
13841    }
13842
13843    #[test]
13844    fn cache_eviction_policy_env_defaults_to_lru_and_accepts_s3_fifo() {
13845        assert_eq!(
13846            cache_eviction_policy_from_env_value(None),
13847            CacheEvictionPolicy::Lru
13848        );
13849        assert_eq!(
13850            cache_eviction_policy_from_env_value(Some("not-a-policy")),
13851            CacheEvictionPolicy::Lru,
13852            "malformed env keeps the current LRU behavior"
13853        );
13854        assert_eq!(
13855            cache_eviction_policy_from_env_value(Some("s3-fifo")),
13856            CacheEvictionPolicy::S3Fifo
13857        );
13858        assert_eq!(
13859            cache_eviction_policy_from_env_value(Some("s3_fifo")),
13860            CacheEvictionPolicy::S3Fifo
13861        );
13862    }
13863
13864    #[test]
13865    fn s3_fifo_admission_rejects_one_off_byte_heavy_entries_then_admits_ghost_replay() {
13866        let content = "large".repeat(1_000);
13867        let hit = SearchHit {
13868            title: "large".into(),
13869            snippet: "large".into(),
13870            content: content.clone(),
13871            content_hash: stable_content_hash(&content),
13872            score: 1.0,
13873            source_path: "large-path".into(),
13874            agent: "a".into(),
13875            workspace: "w".into(),
13876            workspace_original: None,
13877            created_at: None,
13878            line_number: None,
13879            match_type: MatchType::Exact,
13880            source_id: "local".into(),
13881            origin_kind: "local".into(),
13882            origin_host: None,
13883            conversation_id: None,
13884        };
13885        let cached = cached_hit_from(&hit);
13886        let byte_cap = cached.approx_bytes() + 1_024;
13887        assert!(
13888            cached.approx_bytes() > byte_cap.div_ceil(S3_FIFO_LARGE_ENTRY_FRACTION_DENOMINATOR)
13889        );
13890
13891        let mut cache = CacheShards::new_with_policy(100, byte_cap, CacheEvictionPolicy::S3Fifo);
13892        let key = Arc::<str>::from("large-query");
13893
13894        cache.put("global", key.clone(), vec![cached.clone()]);
13895        assert_eq!(
13896            cache.total_cost(),
13897            0,
13898            "first one-off large entry is not admitted"
13899        );
13900        assert_eq!(cache.ghost_entries(), 1);
13901        assert_eq!(cache.admission_rejects(), 1);
13902
13903        cache.put("global", key, vec![cached]);
13904        assert_eq!(
13905            cache.total_cost(),
13906            1,
13907            "ghost replay admits the repeated query"
13908        );
13909        assert_eq!(cache.ghost_entries(), 0);
13910        assert!(cache.ghost_keys.is_empty());
13911        assert_eq!(cache.admission_rejects(), 1);
13912        assert!(cache.total_bytes() <= cache.byte_cap());
13913    }
13914
13915    #[test]
13916    fn lru_policy_keeps_admitting_large_entries_under_existing_caps() {
13917        let content = "large".repeat(1_000);
13918        let hit = SearchHit {
13919            title: "large".into(),
13920            snippet: "large".into(),
13921            content: content.clone(),
13922            content_hash: stable_content_hash(&content),
13923            score: 1.0,
13924            source_path: "large-path".into(),
13925            agent: "a".into(),
13926            workspace: "w".into(),
13927            workspace_original: None,
13928            created_at: None,
13929            line_number: None,
13930            match_type: MatchType::Exact,
13931            source_id: "local".into(),
13932            origin_kind: "local".into(),
13933            origin_host: None,
13934            conversation_id: None,
13935        };
13936        let cached = cached_hit_from(&hit);
13937        let byte_cap = cached.approx_bytes() + 1_024;
13938        let mut cache = CacheShards::new_with_policy(100, byte_cap, CacheEvictionPolicy::Lru);
13939
13940        cache.put("global", Arc::<str>::from("large-query"), vec![cached]);
13941
13942        assert_eq!(cache.total_cost(), 1);
13943        assert_eq!(cache.ghost_entries(), 0);
13944        assert_eq!(cache.admission_rejects(), 0);
13945        assert_eq!(cache.policy_label(), "lru");
13946    }
13947
13948    #[test]
13949    fn cache_byte_cap_triggers_eviction() {
13950        // Large entry cap (1000), tiny byte cap (100 bytes) - forces byte-based evictions
13951        let client = SearchClient {
13952            reader: None,
13953            sqlite: Mutex::new(None),
13954            sqlite_path: None,
13955            prefix_cache: Mutex::new(CacheShards::new(1000, 100)), // byte cap of 100
13956            reload_on_search: true,
13957            last_reload: Mutex::new(None),
13958            last_generation: Mutex::new(None),
13959            reload_epoch: Arc::new(AtomicU64::new(0)),
13960            warm_tx: None,
13961            _warm_handle: None,
13962            metrics: Metrics::default(),
13963            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
13964            semantic: Mutex::new(None),
13965            last_tantivy_total_count: Mutex::new(None),
13966        };
13967
13968        // Large content to exceed byte cap quickly
13969        let content = "c".repeat(100);
13970        let hit = SearchHit {
13971            title: "a".repeat(50),
13972            snippet: "b".repeat(50),
13973            content: content.clone(), // 200+ bytes per hit
13974            content_hash: stable_content_hash(&content),
13975            score: 1.0,
13976            source_path: "p".into(),
13977            agent: "a".into(),
13978            workspace: "w".into(),
13979            workspace_original: None,
13980            created_at: None,
13981            line_number: None,
13982            match_type: MatchType::Exact,
13983            source_id: "local".into(),
13984            origin_kind: "local".into(),
13985            origin_host: None,
13986            conversation_id: None,
13987        };
13988
13989        // Put 3 large entries - should trigger byte-based evictions
13990        client.put_cache("q1", &SearchFilters::default(), std::slice::from_ref(&hit));
13991        client.put_cache("q2", &SearchFilters::default(), std::slice::from_ref(&hit));
13992        client.put_cache("q3", &SearchFilters::default(), std::slice::from_ref(&hit));
13993
13994        let stats = client.cache_stats();
13995        assert!(
13996            stats.eviction_count >= 1,
13997            "byte cap should trigger evictions"
13998        );
13999        assert_eq!(stats.byte_cap, 100, "byte cap should be reported");
14000        // Note: approx_bytes may briefly exceed cap during put, but eviction brings it down
14001    }
14002
14003    #[test]
14004    fn cache_byte_pressure_evicts_byte_heavy_shard_before_small_entries() {
14005        let small_hit = SearchHit {
14006            title: "small".into(),
14007            snippet: "small".into(),
14008            content: "small".into(),
14009            content_hash: stable_content_hash("small"),
14010            score: 1.0,
14011            source_path: "small-path".into(),
14012            agent: "a".into(),
14013            workspace: "w".into(),
14014            workspace_original: None,
14015            created_at: None,
14016            line_number: None,
14017            match_type: MatchType::Exact,
14018            source_id: "local".into(),
14019            origin_kind: "local".into(),
14020            origin_host: None,
14021            conversation_id: None,
14022        };
14023        let large_content = "large".repeat(2_000);
14024        let large_hit = SearchHit {
14025            title: "large".into(),
14026            snippet: "large".into(),
14027            content: large_content.clone(),
14028            content_hash: stable_content_hash(&large_content),
14029            score: 1.0,
14030            source_path: "large-path".into(),
14031            agent: "b".into(),
14032            workspace: "w".into(),
14033            workspace_original: None,
14034            created_at: None,
14035            line_number: None,
14036            match_type: MatchType::Exact,
14037            source_id: "local".into(),
14038            origin_kind: "local".into(),
14039            origin_host: None,
14040            conversation_id: None,
14041        };
14042
14043        let mut cache = CacheShards::new(100, 1_024);
14044        cache.put(
14045            "small",
14046            Arc::<str>::from("small-1"),
14047            vec![cached_hit_from(&small_hit)],
14048        );
14049        cache.put(
14050            "small",
14051            Arc::<str>::from("small-2"),
14052            vec![cached_hit_from(&small_hit)],
14053        );
14054        cache.put(
14055            "large",
14056            Arc::<str>::from("large-1"),
14057            vec![cached_hit_from(&large_hit)],
14058        );
14059
14060        assert_eq!(
14061            cache.shard_opt("small").map(LruCache::len),
14062            Some(2),
14063            "byte pressure should preserve the small shard"
14064        );
14065        assert!(
14066            cache.shard_opt("large").is_none_or(LruCache::is_empty),
14067            "oversized shard should be evicted first under byte pressure"
14068        );
14069        assert!(cache.total_bytes() <= cache.byte_cap());
14070    }
14071
14072    // ============================================================
14073    // Phase 7 Tests: WildcardPattern, escape_regex, fallback, dedup
14074    // ============================================================
14075
14076    #[test]
14077    fn wildcard_pattern_parse_exact() {
14078        // No wildcards - exact match
14079        assert_eq!(
14080            FsCassWildcardPattern::parse("hello"),
14081            FsCassWildcardPattern::Exact("hello".into())
14082        );
14083        assert_eq!(
14084            FsCassWildcardPattern::parse("HELLO"),
14085            FsCassWildcardPattern::Exact("hello".into()) // lowercased
14086        );
14087        assert_eq!(
14088            FsCassWildcardPattern::parse("FooBar123"),
14089            FsCassWildcardPattern::Exact("foobar123".into())
14090        );
14091    }
14092
14093    #[test]
14094    fn wildcard_pattern_parse_prefix() {
14095        // Trailing wildcard: foo*
14096        assert_eq!(
14097            FsCassWildcardPattern::parse("foo*"),
14098            FsCassWildcardPattern::Prefix("foo".into())
14099        );
14100        assert_eq!(
14101            FsCassWildcardPattern::parse("CONFIG*"),
14102            FsCassWildcardPattern::Prefix("config".into())
14103        );
14104        assert_eq!(
14105            FsCassWildcardPattern::parse("test*"),
14106            FsCassWildcardPattern::Prefix("test".into())
14107        );
14108    }
14109
14110    #[test]
14111    fn wildcard_pattern_parse_suffix() {
14112        // Leading wildcard: *foo
14113        assert_eq!(
14114            FsCassWildcardPattern::parse("*foo"),
14115            FsCassWildcardPattern::Suffix("foo".into())
14116        );
14117        assert_eq!(
14118            FsCassWildcardPattern::parse("*Error"),
14119            FsCassWildcardPattern::Suffix("error".into())
14120        );
14121        assert_eq!(
14122            FsCassWildcardPattern::parse("*Handler"),
14123            FsCassWildcardPattern::Suffix("handler".into())
14124        );
14125    }
14126
14127    #[test]
14128    fn wildcard_pattern_parse_substring() {
14129        // Both wildcards: *foo*
14130        assert_eq!(
14131            FsCassWildcardPattern::parse("*foo*"),
14132            FsCassWildcardPattern::Substring("foo".into())
14133        );
14134        assert_eq!(
14135            FsCassWildcardPattern::parse("*CONFIG*"),
14136            FsCassWildcardPattern::Substring("config".into())
14137        );
14138        assert_eq!(
14139            FsCassWildcardPattern::parse("*test*"),
14140            FsCassWildcardPattern::Substring("test".into())
14141        );
14142    }
14143
14144    #[test]
14145    fn wildcard_pattern_parse_edge_cases() {
14146        // Empty after trimming wildcards
14147        assert_eq!(
14148            FsCassWildcardPattern::parse("*"),
14149            FsCassWildcardPattern::Exact(String::new())
14150        );
14151        assert_eq!(
14152            FsCassWildcardPattern::parse("**"),
14153            FsCassWildcardPattern::Exact(String::new())
14154        );
14155        assert_eq!(
14156            FsCassWildcardPattern::parse("***"),
14157            FsCassWildcardPattern::Exact(String::new())
14158        );
14159
14160        // Single char with wildcards
14161        assert_eq!(
14162            FsCassWildcardPattern::parse("*a*"),
14163            FsCassWildcardPattern::Substring("a".into())
14164        );
14165        assert_eq!(
14166            FsCassWildcardPattern::parse("a*"),
14167            FsCassWildcardPattern::Prefix("a".into())
14168        );
14169        assert_eq!(
14170            FsCassWildcardPattern::parse("*a"),
14171            FsCassWildcardPattern::Suffix("a".into())
14172        );
14173
14174        // Multiple asterisks get trimmed
14175        assert_eq!(
14176            FsCassWildcardPattern::parse("***foo***"),
14177            FsCassWildcardPattern::Substring("foo".into())
14178        );
14179    }
14180
14181    #[test]
14182    fn wildcard_pattern_to_regex_suffix() {
14183        let pattern = FsCassWildcardPattern::Suffix("foo".into());
14184        // Suffix patterns need $ anchor to ensure "ends with" semantics
14185        assert_eq!(pattern.to_regex(), Some(".*foo$".into()));
14186    }
14187
14188    #[test]
14189    fn wildcard_pattern_to_regex_substring() {
14190        let pattern = FsCassWildcardPattern::Substring("bar".into());
14191        assert_eq!(pattern.to_regex(), Some(".*bar.*".into()));
14192    }
14193
14194    #[test]
14195    fn wildcard_pattern_to_regex_exact_prefix_none() {
14196        // Exact and Prefix patterns don't need regex
14197        let exact = FsCassWildcardPattern::Exact("foo".into());
14198        assert_eq!(exact.to_regex(), None);
14199
14200        let prefix = FsCassWildcardPattern::Prefix("bar".into());
14201        assert_eq!(prefix.to_regex(), None);
14202    }
14203
14204    #[test]
14205    fn match_type_quality_factors() {
14206        // Exact match has highest quality
14207        assert_eq!(MatchType::Exact.quality_factor(), 1.0);
14208        // Prefix is slightly lower
14209        assert_eq!(MatchType::Prefix.quality_factor(), 0.9);
14210        // Suffix is lower than prefix
14211        assert_eq!(MatchType::Suffix.quality_factor(), 0.8);
14212        // Substring is lower still
14213        assert_eq!(MatchType::Substring.quality_factor(), 0.7);
14214        // Implicit wildcard is lowest
14215        assert_eq!(MatchType::ImplicitWildcard.quality_factor(), 0.6);
14216    }
14217
14218    #[test]
14219    fn dominant_match_type_single_terms() {
14220        // Single terms return their pattern's match type
14221        assert_eq!(dominant_match_type("hello"), MatchType::Exact);
14222        assert_eq!(dominant_match_type("hello*"), MatchType::Prefix);
14223        assert_eq!(dominant_match_type("*hello"), MatchType::Suffix);
14224        assert_eq!(dominant_match_type("*hello*"), MatchType::Substring);
14225    }
14226
14227    #[test]
14228    fn dominant_match_type_multiple_terms() {
14229        // Multiple terms: returns the "loosest" (lowest quality factor)
14230        assert_eq!(dominant_match_type("foo bar"), MatchType::Exact);
14231        assert_eq!(dominant_match_type("foo bar*"), MatchType::Prefix);
14232        assert_eq!(dominant_match_type("foo *bar"), MatchType::Suffix);
14233        assert_eq!(dominant_match_type("foo* *bar*"), MatchType::Substring);
14234        // Substring is loosest even if other terms are exact
14235        assert_eq!(dominant_match_type("foo *bar* baz"), MatchType::Substring);
14236    }
14237
14238    #[test]
14239    fn dominant_match_type_empty_query() {
14240        assert_eq!(dominant_match_type(""), MatchType::Exact);
14241        assert_eq!(dominant_match_type("   "), MatchType::Exact);
14242    }
14243
14244    #[test]
14245    fn wildcard_pattern_to_regex_escapes_special_chars() {
14246        assert_eq!(
14247            FsCassWildcardPattern::Suffix("foo.bar".into()).to_regex(),
14248            Some(".*foo\\.bar$".into())
14249        );
14250        assert_eq!(
14251            FsCassWildcardPattern::Substring("a+b*c?".into()).to_regex(),
14252            Some(".*a\\+b\\*c\\?.*".into())
14253        );
14254    }
14255
14256    #[test]
14257    fn wildcard_pattern_to_regex_escapes_complex_patterns() {
14258        assert_eq!(
14259            FsCassWildcardPattern::Suffix("test[0-9]+".into()).to_regex(),
14260            Some(".*test\\[0-9\\]\\+$".into())
14261        );
14262        assert_eq!(
14263            FsCassWildcardPattern::Substring("(a|b)".into()).to_regex(),
14264            Some(".*\\(a\\|b\\).*".into())
14265        );
14266        assert_eq!(
14267            FsCassWildcardPattern::Substring("end$".into()).to_regex(),
14268            Some(".*end\\$.*".into())
14269        );
14270        assert_eq!(
14271            FsCassWildcardPattern::Substring("^start".into()).to_regex(),
14272            Some(".*\\^start.*".into())
14273        );
14274    }
14275
14276    #[test]
14277    fn is_tool_invocation_noise_detects_noise() {
14278        // "[Tool: Name]" is now kept (users search for tool usage)
14279        assert!(!is_tool_invocation_noise("[Tool: Bash]"));
14280        assert!(!is_tool_invocation_noise("[Tool: Read]"));
14281
14282        // Empty tool names are noise
14283        assert!(is_tool_invocation_noise("[Tool:]"));
14284        assert!(is_tool_invocation_noise("[Tool: ]"));
14285
14286        // Useful content should NOT be filtered
14287        assert!(!is_tool_invocation_noise("[Tool: Bash - Check status]"));
14288        assert!(!is_tool_invocation_noise("  [Tool: Grep - Search files]  "));
14289
14290        // Very short tool markers (< 20 chars with "tool" prefix)
14291        assert!(is_tool_invocation_noise("[tool]"));
14292        assert!(is_tool_invocation_noise("tool: Bash"));
14293    }
14294
14295    #[test]
14296    fn is_tool_invocation_noise_allows_useful_content() {
14297        // This should NOT be considered noise
14298        assert!(!is_tool_invocation_noise("[Tool: Read - src/main.rs]"));
14299        assert!(!is_tool_invocation_noise("[Tool: Bash - cargo test --lib]"));
14300    }
14301
14302    #[test]
14303    fn is_tool_invocation_noise_detects_tool_markers() {
14304        // "[Tool: Name]" is now kept (searchable tool usage)
14305        assert!(!is_tool_invocation_noise("[Tool: Bash]"));
14306        assert!(!is_tool_invocation_noise("[Tool: Read]"));
14307
14308        // Empty names are still noise
14309        assert!(is_tool_invocation_noise("[Tool:]"));
14310
14311        // Useful content allowed
14312        assert!(!is_tool_invocation_noise("[Tool: Bash - Check status]"));
14313        assert!(!is_tool_invocation_noise("  [Tool: Write - description]  "));
14314    }
14315
14316    #[test]
14317    fn deduplicate_hits_removes_exact_dupes() {
14318        let hits = vec![
14319            SearchHit {
14320                title: "title1".into(),
14321                snippet: "snip1".into(),
14322                content: "hello world".into(),
14323                content_hash: stable_content_hash("hello world"),
14324                score: 1.0,
14325                source_path: "a.jsonl".into(),
14326                agent: "agent".into(),
14327                workspace: "ws".into(),
14328                workspace_original: None,
14329                created_at: Some(100),
14330                line_number: None,
14331                match_type: MatchType::Exact,
14332                source_id: "local".into(),
14333                origin_kind: "local".into(),
14334                origin_host: None,
14335                conversation_id: None,
14336            },
14337            SearchHit {
14338                title: "title1".into(),
14339                snippet: "snip2".into(),
14340                content: "hello world".into(), // same content
14341                content_hash: stable_content_hash("hello world"),
14342                score: 0.5, // lower score
14343                source_path: "a.jsonl".into(),
14344                agent: "agent".into(),
14345                workspace: "ws".into(),
14346                workspace_original: None,
14347                created_at: Some(100),
14348                line_number: None,
14349                match_type: MatchType::Exact,
14350                source_id: "local".into(), // same source_id = will dedupe
14351                origin_kind: "local".into(),
14352                origin_host: None,
14353                conversation_id: None,
14354            },
14355        ];
14356
14357        let deduped = deduplicate_hits(hits);
14358        assert_eq!(deduped.len(), 1);
14359        assert_eq!(deduped[0].score, 1.0); // kept higher score
14360        assert_eq!(deduped[0].title, "title1");
14361    }
14362
14363    #[test]
14364    fn deduplicate_hits_keeps_higher_score() {
14365        let hits = vec![
14366            SearchHit {
14367                title: "title1".into(),
14368                snippet: "snip1".into(),
14369                content: "hello world".into(),
14370                content_hash: stable_content_hash("hello world"),
14371                score: 0.3, // lower score first
14372                source_path: "a.jsonl".into(),
14373                agent: "agent".into(),
14374                workspace: "ws".into(),
14375                workspace_original: None,
14376                created_at: Some(100),
14377                line_number: None,
14378                match_type: MatchType::Exact,
14379                source_id: "local".into(),
14380                origin_kind: "local".into(),
14381                origin_host: None,
14382                conversation_id: None,
14383            },
14384            SearchHit {
14385                title: "title1".into(),
14386                snippet: "snip2".into(),
14387                content: "hello world".into(),
14388                content_hash: stable_content_hash("hello world"),
14389                score: 0.9, // higher score second
14390                source_path: "a.jsonl".into(),
14391                agent: "agent".into(),
14392                workspace: "ws".into(),
14393                workspace_original: None,
14394                created_at: Some(100),
14395                line_number: None,
14396                match_type: MatchType::Exact,
14397                source_id: "local".into(),
14398                origin_kind: "local".into(),
14399                origin_host: None,
14400                conversation_id: None,
14401            },
14402        ];
14403
14404        let deduped = deduplicate_hits(hits);
14405        assert_eq!(deduped.len(), 1);
14406        assert_eq!(deduped[0].score, 0.9); // kept higher score
14407        assert_eq!(deduped[0].title, "title1");
14408    }
14409
14410    #[test]
14411    fn deduplicate_hits_keeps_repeated_same_content_at_different_lines() {
14412        let first = SearchHit {
14413            title: "Shared Session".into(),
14414            snippet: String::new(),
14415            content: "repeat me".into(),
14416            content_hash: stable_content_hash("repeat me"),
14417            score: 10.0,
14418            source_path: "/shared/session.jsonl".into(),
14419            agent: "codex".into(),
14420            workspace: "/ws".into(),
14421            workspace_original: None,
14422            created_at: Some(100),
14423            line_number: Some(1),
14424            match_type: MatchType::Exact,
14425            source_id: "local".into(),
14426            origin_kind: "local".into(),
14427            origin_host: None,
14428            conversation_id: None,
14429        };
14430        let mut second = first.clone();
14431        second.line_number = Some(2);
14432        second.created_at = Some(200);
14433        second.score = 9.0;
14434
14435        let deduped = deduplicate_hits(vec![first, second]);
14436        assert_eq!(deduped.len(), 2);
14437    }
14438
14439    #[test]
14440    fn deduplicate_hits_keeps_distinct_conversation_ids_with_same_title_path_and_content() {
14441        let mut first = make_test_hit("same", 1.0);
14442        first.title = "Shared Session".into();
14443        first.source_path = "/shared/session.jsonl".into();
14444        first.content = "identical body".into();
14445        first.content_hash = stable_content_hash("identical body");
14446        first.conversation_id = Some(1);
14447
14448        let mut second = first.clone();
14449        second.conversation_id = Some(2);
14450        second.score = 0.9;
14451
14452        let deduped = deduplicate_hits(vec![first, second]);
14453        assert_eq!(deduped.len(), 2);
14454        assert!(deduped.iter().any(|hit| hit.conversation_id == Some(1)));
14455        assert!(deduped.iter().any(|hit| hit.conversation_id == Some(2)));
14456    }
14457
14458    #[test]
14459    fn deduplicate_hits_coalesces_same_conversation_id_despite_title_drift() {
14460        let mut first = make_test_hit("same", 1.0);
14461        first.title = "Morning Session".into();
14462        first.source_path = "/shared/session.jsonl".into();
14463        first.content = "identical body".into();
14464        first.content_hash = stable_content_hash("identical body");
14465        first.conversation_id = Some(7);
14466
14467        let mut second = first.clone();
14468        second.title = "Evening Session".into();
14469        second.score = 0.9;
14470
14471        let deduped = deduplicate_hits(vec![first, second]);
14472        assert_eq!(deduped.len(), 1);
14473        assert_eq!(deduped[0].conversation_id, Some(7));
14474    }
14475
14476    #[test]
14477    fn deduplicate_hits_keeps_distinct_titles_with_same_source_path_and_content() {
14478        let hits = vec![
14479            SearchHit {
14480                title: "Morning Session".into(),
14481                snippet: "snip1".into(),
14482                content: "hello world".into(),
14483                content_hash: stable_content_hash("hello world"),
14484                score: 0.9,
14485                source_path: "shared.jsonl".into(),
14486                agent: "agent".into(),
14487                workspace: "ws".into(),
14488                workspace_original: None,
14489                created_at: None,
14490                line_number: Some(1),
14491                match_type: MatchType::Exact,
14492                source_id: "local".into(),
14493                origin_kind: "local".into(),
14494                origin_host: None,
14495                conversation_id: None,
14496            },
14497            SearchHit {
14498                title: "Evening Session".into(),
14499                snippet: "snip2".into(),
14500                content: "hello world".into(),
14501                content_hash: stable_content_hash("hello world"),
14502                score: 0.8,
14503                source_path: "shared.jsonl".into(),
14504                agent: "agent".into(),
14505                workspace: "ws".into(),
14506                workspace_original: None,
14507                created_at: None,
14508                line_number: Some(1),
14509                match_type: MatchType::Exact,
14510                source_id: "local".into(),
14511                origin_kind: "local".into(),
14512                origin_host: None,
14513                conversation_id: None,
14514            },
14515        ];
14516
14517        let deduped = deduplicate_hits(hits);
14518        assert_eq!(deduped.len(), 2);
14519        assert!(deduped.iter().any(|hit| hit.title == "Morning Session"));
14520        assert!(deduped.iter().any(|hit| hit.title == "Evening Session"));
14521    }
14522
14523    #[test]
14524    fn deduplicate_hits_normalizes_whitespace() {
14525        let hits = vec![
14526            SearchHit {
14527                title: "title1".into(),
14528                snippet: "snip1".into(),
14529                content: "hello    world".into(), // extra spaces
14530                content_hash: stable_content_hash("hello    world"),
14531                score: 1.0,
14532                source_path: "a.jsonl".into(),
14533                agent: "agent".into(),
14534                workspace: "ws".into(),
14535                workspace_original: None,
14536                created_at: Some(100),
14537                line_number: None,
14538                match_type: MatchType::Exact,
14539                source_id: "local".into(),
14540                origin_kind: "local".into(),
14541                origin_host: None,
14542                conversation_id: None,
14543            },
14544            SearchHit {
14545                title: "title1".into(),
14546                snippet: "snip2".into(),
14547                content: "hello world".into(), // normal spacing
14548                content_hash: stable_content_hash("hello world"),
14549                score: 0.5,
14550                source_path: "a.jsonl".into(),
14551                agent: "agent".into(),
14552                workspace: "ws".into(),
14553                workspace_original: None,
14554                created_at: Some(100),
14555                line_number: None,
14556                match_type: MatchType::Exact,
14557                source_id: "local".into(),
14558                origin_kind: "local".into(),
14559                origin_host: None,
14560                conversation_id: None,
14561            },
14562        ];
14563
14564        let deduped = deduplicate_hits(hits);
14565        assert_eq!(deduped.len(), 1); // normalized to same content
14566    }
14567
14568    #[test]
14569    fn deduplicate_hits_normalizes_blank_local_source_id() {
14570        let hits = vec![
14571            SearchHit {
14572                title: "title1".into(),
14573                snippet: "snip1".into(),
14574                content: "hello world".into(),
14575                content_hash: stable_content_hash("hello world"),
14576                score: 1.0,
14577                source_path: "a.jsonl".into(),
14578                agent: "agent".into(),
14579                workspace: "ws".into(),
14580                workspace_original: None,
14581                created_at: Some(100),
14582                line_number: None,
14583                match_type: MatchType::Exact,
14584                source_id: "local".into(),
14585                origin_kind: "local".into(),
14586                origin_host: None,
14587                conversation_id: None,
14588            },
14589            SearchHit {
14590                title: "title1".into(),
14591                snippet: "snip2".into(),
14592                content: "hello world".into(),
14593                content_hash: stable_content_hash("hello world"),
14594                score: 0.5,
14595                source_path: "a.jsonl".into(),
14596                agent: "agent".into(),
14597                workspace: "ws".into(),
14598                workspace_original: None,
14599                created_at: Some(100),
14600                line_number: None,
14601                match_type: MatchType::Exact,
14602                source_id: "   ".into(),
14603                origin_kind: "local".into(),
14604                origin_host: None,
14605                conversation_id: None,
14606            },
14607        ];
14608
14609        let deduped = deduplicate_hits(hits);
14610        assert_eq!(deduped.len(), 1);
14611        assert_eq!(deduped[0].source_id, "local");
14612    }
14613
14614    #[test]
14615    fn deduplicate_hits_filters_tool_noise() {
14616        let hits = vec![
14617            SearchHit {
14618                title: "title1".into(),
14619                snippet: "snip1".into(),
14620                content: "[Tool:]".into(), // noise (empty tool name)
14621                content_hash: stable_content_hash("[Tool:]"),
14622                score: 1.0,
14623                source_path: "a.jsonl".into(),
14624                agent: "agent".into(),
14625                workspace: "ws".into(),
14626                workspace_original: None,
14627                created_at: Some(100),
14628                line_number: None,
14629                match_type: MatchType::Exact,
14630                source_id: "local".into(),
14631                origin_kind: "local".into(),
14632                origin_host: None,
14633                conversation_id: None,
14634            },
14635            SearchHit {
14636                title: "title2".into(),
14637                snippet: "snip2".into(),
14638                content: "This is real content about testing".into(),
14639                content_hash: stable_content_hash("This is real content about testing"),
14640                score: 0.5,
14641                source_path: "b.jsonl".into(),
14642                agent: "agent".into(),
14643                workspace: "ws".into(),
14644                workspace_original: None,
14645                created_at: Some(200),
14646                line_number: None,
14647                match_type: MatchType::Exact,
14648                source_id: "local".into(),
14649                origin_kind: "local".into(),
14650                origin_host: None,
14651                conversation_id: None,
14652            },
14653        ];
14654
14655        let deduped = deduplicate_hits(hits);
14656        assert_eq!(deduped.len(), 1);
14657        assert!(deduped[0].content.contains("real content"));
14658    }
14659
14660    #[test]
14661    fn deduplicate_hits_filters_acknowledgement_noise() {
14662        let hits = vec![
14663            SearchHit {
14664                title: "ack".into(),
14665                snippet: "ack".into(),
14666                content: "Acknowledged.".into(),
14667                content_hash: stable_content_hash("Acknowledged."),
14668                score: 1.0,
14669                source_path: "ack.jsonl".into(),
14670                agent: "agent".into(),
14671                workspace: "ws".into(),
14672                workspace_original: None,
14673                created_at: Some(100),
14674                line_number: None,
14675                match_type: MatchType::Exact,
14676                source_id: "local".into(),
14677                origin_kind: "local".into(),
14678                origin_host: None,
14679                conversation_id: None,
14680            },
14681            SearchHit {
14682                title: "real".into(),
14683                snippet: "real".into(),
14684                content: "Authentication refresh logic changed".into(),
14685                content_hash: stable_content_hash("Authentication refresh logic changed"),
14686                score: 0.5,
14687                source_path: "real.jsonl".into(),
14688                agent: "agent".into(),
14689                workspace: "ws".into(),
14690                workspace_original: None,
14691                created_at: Some(200),
14692                line_number: None,
14693                match_type: MatchType::Exact,
14694                source_id: "local".into(),
14695                origin_kind: "local".into(),
14696                origin_host: None,
14697                conversation_id: None,
14698            },
14699        ];
14700
14701        let deduped = deduplicate_hits_with_query(hits, "authentication");
14702        assert_eq!(deduped.len(), 1);
14703        assert_eq!(deduped[0].title, "real");
14704    }
14705
14706    #[test]
14707    fn deduplicate_hits_hides_system_prompts_unless_query_requests_them() {
14708        let prompt_hit = SearchHit {
14709            title: "prompt".into(),
14710            snippet: "prompt".into(),
14711            content:
14712                "# AGENTS.md instructions for /repo\n\nYou are a coding assistant. Follow the instructions exactly."
14713                    .into(),
14714            content_hash: stable_content_hash(
14715                "# AGENTS.md instructions for /repo\n\nYou are a coding assistant. Follow the instructions exactly.",
14716            ),
14717            score: 1.0,
14718            source_path: "prompt.jsonl".into(),
14719            agent: "agent".into(),
14720            workspace: "ws".into(),
14721            workspace_original: None,
14722            created_at: Some(100),
14723            line_number: None,
14724            match_type: MatchType::Exact,
14725            source_id: "local".into(),
14726            origin_kind: "local".into(),
14727            origin_host: None,
14728            conversation_id: None,
14729        };
14730
14731        assert!(
14732            deduplicate_hits_with_query(vec![prompt_hit.clone()], "coding assistant").is_empty()
14733        );
14734
14735        let kept = deduplicate_hits_with_query(vec![prompt_hit], "AGENTS.md instructions");
14736        assert_eq!(kept.len(), 1);
14737        assert_eq!(kept[0].title, "prompt");
14738    }
14739
14740    #[test]
14741    fn deduplicate_hits_preserves_unique_content() {
14742        let hits = vec![
14743            SearchHit {
14744                title: "title1".into(),
14745                snippet: "snip1".into(),
14746                content: "first message".into(),
14747                content_hash: stable_content_hash("first message"),
14748                score: 1.0,
14749                source_path: "a.jsonl".into(),
14750                agent: "agent".into(),
14751                workspace: "ws".into(),
14752                workspace_original: None,
14753                created_at: Some(100),
14754                line_number: None,
14755                match_type: MatchType::Exact,
14756                source_id: "local".into(),
14757                origin_kind: "local".into(),
14758                origin_host: None,
14759                conversation_id: None,
14760            },
14761            SearchHit {
14762                title: "title2".into(),
14763                snippet: "snip2".into(),
14764                content: "second message".into(),
14765                content_hash: stable_content_hash("second message"),
14766                score: 0.8,
14767                source_path: "b.jsonl".into(),
14768                agent: "agent".into(),
14769                workspace: "ws".into(),
14770                workspace_original: None,
14771                created_at: Some(200),
14772                line_number: None,
14773                match_type: MatchType::Exact,
14774                source_id: "local".into(),
14775                origin_kind: "local".into(),
14776                origin_host: None,
14777                conversation_id: None,
14778            },
14779            SearchHit {
14780                title: "title3".into(),
14781                snippet: "snip3".into(),
14782                content: "third message".into(),
14783                content_hash: stable_content_hash("third message"),
14784                score: 0.6,
14785                source_path: "c.jsonl".into(),
14786                agent: "agent".into(),
14787                workspace: "ws".into(),
14788                workspace_original: None,
14789                created_at: Some(300),
14790                line_number: None,
14791                match_type: MatchType::Exact,
14792                source_id: "local".into(),
14793                origin_kind: "local".into(),
14794                origin_host: None,
14795                conversation_id: None,
14796            },
14797        ];
14798
14799        let deduped = deduplicate_hits(hits);
14800        assert_eq!(deduped.len(), 3); // all unique
14801    }
14802
14803    /// P2.3: Deduplication respects source boundaries - same content from different sources
14804    /// should appear as separate results.
14805    #[test]
14806    fn deduplicate_hits_respects_source_boundaries() {
14807        let hits = vec![
14808            SearchHit {
14809                title: "local title".into(),
14810                snippet: "snip".into(),
14811                content: "hello world".into(),
14812                content_hash: stable_content_hash("hello world"),
14813                score: 1.0,
14814                source_path: "a.jsonl".into(),
14815                agent: "agent".into(),
14816                workspace: "ws".into(),
14817                workspace_original: None,
14818                created_at: Some(100),
14819                line_number: None,
14820                match_type: MatchType::Exact,
14821                source_id: "local".into(),
14822                origin_kind: "local".into(),
14823                origin_host: None,
14824                conversation_id: None,
14825            },
14826            SearchHit {
14827                title: "remote title".into(),
14828                snippet: "snip".into(),
14829                content: "hello world".into(), // same content
14830                content_hash: stable_content_hash("hello world"),
14831                score: 0.9,
14832                source_path: "b.jsonl".into(),
14833                agent: "agent".into(),
14834                workspace: "ws".into(),
14835                workspace_original: None,
14836                created_at: Some(200),
14837                line_number: None,
14838                match_type: MatchType::Exact,
14839                source_id: "work-laptop".into(), // different source = no dedupe
14840                origin_kind: "ssh".into(),
14841                origin_host: Some("work-laptop.local".into()),
14842                conversation_id: None,
14843            },
14844        ];
14845
14846        let deduped = deduplicate_hits(hits);
14847        assert_eq!(
14848            deduped.len(),
14849            2,
14850            "same content from different sources should not dedupe"
14851        );
14852        assert!(deduped.iter().any(|h| h.source_id == "local"));
14853        assert!(deduped.iter().any(|h| h.source_id == "work-laptop"));
14854    }
14855
14856    #[test]
14857    fn wildcard_fallback_sparse_check_uses_effective_limit() {
14858        assert!(
14859            !should_try_wildcard_fallback(1, 1, 0, 3),
14860            "a filled one-result page is not sparse for fallback purposes"
14861        );
14862        assert!(
14863            !should_try_wildcard_fallback(2, 2, 0, 3),
14864            "a filled two-result page is not sparse for fallback purposes"
14865        );
14866        assert!(
14867            should_try_wildcard_fallback(0, 1, 0, 3),
14868            "zero hits should still trigger fallback even for tiny pages"
14869        );
14870        assert!(
14871            should_try_wildcard_fallback(1, 2, 0, 3),
14872            "a partially filled page should still trigger fallback"
14873        );
14874        assert!(
14875            !should_try_wildcard_fallback(0, 5, 10, 3),
14876            "pagination should not trigger wildcard fallback"
14877        );
14878        assert!(
14879            should_try_wildcard_fallback(1, 0, 0, 3),
14880            "limit zero preserves the legacy sparse-threshold semantics"
14881        );
14882    }
14883
14884    #[test]
14885    fn snippet_preview_fast_path_requires_snippet_only_match() {
14886        let snippet_only = FieldMask::new(false, true, false, false);
14887        let snippet = snippet_from_preview_without_full_content(
14888            snippet_only,
14889            "migration checks the database constraint before writing",
14890            "database",
14891        )
14892        .expect("preview should satisfy a snippet-only request when it contains the query");
14893        assert!(snippet.contains("**database**"));
14894
14895        assert!(
14896            snippet_from_preview_without_full_content(
14897                FieldMask::FULL,
14898                "migration checks the database constraint before writing",
14899                "database",
14900            )
14901            .is_none(),
14902            "full-content requests must keep the sqlite hydration path"
14903        );
14904        assert!(
14905            snippet_from_preview_without_full_content(
14906                snippet_only,
14907                "migration checks constraints before writing",
14908                "database",
14909            )
14910            .is_none(),
14911            "snippet-only requests hydrate when the preview cannot show the match"
14912        );
14913    }
14914
14915    #[test]
14916    fn search_with_fallback_returns_exact_when_sufficient() -> Result<()> {
14917        let dir = TempDir::new()?;
14918        let mut index = TantivyIndex::open_or_create(dir.path())?;
14919
14920        // Add enough docs to exceed threshold - each with UNIQUE content to avoid dedup
14921        for i in 0..5 {
14922            let conv = NormalizedConversation {
14923                agent_slug: "codex".into(),
14924                external_id: None,
14925                title: Some(format!("doc-{i}")),
14926                workspace: Some(std::path::PathBuf::from("/ws")),
14927                source_path: dir.path().join(format!("{i}.jsonl")),
14928                started_at: Some(100 + i),
14929                ended_at: None,
14930                metadata: serde_json::json!({}),
14931                messages: vec![NormalizedMessage {
14932                    idx: 0,
14933                    role: "user".into(),
14934                    author: None,
14935                    created_at: Some(100 + i),
14936                    // Each doc has unique content but shares "apple" keyword
14937                    content: format!("apple fruit number {i} is delicious and healthy"),
14938                    extra: serde_json::json!({}),
14939                    snippets: vec![],
14940                    invocations: Vec::new(),
14941                }],
14942            };
14943            index.add_conversation(&conv)?;
14944        }
14945        index.commit()?;
14946
14947        let client = SearchClient::open(dir.path(), None)?.expect("index present");
14948
14949        // Search with low threshold - should not trigger fallback
14950        let result = client.search_with_fallback(
14951            "apple",
14952            SearchFilters::default(),
14953            10,
14954            0,
14955            3, // threshold of 3
14956            FieldMask::FULL,
14957        )?;
14958
14959        assert!(!result.wildcard_fallback);
14960        assert!(result.hits.len() >= 3); // has enough results
14961
14962        Ok(())
14963    }
14964
14965    #[test]
14966    fn search_with_fallback_triggers_on_sparse_results() -> Result<()> {
14967        let dir = TempDir::new()?;
14968        let mut index = TantivyIndex::open_or_create(dir.path())?;
14969
14970        // Add docs with substring that won't match exact prefix
14971        let conv = NormalizedConversation {
14972            agent_slug: "codex".into(),
14973            external_id: None,
14974            title: Some("substring test".into()),
14975            workspace: Some(std::path::PathBuf::from("/ws")),
14976            source_path: dir.path().join("test.jsonl"),
14977            started_at: Some(100),
14978            ended_at: None,
14979            metadata: serde_json::json!({}),
14980            messages: vec![NormalizedMessage {
14981                idx: 0,
14982                role: "user".into(),
14983                author: None,
14984                created_at: Some(100),
14985                content: "configuration management system".into(),
14986                extra: serde_json::json!({}),
14987                snippets: vec![],
14988                invocations: Vec::new(),
14989            }],
14990        };
14991        index.add_conversation(&conv)?;
14992        index.commit()?;
14993
14994        let client = SearchClient::open(dir.path(), None)?.expect("index present");
14995
14996        // Search for "config" which should match "configuration" via prefix
14997        let result = client.search_with_fallback(
14998            "config",
14999            SearchFilters::default(),
15000            10,
15001            0,
15002            5, // high threshold
15003            FieldMask::FULL,
15004        )?;
15005
15006        // Since we have only 1 result and threshold is 5, it may trigger fallback
15007        // but *config* would still match "configuration"
15008        assert!(!result.hits.is_empty());
15009
15010        Ok(())
15011    }
15012
15013    #[test]
15014    fn search_with_fallback_skips_when_query_has_wildcards() -> Result<()> {
15015        let dir = TempDir::new()?;
15016        let mut index = TantivyIndex::open_or_create(dir.path())?;
15017
15018        let conv = NormalizedConversation {
15019            agent_slug: "codex".into(),
15020            external_id: None,
15021            title: Some("test".into()),
15022            workspace: None,
15023            source_path: dir.path().join("test.jsonl"),
15024            started_at: Some(100),
15025            ended_at: None,
15026            metadata: serde_json::json!({}),
15027            messages: vec![NormalizedMessage {
15028                idx: 0,
15029                role: "user".into(),
15030                author: None,
15031                created_at: Some(100),
15032                content: "testing data".into(),
15033                extra: serde_json::json!({}),
15034                snippets: vec![],
15035                invocations: Vec::new(),
15036            }],
15037        };
15038        index.add_conversation(&conv)?;
15039        index.commit()?;
15040
15041        let client = SearchClient::open(dir.path(), None)?.expect("index present");
15042
15043        // Query already has wildcards - should not trigger fallback
15044        let result = client.search_with_fallback(
15045            "*test*",
15046            SearchFilters::default(),
15047            10,
15048            0,
15049            10, // high threshold
15050            FieldMask::FULL,
15051        )?;
15052
15053        assert!(!result.wildcard_fallback); // shouldn't trigger fallback for wildcard queries
15054        Ok(())
15055    }
15056
15057    #[test]
15058    fn search_with_fallback_prefers_wildcards_when_they_add_hits() -> Result<()> {
15059        let dir = TempDir::new()?;
15060        let mut index = TantivyIndex::open_or_create(dir.path())?;
15061
15062        // None of these documents contain the exact token "bet",
15063        // but they do contain it as a substring ("alphabet").
15064        for (i, body) in [
15065            "alphabet soup for coders",
15066            "mapping the alphabet city blocks",
15067        ]
15068        .iter()
15069        .enumerate()
15070        {
15071            let conv = NormalizedConversation {
15072                agent_slug: "codex".into(),
15073                external_id: None,
15074                title: Some(format!("alpha-{i}")),
15075                workspace: Some(std::path::PathBuf::from("/ws")),
15076                source_path: dir.path().join(format!("alpha-{i}.jsonl")),
15077                started_at: Some(100 + i as i64),
15078                ended_at: None,
15079                metadata: serde_json::json!({}),
15080                messages: vec![NormalizedMessage {
15081                    idx: 0,
15082                    role: "user".into(),
15083                    author: None,
15084                    created_at: Some(100 + i as i64),
15085                    content: body.to_string(),
15086                    extra: serde_json::json!({}),
15087                    snippets: vec![],
15088                    invocations: Vec::new(),
15089                }],
15090            };
15091            index.add_conversation(&conv)?;
15092        }
15093        index.commit()?;
15094
15095        let client = SearchClient::open(dir.path(), None)?.expect("index present");
15096
15097        let result = client.search_with_fallback(
15098            "bet",
15099            SearchFilters::default(),
15100            10,
15101            0,
15102            2,
15103            FieldMask::FULL,
15104        )?;
15105
15106        assert!(
15107            result.wildcard_fallback,
15108            "should switch to wildcard fallback when it yields more hits"
15109        );
15110        assert_eq!(
15111            result.hits.len(),
15112            2,
15113            "fallback should surface all alphabet docs"
15114        );
15115        assert!(
15116            result
15117                .hits
15118                .iter()
15119                .all(|h| h.match_type == MatchType::ImplicitWildcard)
15120        );
15121        assert!(result.hits.iter().all(|h| h.content.contains("alphabet")));
15122
15123        Ok(())
15124    }
15125
15126    #[test]
15127    fn automatic_wildcard_fallback_skips_long_zero_hit_token() -> Result<()> {
15128        let dir = TempDir::new()?;
15129        let mut index = TantivyIndex::open_or_create(dir.path())?;
15130
15131        let conv = NormalizedConversation {
15132            agent_slug: "codex".into(),
15133            external_id: None,
15134            title: Some("fruit".into()),
15135            workspace: Some(std::path::PathBuf::from("/ws")),
15136            source_path: dir.path().join("fruit.jsonl"),
15137            started_at: Some(100),
15138            ended_at: None,
15139            metadata: serde_json::json!({}),
15140            messages: vec![NormalizedMessage {
15141                idx: 0,
15142                role: "user".into(),
15143                author: None,
15144                created_at: Some(100),
15145                content: "apple pear banana".into(),
15146                extra: serde_json::json!({}),
15147                snippets: vec![],
15148                invocations: Vec::new(),
15149            }],
15150        };
15151        index.add_conversation(&conv)?;
15152        index.commit()?;
15153
15154        let client = SearchClient::open(dir.path(), None)?.expect("index present");
15155
15156        let result = client.search_with_fallback(
15157            "zzzzzzunlikelyterm",
15158            SearchFilters::default(),
15159            10,
15160            0,
15161            1,
15162            FieldMask::FULL,
15163        )?;
15164        assert!(result.hits.is_empty());
15165        assert!(!result.wildcard_fallback);
15166        assert!(
15167            result
15168                .suggestions
15169                .iter()
15170                .any(|s| matches!(s.kind, SuggestionKind::WildcardQuery)),
15171            "manual wildcard suggestion should remain available"
15172        );
15173
15174        let short_result = client.search_with_fallback(
15175            "pple",
15176            SearchFilters::default(),
15177            10,
15178            0,
15179            1,
15180            FieldMask::FULL,
15181        )?;
15182        assert!(short_result.wildcard_fallback);
15183        assert_eq!(short_result.hits.len(), 1);
15184        assert_eq!(short_result.hits[0].match_type, MatchType::ImplicitWildcard);
15185
15186        Ok(())
15187    }
15188
15189    #[test]
15190    fn nohit_suggestions_do_not_lazy_open_sqlite_when_tantivy_is_present() -> Result<()> {
15191        let dir = TempDir::new()?;
15192        let index_path = dir.path().join("index");
15193        let db_path = dir.path().join("cass.db");
15194
15195        let storage = FrankenStorage::open(&db_path)?;
15196        storage.close()?;
15197
15198        let mut index = TantivyIndex::open_or_create(&index_path)?;
15199        let conv = NormalizedConversation {
15200            agent_slug: "codex".into(),
15201            external_id: None,
15202            title: Some("fruit".into()),
15203            workspace: Some(std::path::PathBuf::from("/ws")),
15204            source_path: dir.path().join("fruit.jsonl"),
15205            started_at: Some(100),
15206            ended_at: None,
15207            metadata: serde_json::json!({}),
15208            messages: vec![NormalizedMessage {
15209                idx: 0,
15210                role: "user".into(),
15211                author: None,
15212                created_at: Some(100),
15213                content: "apple pear banana".into(),
15214                extra: serde_json::json!({}),
15215                snippets: vec![],
15216                invocations: Vec::new(),
15217            }],
15218        };
15219        index.add_conversation(&conv)?;
15220        index.commit()?;
15221
15222        let client = SearchClient::open(&index_path, Some(&db_path))?.expect("index present");
15223        assert!(
15224            client
15225                .sqlite
15226                .lock()
15227                .map(|guard| guard.is_none())
15228                .unwrap_or(false),
15229            "sqlite should start closed"
15230        );
15231
15232        let result = client.search_with_fallback(
15233            "zzzzzzunlikelyterm",
15234            SearchFilters::default(),
15235            10,
15236            0,
15237            1,
15238            FieldMask::FULL,
15239        )?;
15240
15241        assert!(result.hits.is_empty());
15242        assert!(
15243            result
15244                .suggestions
15245                .iter()
15246                .any(|s| matches!(s.kind, SuggestionKind::WildcardQuery)),
15247            "manual wildcard suggestion should remain available"
15248        );
15249        assert!(
15250            result
15251                .suggestions
15252                .iter()
15253                .all(|s| !matches!(s.kind, SuggestionKind::AlternateAgent)),
15254            "alternate-agent suggestions should not force a SQLite open"
15255        );
15256        assert!(
15257            client
15258                .sqlite
15259                .lock()
15260                .map(|guard| guard.is_none())
15261                .unwrap_or(false),
15262            "sqlite should stay closed after Tantivy no-hit suggestions"
15263        );
15264
15265        Ok(())
15266    }
15267
15268    #[test]
15269    fn search_with_fallback_emits_wildcard_suggestion_on_zero_hits() -> Result<()> {
15270        let client = SearchClient {
15271            reader: None,
15272            sqlite: Mutex::new(None),
15273            sqlite_path: None,
15274            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
15275            reload_on_search: true,
15276            last_reload: Mutex::new(None),
15277            last_generation: Mutex::new(None),
15278            reload_epoch: Arc::new(AtomicU64::new(0)),
15279            warm_tx: None,
15280            _warm_handle: None,
15281            metrics: Metrics::default(),
15282            cache_namespace: "vtest|schema:none".into(),
15283            semantic: Mutex::new(None),
15284            last_tantivy_total_count: Mutex::new(None),
15285        };
15286
15287        let result = client.search_with_fallback(
15288            "ghost",
15289            SearchFilters::default(),
15290            5,
15291            0,
15292            3,
15293            FieldMask::FULL,
15294        )?;
15295
15296        assert!(
15297            result.hits.is_empty(),
15298            "no index/db means no hits should be returned"
15299        );
15300        assert!(
15301            !result.wildcard_fallback,
15302            "with zero baseline and fallback hits, we should keep baseline and mark fallback=false"
15303        );
15304
15305        let wildcard = result
15306            .suggestions
15307            .iter()
15308            .find(|s| matches!(s.kind, SuggestionKind::WildcardQuery))
15309            .expect("should suggest adding wildcards");
15310        assert_eq!(wildcard.suggested_query.as_deref(), Some("*ghost*"));
15311
15312        Ok(())
15313    }
15314
15315    #[test]
15316    fn search_with_fallback_skips_empty_query() -> Result<()> {
15317        let dir = TempDir::new()?;
15318        let mut index = TantivyIndex::open_or_create(dir.path())?;
15319
15320        let conv = NormalizedConversation {
15321            agent_slug: "codex".into(),
15322            external_id: None,
15323            title: Some("test".into()),
15324            workspace: None,
15325            source_path: dir.path().join("test.jsonl"),
15326            started_at: Some(100),
15327            ended_at: None,
15328            metadata: serde_json::json!({}),
15329            messages: vec![NormalizedMessage {
15330                idx: 0,
15331                role: "user".into(),
15332                author: None,
15333                created_at: Some(100),
15334                content: "testing data".into(),
15335                extra: serde_json::json!({}),
15336                snippets: vec![],
15337                invocations: Vec::new(),
15338            }],
15339        };
15340        index.add_conversation(&conv)?;
15341        index.commit()?;
15342
15343        let client = SearchClient::open(dir.path(), None)?.expect("index present");
15344
15345        // Empty query - should not trigger fallback
15346        let result = client.search_with_fallback(
15347            "  ",
15348            SearchFilters::default(),
15349            10,
15350            0,
15351            10,
15352            FieldMask::FULL,
15353        )?;
15354
15355        assert!(!result.wildcard_fallback);
15356        Ok(())
15357    }
15358
15359    #[test]
15360    fn search_with_fallback_skips_for_nonzero_offset() -> Result<()> {
15361        // Even with zero hits, fallback should not run when paginating (offset > 0)
15362        let client = SearchClient {
15363            reader: None,
15364            sqlite: Mutex::new(None),
15365            sqlite_path: None,
15366            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
15367            reload_on_search: true,
15368            last_reload: Mutex::new(None),
15369            last_generation: Mutex::new(None),
15370            reload_epoch: Arc::new(AtomicU64::new(0)),
15371            warm_tx: None,
15372            _warm_handle: None,
15373            metrics: Metrics::default(),
15374            cache_namespace: "vtest|schema:none".into(),
15375            semantic: Mutex::new(None),
15376            last_tantivy_total_count: Mutex::new(None),
15377        };
15378
15379        let result = client.search_with_fallback(
15380            "ghost",
15381            SearchFilters::default(),
15382            5,
15383            10,
15384            3,
15385            FieldMask::FULL,
15386        )?;
15387
15388        assert!(
15389            !result.wildcard_fallback,
15390            "fallback should not run on paginated searches"
15391        );
15392        // Suggestions still surface (wildcard suggestion expected)
15393        let wildcard = result
15394            .suggestions
15395            .iter()
15396            .find(|s| matches!(s.kind, SuggestionKind::WildcardQuery))
15397            .expect("wildcard suggestion present");
15398        assert_eq!(wildcard.suggested_query.as_deref(), Some("*ghost*"));
15399
15400        Ok(())
15401    }
15402
15403    #[test]
15404    fn generate_suggestions_limits_and_sets_shortcuts() -> Result<()> {
15405        // Build a client without backends; suggestions are purely local heuristics
15406        let client = SearchClient {
15407            reader: None,
15408            sqlite: Mutex::new(None),
15409            sqlite_path: None,
15410            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
15411            reload_on_search: true,
15412            last_reload: Mutex::new(None),
15413            last_generation: Mutex::new(None),
15414            reload_epoch: Arc::new(AtomicU64::new(0)),
15415            warm_tx: None,
15416            _warm_handle: None,
15417            metrics: Metrics::default(),
15418            cache_namespace: "vtest|schema:none".into(),
15419            semantic: Mutex::new(None),
15420            last_tantivy_total_count: Mutex::new(None),
15421        };
15422
15423        let mut filters = SearchFilters::default();
15424        filters.agents.insert("codex".into()); // triggers remove-agent suggestion
15425
15426        let result = client.search_with_fallback("claud", filters, 5, 0, 3, FieldMask::FULL)?;
15427
15428        // Should cap at 3 suggestions with shortcuts 1..=3
15429        assert_eq!(
15430            result.suggestions.len(),
15431            3,
15432            "should truncate to 3 suggestions"
15433        );
15434        for (idx, sugg) in result.suggestions.iter().enumerate() {
15435            assert_eq!(
15436                sugg.shortcut,
15437                Some((idx + 1) as u8),
15438                "shortcut should match position (1-based)"
15439            );
15440        }
15441
15442        // Expect wildcard, remove filter, and spelling fix (claud -> claude)
15443        assert!(
15444            result
15445                .suggestions
15446                .iter()
15447                .any(|s| matches!(s.kind, SuggestionKind::WildcardQuery)),
15448            "should suggest wildcard search"
15449        );
15450        assert!(
15451            result
15452                .suggestions
15453                .iter()
15454                .any(|s| matches!(s.kind, SuggestionKind::RemoveFilter)),
15455            "should suggest removing agent filter"
15456        );
15457        assert!(
15458            result
15459                .suggestions
15460                .iter()
15461                .any(|s| matches!(s.kind, SuggestionKind::SpellingFix)),
15462            "should suggest spelling fix for nearby agent name"
15463        );
15464
15465        Ok(())
15466    }
15467
15468    #[test]
15469    fn generate_suggestions_includes_recent_alternate_agents() -> Result<()> {
15470        let dir = TempDir::new()?;
15471        let db_path = dir.path().join("cass.db");
15472        let storage = FrankenStorage::open(&db_path)?;
15473        let workspace_id = storage.ensure_workspace(dir.path(), None)?;
15474        let base_ts = 1_700_000_010_000_i64;
15475
15476        for (idx, slug) in ["claude_code", "codex"].iter().enumerate() {
15477            let agent = Agent {
15478                id: None,
15479                slug: (*slug).to_string(),
15480                name: (*slug).to_string(),
15481                version: None,
15482                kind: AgentKind::Cli,
15483            };
15484            let agent_id = storage.ensure_agent(&agent)?;
15485            let conversation = Conversation {
15486                id: None,
15487                agent_slug: (*slug).to_string(),
15488                workspace: Some(dir.path().to_path_buf()),
15489                external_id: Some(format!("alt-agent-{idx}")),
15490                title: Some(format!("alternate agent {idx}")),
15491                source_path: dir.path().join(format!("{slug}.jsonl")),
15492                started_at: Some(base_ts + idx as i64),
15493                ended_at: Some(base_ts + idx as i64),
15494                approx_tokens: Some(8),
15495                metadata_json: json!({}),
15496                messages: vec![Message {
15497                    id: None,
15498                    idx: 0,
15499                    role: MessageRole::User,
15500                    author: Some("user".into()),
15501                    created_at: Some(base_ts + idx as i64),
15502                    content: format!("content from {slug}"),
15503                    extra_json: json!({}),
15504                    snippets: Vec::new(),
15505                }],
15506                source_id: crate::sources::provenance::LOCAL_SOURCE_ID.to_string(),
15507                origin_host: None,
15508            };
15509            storage.insert_conversation_tree(agent_id, Some(workspace_id), &conversation)?;
15510        }
15511        drop(storage);
15512
15513        let client = SearchClient::open(dir.path(), Some(&db_path))?.expect("db-backed client");
15514        let result = client.search_with_fallback(
15515            "ghost",
15516            SearchFilters::default(),
15517            5,
15518            0,
15519            3,
15520            FieldMask::FULL,
15521        )?;
15522
15523        let alternate_agents: HashSet<String> = result
15524            .suggestions
15525            .iter()
15526            .filter(|suggestion| matches!(suggestion.kind, SuggestionKind::AlternateAgent))
15527            .filter_map(|suggestion| suggestion.suggested_filters.as_ref())
15528            .flat_map(|filters| filters.agents.iter().cloned())
15529            .collect();
15530
15531        assert!(
15532            alternate_agents.contains("claude_code"),
15533            "should suggest claude_code from normalized conversations schema"
15534        );
15535        assert!(
15536            alternate_agents.contains("codex"),
15537            "should suggest codex from normalized conversations schema"
15538        );
15539
15540        Ok(())
15541    }
15542
15543    #[test]
15544    fn sanitize_query_preserves_wildcards() {
15545        // Wildcards should be preserved
15546        assert_eq!(fs_cass_sanitize_query("*foo*"), "*foo*");
15547        assert_eq!(fs_cass_sanitize_query("foo*"), "foo*");
15548        assert_eq!(fs_cass_sanitize_query("*bar"), "*bar");
15549        assert_eq!(fs_cass_sanitize_query("*config*"), "*config*");
15550    }
15551
15552    #[test]
15553    fn sanitize_query_strips_other_special_chars() {
15554        // Non-wildcard special chars become spaces
15555        assert_eq!(fs_cass_sanitize_query("foo.bar"), "foo bar");
15556        assert_eq!(fs_cass_sanitize_query("c++"), "c  ");
15557        assert_eq!(fs_cass_sanitize_query("foo-bar"), "foo-bar");
15558        assert_eq!(fs_cass_sanitize_query("test_case"), "test case");
15559    }
15560
15561    #[test]
15562    fn sanitize_query_combined() {
15563        // Mix of wildcards and special chars
15564        assert_eq!(fs_cass_sanitize_query("*foo.bar*"), "*foo bar*");
15565        assert_eq!(fs_cass_sanitize_query("test-*"), "test-*");
15566        assert_eq!(fs_cass_sanitize_query("*c++*"), "*c  *");
15567    }
15568
15569    // Boolean query parsing tests
15570    #[test]
15571    fn parse_boolean_query_simple_terms() {
15572        let tokens = fs_cass_parse_boolean_query("foo bar baz");
15573        assert_eq!(tokens.len(), 3);
15574        assert_eq!(tokens[0], FsCassQueryToken::Term("foo".to_string()));
15575        assert_eq!(tokens[1], FsCassQueryToken::Term("bar".to_string()));
15576        assert_eq!(tokens[2], FsCassQueryToken::Term("baz".to_string()));
15577    }
15578
15579    #[test]
15580    fn parse_boolean_query_and_operator() {
15581        let tokens = fs_cass_parse_boolean_query("foo AND bar");
15582        assert_eq!(tokens.len(), 3);
15583        assert_eq!(tokens[0], FsCassQueryToken::Term("foo".to_string()));
15584        assert_eq!(tokens[1], FsCassQueryToken::And);
15585        assert_eq!(tokens[2], FsCassQueryToken::Term("bar".to_string()));
15586
15587        // Also test && syntax
15588        let tokens2 = fs_cass_parse_boolean_query("foo && bar");
15589        assert_eq!(tokens2.len(), 3);
15590        assert_eq!(tokens2[1], FsCassQueryToken::And);
15591    }
15592
15593    #[test]
15594    fn parse_boolean_query_or_operator() {
15595        let tokens = fs_cass_parse_boolean_query("foo OR bar");
15596        assert_eq!(tokens.len(), 3);
15597        assert_eq!(tokens[0], FsCassQueryToken::Term("foo".to_string()));
15598        assert_eq!(tokens[1], FsCassQueryToken::Or);
15599        assert_eq!(tokens[2], FsCassQueryToken::Term("bar".to_string()));
15600
15601        // Also test || syntax
15602        let tokens2 = fs_cass_parse_boolean_query("foo || bar");
15603        assert_eq!(tokens2.len(), 3);
15604        assert_eq!(tokens2[1], FsCassQueryToken::Or);
15605    }
15606
15607    #[test]
15608    fn parse_boolean_query_not_operator() {
15609        let tokens = fs_cass_parse_boolean_query("foo NOT bar");
15610        assert_eq!(tokens.len(), 3);
15611        assert_eq!(tokens[0], FsCassQueryToken::Term("foo".to_string()));
15612        assert_eq!(tokens[1], FsCassQueryToken::Not);
15613        assert_eq!(tokens[2], FsCassQueryToken::Term("bar".to_string()));
15614    }
15615
15616    #[test]
15617    fn parse_boolean_query_quoted_phrase() {
15618        let tokens = fs_cass_parse_boolean_query(r#"foo "exact phrase" bar"#);
15619        assert_eq!(tokens.len(), 3);
15620        assert_eq!(tokens[0], FsCassQueryToken::Term("foo".to_string()));
15621        assert_eq!(
15622            tokens[1],
15623            FsCassQueryToken::Phrase("exact phrase".to_string())
15624        );
15625        assert_eq!(tokens[2], FsCassQueryToken::Term("bar".to_string()));
15626    }
15627
15628    #[test]
15629    fn parse_boolean_query_complex() {
15630        let tokens = fs_cass_parse_boolean_query(r#"error OR warning NOT "false positive""#);
15631        assert_eq!(tokens.len(), 5);
15632        assert_eq!(tokens[0], FsCassQueryToken::Term("error".to_string()));
15633        assert_eq!(tokens[1], FsCassQueryToken::Or);
15634        assert_eq!(tokens[2], FsCassQueryToken::Term("warning".to_string()));
15635        assert_eq!(tokens[3], FsCassQueryToken::Not);
15636        assert_eq!(
15637            tokens[4],
15638            FsCassQueryToken::Phrase("false positive".to_string())
15639        );
15640    }
15641
15642    #[test]
15643    fn has_boolean_operators_detection() {
15644        assert!(!fs_cass_has_boolean_operators("foo bar"));
15645        assert!(fs_cass_has_boolean_operators("foo AND bar"));
15646        assert!(fs_cass_has_boolean_operators("foo OR bar"));
15647        assert!(fs_cass_has_boolean_operators("foo NOT bar"));
15648        assert!(fs_cass_has_boolean_operators(r#""exact phrase""#));
15649        assert!(fs_cass_has_boolean_operators("foo && bar"));
15650        assert!(fs_cass_has_boolean_operators("foo || bar"));
15651    }
15652
15653    #[test]
15654    fn parse_boolean_query_case_insensitive_operators() {
15655        // Operators should be case-insensitive
15656        let tokens = fs_cass_parse_boolean_query("foo and bar or baz not qux");
15657        assert_eq!(tokens.len(), 7);
15658        assert_eq!(tokens[1], FsCassQueryToken::And);
15659        assert_eq!(tokens[3], FsCassQueryToken::Or);
15660        assert_eq!(tokens[5], FsCassQueryToken::Not);
15661    }
15662
15663    #[test]
15664    fn parse_boolean_query_with_wildcards() {
15665        let tokens = fs_cass_parse_boolean_query("*config* OR env*");
15666        assert_eq!(tokens.len(), 3);
15667        assert_eq!(tokens[0], FsCassQueryToken::Term("*config*".to_string()));
15668        assert_eq!(tokens[1], FsCassQueryToken::Or);
15669        assert_eq!(tokens[2], FsCassQueryToken::Term("env*".to_string()));
15670    }
15671
15672    // ============================================================
15673    // Filter Fidelity Property Tests (glt.9)
15674    // Verify filters are never violated in search results
15675    // ============================================================
15676
15677    #[test]
15678    fn tantivy_search_hydrates_long_content_when_content_field_is_not_stored() -> Result<()> {
15679        let dir = TempDir::new()?;
15680        let db_path = dir.path().join("cass.db");
15681        let storage = FrankenStorage::open(&db_path)?;
15682        let workspace_id = storage.ensure_workspace(dir.path(), None)?;
15683        let agent = Agent {
15684            id: None,
15685            slug: "codex".into(),
15686            name: "Codex".into(),
15687            version: None,
15688            kind: AgentKind::Cli,
15689        };
15690        let agent_id = storage.ensure_agent(&agent)?;
15691        let long_content = format!(
15692            "{}needle appears past the preview boundary for hydration proof",
15693            "padding ".repeat(70)
15694        );
15695        let short_content = "shortneedle fits entirely inside the stored preview".to_string();
15696        let conversation = Conversation {
15697            id: None,
15698            agent_slug: "codex".into(),
15699            workspace: Some(dir.path().to_path_buf()),
15700            external_id: Some("hydrate-long-content".into()),
15701            title: Some("hydrated lexical doc".into()),
15702            source_path: dir.path().join("hydrate.jsonl"),
15703            started_at: Some(1_700_000_123_000),
15704            ended_at: Some(1_700_000_123_000),
15705            approx_tokens: Some(32),
15706            metadata_json: json!({}),
15707            messages: vec![
15708                Message {
15709                    id: None,
15710                    idx: 0,
15711                    role: MessageRole::User,
15712                    author: Some("user".into()),
15713                    created_at: Some(1_700_000_123_000),
15714                    content: long_content.clone(),
15715                    extra_json: json!({}),
15716                    snippets: Vec::new(),
15717                },
15718                Message {
15719                    id: None,
15720                    idx: 1,
15721                    role: MessageRole::Agent,
15722                    author: Some("assistant".into()),
15723                    created_at: Some(1_700_000_124_000),
15724                    content: short_content.clone(),
15725                    extra_json: json!({}),
15726                    snippets: Vec::new(),
15727                },
15728            ],
15729            source_id: crate::sources::provenance::LOCAL_SOURCE_ID.to_string(),
15730            origin_host: None,
15731        };
15732        storage.insert_conversation_tree(agent_id, Some(workspace_id), &conversation)?;
15733        storage.close()?;
15734
15735        let index_path = dir.path().join("search-index");
15736        let mut index = TantivyIndex::open_or_create(&index_path)?;
15737        let normalized = NormalizedConversation {
15738            agent_slug: "codex".into(),
15739            external_id: Some("hydrate-long-content".into()),
15740            title: Some("hydrated lexical doc".into()),
15741            workspace: Some(dir.path().to_path_buf()),
15742            source_path: dir.path().join("hydrate.jsonl"),
15743            started_at: Some(1_700_000_123_000),
15744            ended_at: Some(1_700_000_123_000),
15745            metadata: json!({}),
15746            messages: vec![
15747                NormalizedMessage {
15748                    idx: 0,
15749                    role: "user".into(),
15750                    author: Some("user".into()),
15751                    created_at: Some(1_700_000_123_000),
15752                    content: long_content.clone(),
15753                    extra: json!({}),
15754                    snippets: vec![],
15755                    invocations: Vec::new(),
15756                },
15757                NormalizedMessage {
15758                    idx: 1,
15759                    role: "assistant".into(),
15760                    author: Some("assistant".into()),
15761                    created_at: Some(1_700_000_124_000),
15762                    content: short_content.clone(),
15763                    extra: json!({}),
15764                    snippets: vec![],
15765                    invocations: Vec::new(),
15766                },
15767            ],
15768        };
15769        index.add_conversation(&normalized)?;
15770        index.commit()?;
15771
15772        let client = SearchClient::open(&index_path, Some(&db_path))?.expect("db-backed client");
15773        let hits = client.search("needle", SearchFilters::default(), 5, 0, FieldMask::FULL)?;
15774
15775        assert_eq!(hits.len(), 1, "expected one lexical hit");
15776        assert_eq!(hits[0].title, "hydrated lexical doc");
15777        assert!(
15778            hits[0]
15779                .content
15780                .contains("needle appears past the preview boundary"),
15781            "lexical hit should hydrate full content from sqlite when Tantivy content is not stored"
15782        );
15783        assert!(
15784            hits[0].snippet.to_lowercase().contains("needle"),
15785            "snippet should still be rendered from hydrated content"
15786        );
15787
15788        let bounded_hits = client.search(
15789            "needle",
15790            SearchFilters::default(),
15791            5,
15792            0,
15793            FieldMask::FULL.with_preview_content_limit(Some(200)),
15794        )?;
15795
15796        assert_eq!(bounded_hits.len(), 1, "expected one lexical hit");
15797        assert!(
15798            bounded_hits[0].content.starts_with("padding padding"),
15799            "bounded content may be served from the stored preview prefix"
15800        );
15801        assert!(
15802            !bounded_hits[0]
15803                .content
15804                .contains("needle appears past the preview boundary"),
15805            "bounded preview content should not hydrate the full sqlite row"
15806        );
15807
15808        let short_client =
15809            SearchClient::open(&index_path, Some(&db_path))?.expect("db-backed client");
15810        assert!(
15811            short_client
15812                .sqlite
15813                .lock()
15814                .map(|guard| guard.is_none())
15815                .unwrap_or(false),
15816            "sqlite should start closed for short preview hit"
15817        );
15818
15819        let short_hits = short_client.search(
15820            "shortneedle",
15821            SearchFilters::default(),
15822            5,
15823            0,
15824            FieldMask::FULL,
15825        )?;
15826
15827        assert_eq!(short_hits.len(), 1, "expected one short lexical hit");
15828        assert_eq!(
15829            short_hits[0].content, short_content,
15830            "untruncated stored preview is exact full content"
15831        );
15832        assert!(
15833            short_client
15834                .sqlite
15835                .lock()
15836                .map(|guard| guard.is_none())
15837                .unwrap_or(false),
15838            "short full-content hit should not lazy-open sqlite"
15839        );
15840
15841        Ok(())
15842    }
15843
15844    #[test]
15845    fn filter_fidelity_agent_filter_respected() -> Result<()> {
15846        // Multiple agents; filter should return only matching agent
15847        let dir = TempDir::new()?;
15848        let mut index = TantivyIndex::open_or_create(dir.path())?;
15849
15850        // Agent A (codex)
15851        let conv_a = NormalizedConversation {
15852            agent_slug: "codex".into(),
15853            external_id: None,
15854            title: Some("alpha doc".into()),
15855            workspace: None,
15856            source_path: dir.path().join("a.jsonl"),
15857            started_at: Some(100),
15858            ended_at: None,
15859            metadata: serde_json::json!({}),
15860            messages: vec![NormalizedMessage {
15861                idx: 0,
15862                role: "user".into(),
15863                author: None,
15864                created_at: Some(100),
15865                content: "hello world findme alpha".into(),
15866                extra: serde_json::json!({}),
15867                snippets: vec![],
15868                invocations: Vec::new(),
15869            }],
15870        };
15871        // Agent B (claude)
15872        let conv_b = NormalizedConversation {
15873            agent_slug: "claude".into(),
15874            external_id: None,
15875            title: Some("beta doc".into()),
15876            workspace: None,
15877            source_path: dir.path().join("b.jsonl"),
15878            started_at: Some(200),
15879            ended_at: None,
15880            metadata: serde_json::json!({}),
15881            messages: vec![NormalizedMessage {
15882                idx: 0,
15883                role: "user".into(),
15884                author: None,
15885                created_at: Some(200),
15886                content: "hello world findme beta".into(),
15887                extra: serde_json::json!({}),
15888                snippets: vec![],
15889                invocations: Vec::new(),
15890            }],
15891        };
15892        index.add_conversation(&conv_a)?;
15893        index.add_conversation(&conv_b)?;
15894        index.commit()?;
15895
15896        let client = SearchClient::open(dir.path(), None)?.expect("index present");
15897
15898        // Search with agent filter for codex only
15899        let mut filters = SearchFilters::default();
15900        filters.agents.insert("codex".into());
15901
15902        let hits = client.search("findme", filters.clone(), 10, 0, FieldMask::FULL)?;
15903
15904        // Property: all results must have agent == "codex"
15905        for hit in &hits {
15906            assert_eq!(
15907                hit.agent, "codex",
15908                "Agent filter violated: got agent '{}' instead of 'codex'",
15909                hit.agent
15910            );
15911        }
15912        assert!(!hits.is_empty(), "Should have found results");
15913
15914        // Repeat search (should use cache) and verify same property
15915        let cached_hits = client.search("findme", filters, 10, 0, FieldMask::FULL)?;
15916        for hit in &cached_hits {
15917            assert_eq!(hit.agent, "codex", "Cached search violated agent filter");
15918        }
15919
15920        Ok(())
15921    }
15922
15923    #[test]
15924    fn filter_fidelity_workspace_filter_respected() -> Result<()> {
15925        // Multiple workspaces; filter should return only matching workspace
15926        let dir = TempDir::new()?;
15927        let mut index = TantivyIndex::open_or_create(dir.path())?;
15928
15929        // Workspace A
15930        let conv_a = NormalizedConversation {
15931            agent_slug: "codex".into(),
15932            external_id: None,
15933            title: Some("ws_a doc".into()),
15934            workspace: Some(std::path::PathBuf::from("/workspace/alpha")),
15935            source_path: dir.path().join("a.jsonl"),
15936            started_at: Some(100),
15937            ended_at: None,
15938            metadata: serde_json::json!({}),
15939            messages: vec![NormalizedMessage {
15940                idx: 0,
15941                role: "user".into(),
15942                author: None,
15943                created_at: Some(100),
15944                content: "workspace test needle".into(),
15945                extra: serde_json::json!({}),
15946                snippets: vec![],
15947                invocations: Vec::new(),
15948            }],
15949        };
15950        // Workspace B
15951        let conv_b = NormalizedConversation {
15952            agent_slug: "codex".into(),
15953            external_id: None,
15954            title: Some("ws_b doc".into()),
15955            workspace: Some(std::path::PathBuf::from("/workspace/beta")),
15956            source_path: dir.path().join("b.jsonl"),
15957            started_at: Some(200),
15958            ended_at: None,
15959            metadata: serde_json::json!({}),
15960            messages: vec![NormalizedMessage {
15961                idx: 0,
15962                role: "user".into(),
15963                author: None,
15964                created_at: Some(200),
15965                content: "workspace test needle".into(),
15966                extra: serde_json::json!({}),
15967                snippets: vec![],
15968                invocations: Vec::new(),
15969            }],
15970        };
15971        index.add_conversation(&conv_a)?;
15972        index.add_conversation(&conv_b)?;
15973        index.commit()?;
15974
15975        let client = SearchClient::open(dir.path(), None)?.expect("index present");
15976
15977        // Search with workspace filter for beta only
15978        let mut filters = SearchFilters::default();
15979        filters.workspaces.insert("/workspace/beta".into());
15980
15981        let hits = client.search("needle", filters.clone(), 10, 0, FieldMask::FULL)?;
15982
15983        // Property: all results must have workspace == "/workspace/beta"
15984        for hit in &hits {
15985            assert_eq!(
15986                hit.workspace, "/workspace/beta",
15987                "Workspace filter violated: got '{}' instead of '/workspace/beta'",
15988                hit.workspace
15989            );
15990        }
15991        assert!(!hits.is_empty(), "Should have found results");
15992
15993        // Repeat search (should use cache)
15994        let cached_hits = client.search("needle", filters, 10, 0, FieldMask::FULL)?;
15995        for hit in &cached_hits {
15996            assert_eq!(
15997                hit.workspace, "/workspace/beta",
15998                "Cached search violated workspace filter"
15999            );
16000        }
16001
16002        Ok(())
16003    }
16004
16005    #[test]
16006    fn filter_fidelity_date_range_respected() -> Result<()> {
16007        // Multiple dates; filter should return only within range
16008        let dir = TempDir::new()?;
16009        let mut index = TantivyIndex::open_or_create(dir.path())?;
16010
16011        // Early doc (ts=100)
16012        let conv_early = NormalizedConversation {
16013            agent_slug: "codex".into(),
16014            external_id: None,
16015            title: Some("early".into()),
16016            workspace: None,
16017            source_path: dir.path().join("early.jsonl"),
16018            started_at: Some(100),
16019            ended_at: None,
16020            metadata: serde_json::json!({}),
16021            messages: vec![NormalizedMessage {
16022                idx: 0,
16023                role: "user".into(),
16024                author: None,
16025                created_at: Some(100),
16026                content: "date range test".into(),
16027                extra: serde_json::json!({}),
16028                snippets: vec![],
16029                invocations: Vec::new(),
16030            }],
16031        };
16032        // Middle doc (ts=500)
16033        let conv_middle = NormalizedConversation {
16034            agent_slug: "codex".into(),
16035            external_id: None,
16036            title: Some("middle".into()),
16037            workspace: None,
16038            source_path: dir.path().join("middle.jsonl"),
16039            started_at: Some(500),
16040            ended_at: None,
16041            metadata: serde_json::json!({}),
16042            messages: vec![NormalizedMessage {
16043                idx: 0,
16044                role: "user".into(),
16045                author: None,
16046                created_at: Some(500),
16047                content: "date range test".into(),
16048                extra: serde_json::json!({}),
16049                snippets: vec![],
16050                invocations: Vec::new(),
16051            }],
16052        };
16053        // Late doc (ts=900)
16054        let conv_late = NormalizedConversation {
16055            agent_slug: "codex".into(),
16056            external_id: None,
16057            title: Some("late".into()),
16058            workspace: None,
16059            source_path: dir.path().join("late.jsonl"),
16060            started_at: Some(900),
16061            ended_at: None,
16062            metadata: serde_json::json!({}),
16063            messages: vec![NormalizedMessage {
16064                idx: 0,
16065                role: "user".into(),
16066                author: None,
16067                created_at: Some(900),
16068                content: "date range test".into(),
16069                extra: serde_json::json!({}),
16070                snippets: vec![],
16071                invocations: Vec::new(),
16072            }],
16073        };
16074        index.add_conversation(&conv_early)?;
16075        index.add_conversation(&conv_middle)?;
16076        index.add_conversation(&conv_late)?;
16077        index.commit()?;
16078
16079        let client = SearchClient::open(dir.path(), None)?.expect("index present");
16080
16081        // Filter for middle range only (400-600)
16082        let filters = SearchFilters {
16083            created_from: Some(400),
16084            created_to: Some(600),
16085            ..Default::default()
16086        };
16087
16088        let hits = client.search("range", filters.clone(), 10, 0, FieldMask::FULL)?;
16089
16090        // Property: all results must have created_at within [400, 600]
16091        for hit in &hits {
16092            if let Some(ts) = hit.created_at {
16093                assert!(
16094                    (400..=600).contains(&ts),
16095                    "Date range filter violated: got ts={ts} outside [400, 600]"
16096                );
16097            }
16098        }
16099        // Should find only the middle doc
16100        assert_eq!(hits.len(), 1, "Should find exactly 1 doc in range");
16101
16102        // Repeat search (cache)
16103        let cached_hits = client.search("range", filters, 10, 0, FieldMask::FULL)?;
16104        for hit in &cached_hits {
16105            if let Some(ts) = hit.created_at {
16106                assert!(
16107                    (400..=600).contains(&ts),
16108                    "Cached search violated date range filter"
16109                );
16110            }
16111        }
16112
16113        Ok(())
16114    }
16115
16116    #[test]
16117    fn filter_fidelity_combined_filters_respected() -> Result<()> {
16118        // Combine agent + workspace + date filters
16119        let dir = TempDir::new()?;
16120        let mut index = TantivyIndex::open_or_create(dir.path())?;
16121
16122        // Create 4 docs with different combinations
16123        let combinations = [
16124            ("codex", "/ws/prod", 100),  // wrong date
16125            ("claude", "/ws/prod", 500), // correct agent, correct ws, correct date
16126            ("claude", "/ws/dev", 500),  // correct agent, wrong ws, correct date
16127            ("claude", "/ws/prod", 900), // correct agent, correct ws, wrong date
16128        ];
16129
16130        for (i, (agent, ws, ts)) in combinations.iter().enumerate() {
16131            let conv = NormalizedConversation {
16132                agent_slug: (*agent).into(),
16133                external_id: None,
16134                title: Some(format!("combo-{i}")),
16135                workspace: Some(std::path::PathBuf::from(*ws)),
16136                source_path: dir.path().join(format!("{i}.jsonl")),
16137                started_at: Some(*ts),
16138                ended_at: None,
16139                metadata: serde_json::json!({}),
16140                messages: vec![NormalizedMessage {
16141                    idx: 0,
16142                    role: "user".into(),
16143                    author: None,
16144                    created_at: Some(*ts),
16145                    content: "hello world combotest query".into(),
16146                    extra: serde_json::json!({}),
16147                    snippets: vec![],
16148                    invocations: Vec::new(),
16149                }],
16150            };
16151            index.add_conversation(&conv)?;
16152        }
16153        index.commit()?;
16154
16155        let client = SearchClient::open(dir.path(), None)?.expect("index present");
16156
16157        // Filter: claude + /ws/prod + date 400-600
16158        let mut filters = SearchFilters::default();
16159        filters.agents.insert("claude".into());
16160        filters.workspaces.insert("/ws/prod".into());
16161        filters.created_from = Some(400);
16162        filters.created_to = Some(600);
16163
16164        let hits = client.search("combotest", filters.clone(), 10, 0, FieldMask::FULL)?;
16165
16166        // Should find exactly 1 doc (index 1 in combinations)
16167        assert_eq!(hits.len(), 1, "Combined filter should match exactly 1 doc");
16168
16169        for hit in &hits {
16170            assert_eq!(hit.agent, "claude", "Agent filter violated");
16171            assert_eq!(hit.workspace, "/ws/prod", "Workspace filter violated");
16172            if let Some(ts) = hit.created_at {
16173                assert!((400..=600).contains(&ts), "Date filter violated: ts={ts}");
16174            }
16175        }
16176
16177        // Cache hit
16178        let cached = client.search("combotest", filters, 10, 0, FieldMask::FULL)?;
16179        assert_eq!(cached.len(), 1, "Cached result count mismatch");
16180
16181        Ok(())
16182    }
16183
16184    #[test]
16185    fn lexical_hits_normalize_trimmed_local_source_metadata() -> Result<()> {
16186        let dir = TempDir::new()?;
16187        let mut index = TantivyIndex::open_or_create(dir.path())?;
16188
16189        let conv = NormalizedConversation {
16190            agent_slug: "codex".into(),
16191            external_id: None,
16192            title: Some("trimmed local doc".into()),
16193            workspace: None,
16194            source_path: dir.path().join("trimmed-local.jsonl"),
16195            started_at: Some(100),
16196            ended_at: None,
16197            metadata: serde_json::json!({
16198                "cass": {
16199                    "origin": {
16200                        "source_id": "  LOCAL  ",
16201                        "kind": "local"
16202                    }
16203                }
16204            }),
16205            messages: vec![NormalizedMessage {
16206                idx: 0,
16207                role: "user".into(),
16208                author: None,
16209                created_at: Some(100),
16210                content: "trimmed local lexical".into(),
16211                extra: serde_json::json!({}),
16212                snippets: vec![],
16213                invocations: Vec::new(),
16214            }],
16215        };
16216        index.add_conversation(&conv)?;
16217        index.commit()?;
16218
16219        let client = SearchClient::open(dir.path(), None)?.expect("index present");
16220        let hits = client.search("trimmed", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
16221
16222        assert_eq!(hits.len(), 1);
16223        assert_eq!(hits[0].source_id, "local");
16224        assert_eq!(hits[0].origin_kind, "local");
16225
16226        Ok(())
16227    }
16228
16229    #[test]
16230    fn lexical_hits_normalize_remote_origin_kind_without_source_id() -> Result<()> {
16231        let dir = TempDir::new()?;
16232        let mut index = TantivyIndex::open_or_create(dir.path())?;
16233
16234        let conv = NormalizedConversation {
16235            agent_slug: "codex".into(),
16236            external_id: None,
16237            title: Some("remote lexical doc".into()),
16238            workspace: None,
16239            source_path: dir.path().join("remote-lexical.jsonl"),
16240            started_at: Some(100),
16241            ended_at: None,
16242            metadata: serde_json::json!({
16243                "cass": {
16244                    "origin": {
16245                        "source_id": "   ",
16246                        "kind": "ssh",
16247                        "host": "dev@laptop"
16248                    }
16249                }
16250            }),
16251            messages: vec![NormalizedMessage {
16252                idx: 0,
16253                role: "user".into(),
16254                author: None,
16255                created_at: Some(100),
16256                content: "remote lexical".into(),
16257                extra: serde_json::json!({}),
16258                snippets: vec![],
16259                invocations: Vec::new(),
16260            }],
16261        };
16262        index.add_conversation(&conv)?;
16263        index.commit()?;
16264
16265        let client = SearchClient::open(dir.path(), None)?.expect("index present");
16266        let hits = client.search("remote", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
16267
16268        assert_eq!(hits.len(), 1);
16269        assert_eq!(hits[0].source_id, "dev@laptop");
16270        assert_eq!(hits[0].origin_kind, "remote");
16271        assert_eq!(hits[0].origin_host.as_deref(), Some("dev@laptop"));
16272
16273        Ok(())
16274    }
16275
16276    #[test]
16277    fn lexical_hits_infer_remote_origin_from_host_without_kind() -> Result<()> {
16278        let dir = TempDir::new()?;
16279        let mut index = TantivyIndex::open_or_create(dir.path())?;
16280
16281        let conv = NormalizedConversation {
16282            agent_slug: "codex".into(),
16283            external_id: None,
16284            title: Some("legacy host-only lexical doc".into()),
16285            workspace: None,
16286            source_path: dir.path().join("legacy-host-only-lexical.jsonl"),
16287            started_at: Some(100),
16288            ended_at: None,
16289            metadata: serde_json::json!({
16290                "cass": {
16291                    "origin": {
16292                        "source_id": "   ",
16293                        "host": "dev@laptop"
16294                    }
16295                }
16296            }),
16297            messages: vec![NormalizedMessage {
16298                idx: 0,
16299                role: "user".into(),
16300                author: None,
16301                created_at: Some(100),
16302                content: "legacy remote lexical".into(),
16303                extra: serde_json::json!({}),
16304                snippets: vec![],
16305                invocations: Vec::new(),
16306            }],
16307        };
16308        index.add_conversation(&conv)?;
16309        index.commit()?;
16310
16311        let client = SearchClient::open(dir.path(), None)?.expect("index present");
16312        let hits = client.search("legacy", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
16313
16314        assert_eq!(hits.len(), 1);
16315        assert_eq!(hits[0].source_id, "dev@laptop");
16316        assert_eq!(hits[0].origin_kind, "remote");
16317        assert_eq!(hits[0].origin_host.as_deref(), Some("dev@laptop"));
16318
16319        Ok(())
16320    }
16321
16322    #[test]
16323    fn filter_fidelity_source_filter_respected() -> Result<()> {
16324        // P3.1: Source filter should filter by origin_kind or source_id
16325        let dir = TempDir::new()?;
16326        let mut index = TantivyIndex::open_or_create(dir.path())?;
16327
16328        // Local source doc
16329        let conv_local = NormalizedConversation {
16330            agent_slug: "codex".into(),
16331            external_id: None,
16332            title: Some("local doc".into()),
16333            workspace: None,
16334            source_path: dir.path().join("local.jsonl"),
16335            started_at: Some(100),
16336            ended_at: None,
16337            metadata: serde_json::json!({}),
16338            messages: vec![NormalizedMessage {
16339                idx: 0,
16340                role: "user".into(),
16341                author: None,
16342                created_at: Some(100),
16343                content: "source filter test local".into(),
16344                extra: serde_json::json!({}),
16345                snippets: vec![],
16346                invocations: Vec::new(),
16347            }],
16348        };
16349        // Remote source doc (would need to be indexed with ssh origin_kind)
16350        // For now, test that local filter returns local docs
16351        index.add_conversation(&conv_local)?;
16352        index.commit()?;
16353
16354        let client = SearchClient::open(dir.path(), None)?.expect("index present");
16355
16356        // Filter for local sources
16357        let filters = SearchFilters {
16358            source_filter: SourceFilter::Local,
16359            ..Default::default()
16360        };
16361
16362        let hits = client.search("source", filters.clone(), 10, 0, FieldMask::FULL)?;
16363
16364        // Property: all results should have source_id == "local"
16365        for hit in &hits {
16366            assert_eq!(
16367                hit.source_id, "local",
16368                "Source filter violated: got source_id '{}' instead of 'local'",
16369                hit.source_id
16370            );
16371        }
16372        assert!(!hits.is_empty(), "Should have found local results");
16373
16374        // Filter for specific source ID
16375        let filters_id = SearchFilters {
16376            source_filter: SourceFilter::SourceId("  LOCAL  ".to_string()),
16377            ..Default::default()
16378        };
16379
16380        let hits_id = client.search("source", filters_id, 10, 0, FieldMask::FULL)?;
16381        for hit in &hits_id {
16382            assert_eq!(
16383                hit.source_id, "local",
16384                "SourceId filter violated: got '{}' instead of 'local'",
16385                hit.source_id
16386            );
16387        }
16388        assert!(
16389            !hits_id.is_empty(),
16390            "Should have found results for source_id=local"
16391        );
16392
16393        Ok(())
16394    }
16395
16396    #[test]
16397    fn filter_fidelity_cache_key_isolation() {
16398        // Different filters should have different cache keys
16399        let client = SearchClient {
16400            reader: None,
16401            sqlite: Mutex::new(None),
16402            sqlite_path: None,
16403            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
16404            reload_on_search: true,
16405            last_reload: Mutex::new(None),
16406            last_generation: Mutex::new(None),
16407            reload_epoch: Arc::new(AtomicU64::new(0)),
16408            warm_tx: None,
16409            _warm_handle: None,
16410            metrics: Metrics::default(),
16411            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
16412            semantic: Mutex::new(None),
16413            last_tantivy_total_count: Mutex::new(None),
16414        };
16415
16416        let filters_empty = SearchFilters::default();
16417        let mut filters_agent = SearchFilters::default();
16418        filters_agent.agents.insert("codex".into());
16419
16420        let mut filters_ws = SearchFilters::default();
16421        filters_ws.workspaces.insert("/ws".into());
16422
16423        let key_empty = client.cache_key("test", &filters_empty);
16424        let key_agent = client.cache_key("test", &filters_agent);
16425        let key_ws = client.cache_key("test", &filters_ws);
16426
16427        // All keys should be different
16428        assert_ne!(
16429            key_empty, key_agent,
16430            "Empty vs agent filter keys should differ"
16431        );
16432        assert_ne!(
16433            key_empty, key_ws,
16434            "Empty vs workspace filter keys should differ"
16435        );
16436        assert_ne!(
16437            key_agent, key_ws,
16438            "Agent vs workspace filter keys should differ"
16439        );
16440
16441        // Same filter should produce same key
16442        let mut filters_agent2 = SearchFilters::default();
16443        filters_agent2.agents.insert("codex".into());
16444        let key_agent2 = client.cache_key("test", &filters_agent2);
16445        assert_eq!(key_agent, key_agent2, "Same filter should produce same key");
16446    }
16447
16448    // ==========================================================================
16449    // FTS5 Query Generation Tests (tst.srch.fts)
16450    // Additional tests for SQL/FTS5 query generation edge cases
16451    // ==========================================================================
16452
16453    // --- Additional sanitize_query tests (edge cases) ---
16454
16455    #[test]
16456    fn sanitize_query_preserves_unicode_alphanumeric() {
16457        // Unicode letters and digits should be preserved
16458        assert_eq!(fs_cass_sanitize_query("こんにちは"), "こんにちは");
16459        assert_eq!(fs_cass_sanitize_query("café"), "café");
16460        assert_eq!(fs_cass_sanitize_query("日本語123"), "日本語123");
16461    }
16462
16463    #[test]
16464    fn sanitize_query_handles_multiple_consecutive_special_chars() {
16465        assert_eq!(fs_cass_sanitize_query("foo---bar"), "foo---bar");
16466        // a!@#$%^&()b has 9 special chars between a and b: ! @ # $ % ^ & ( )
16467        assert_eq!(fs_cass_sanitize_query("a!@#$%^&()b"), "a         b");
16468    }
16469
16470    // --- Additional WildcardPattern::parse tests (edge cases) ---
16471
16472    #[test]
16473    fn wildcard_pattern_empty_after_trim_returns_exact_empty() {
16474        assert_eq!(
16475            FsCassWildcardPattern::parse("*"),
16476            FsCassWildcardPattern::Exact(String::new())
16477        );
16478        assert_eq!(
16479            FsCassWildcardPattern::parse("**"),
16480            FsCassWildcardPattern::Exact(String::new())
16481        );
16482        assert_eq!(
16483            FsCassWildcardPattern::parse("***"),
16484            FsCassWildcardPattern::Exact(String::new())
16485        );
16486    }
16487
16488    #[test]
16489    fn wildcard_pattern_to_regex_generation() {
16490        // Exact and prefix patterns don't need regex
16491        assert_eq!(FsCassWildcardPattern::Exact("foo".into()).to_regex(), None);
16492        assert_eq!(FsCassWildcardPattern::Prefix("foo".into()).to_regex(), None);
16493        // Suffix and substring need regex
16494        // Suffix needs $ anchor for "ends with" semantics
16495        assert_eq!(
16496            FsCassWildcardPattern::Suffix("foo".into()).to_regex(),
16497            Some(".*foo$".into())
16498        );
16499        assert_eq!(
16500            FsCassWildcardPattern::Substring("foo".into()).to_regex(),
16501            Some(".*foo.*".into())
16502        );
16503    }
16504
16505    // --- Additional parse_boolean_query tests (edge cases) ---
16506
16507    #[test]
16508    fn parse_boolean_query_prefix_minus_not() {
16509        // Prefix minus at start of query should trigger NOT
16510        let tokens = fs_cass_parse_boolean_query("-world");
16511        let expected = vec![
16512            FsCassQueryToken::Not,
16513            FsCassQueryToken::Term("world".into()),
16514        ];
16515        assert_eq!(tokens, expected);
16516
16517        // Prefix minus after space should trigger NOT
16518        let tokens = fs_cass_parse_boolean_query("hello -world");
16519        let expected = vec![
16520            FsCassQueryToken::Term("hello".into()),
16521            FsCassQueryToken::Not,
16522            FsCassQueryToken::Term("world".into()),
16523        ];
16524        assert_eq!(tokens, expected);
16525    }
16526
16527    #[test]
16528    fn parse_boolean_query_empty_quoted_phrase_ignored() {
16529        let tokens = parse_boolean_query("\"\"");
16530        assert!(tokens.is_empty());
16531
16532        let tokens = parse_boolean_query("foo \"\" bar");
16533        let expected: QueryTokenList = vec![
16534            QueryToken::Term("foo".into()),
16535            QueryToken::Term("bar".into()),
16536        ];
16537        assert_eq!(tokens, expected);
16538    }
16539
16540    #[test]
16541    fn parse_boolean_query_unclosed_quote() {
16542        // Unclosed quote should collect until end
16543        let tokens = parse_boolean_query("\"hello world");
16544        let expected: QueryTokenList = vec![QueryToken::Phrase("hello world".into())];
16545        assert_eq!(tokens, expected);
16546    }
16547
16548    #[test]
16549    fn transpile_to_fts5_rejects_leading_unary_not_queries() {
16550        assert_eq!(transpile_to_fts5("NOT foo"), None);
16551        assert_eq!(transpile_to_fts5("-foo"), None);
16552    }
16553
16554    #[test]
16555    fn transpile_to_fts5_rejects_or_not_forms_it_cannot_represent() {
16556        assert_eq!(transpile_to_fts5("foo OR NOT bar"), None);
16557        assert_eq!(transpile_to_fts5("foo NOT bar OR baz"), None);
16558    }
16559
16560    #[test]
16561    fn transpile_to_fts5_ignores_leading_or() {
16562        assert_eq!(transpile_to_fts5("OR test"), Some("test".to_string()));
16563        assert_eq!(
16564            transpile_to_fts5("OR foo-bar"),
16565            Some("(foo AND bar)".to_string())
16566        );
16567    }
16568
16569    #[test]
16570    fn transpile_to_fts5_splits_hyphenated_subterms_for_sqlite_fts() {
16571        assert_eq!(
16572            transpile_to_fts5("br-123.jsonl"),
16573            Some("(br AND 123 AND jsonl)".to_string())
16574        );
16575        assert_eq!(
16576            transpile_to_fts5("br-123.json*"),
16577            Some("(br AND 123 AND json*)".to_string())
16578        );
16579    }
16580
16581    #[test]
16582    fn transpile_to_fts5_preserves_supported_binary_not() {
16583        assert_eq!(
16584            transpile_to_fts5("foo NOT bar").as_deref(),
16585            Some("foo NOT bar")
16586        );
16587        assert_eq!(
16588            transpile_to_fts5("foo NOT bar-baz"),
16589            Some("foo NOT (bar AND baz)".to_string())
16590        );
16591    }
16592
16593    #[test]
16594    fn search_sqlite_fts5_returns_empty_when_sqlite_is_unavailable() {
16595        let client = SearchClient {
16596            reader: None,
16597            sqlite: Mutex::new(None),
16598            sqlite_path: None,
16599            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
16600            reload_on_search: false,
16601            last_reload: Mutex::new(None),
16602            last_generation: Mutex::new(None),
16603            reload_epoch: Arc::new(AtomicU64::new(0)),
16604            warm_tx: None,
16605            _warm_handle: None,
16606            metrics: Metrics::default(),
16607            cache_namespace: "fts5-disabled".to_string(),
16608            semantic: Mutex::new(None),
16609            last_tantivy_total_count: Mutex::new(None),
16610        };
16611
16612        let hits = client.search_sqlite_fts5(
16613            Path::new("/nonexistent"),
16614            "test query",
16615            SearchFilters::default(),
16616            10,
16617            0,
16618            FieldMask::FULL,
16619        );
16620
16621        assert!(hits.is_ok(), "disabled FTS5 path should stay non-fatal");
16622        assert!(
16623            hits.unwrap().is_empty(),
16624            "unavailable SQLite fallback should keep returning an empty result set"
16625        );
16626    }
16627
16628    /// `coding_agent_session_search-k0e5p` (ibuuh.24.2 sub-bead):
16629    /// E2E equivalence gate for the rank+hydrate FTS5 fallback split
16630    /// landed in peer commit c91ea038. The peer's existing unit test
16631    /// pins the rank-SQL SHAPE (no content columns referenced) but
16632    /// nothing pins the user-facing RESULT-SET equivalence. A
16633    /// regression where the hydrate phase silently re-orders, drops,
16634    /// or re-filters hits would slip past the SQL-shape check and
16635    /// produce user-visible quality changes.
16636    ///
16637    /// This test pins the prefix invariant (same pattern as bead
16638    /// 1dd5u for the lexical search path): seed N ranked hits in the
16639    /// FTS5 fallback DB, run search_sqlite_fts5 at limit=K and
16640    /// limit=N, assert the smaller-limit result is a prefix of the
16641    /// larger-limit result. A regression in either rank or hydrate
16642    /// (re-order, drop, re-filter) trips immediately.
16643    ///
16644    /// Pins three invariants:
16645    /// 1. Smaller-limit hits are a strict prefix of larger-limit hits.
16646    /// 2. Limit=N returns exactly N matches when ≥N candidates exist.
16647    /// 3. Limit=0 returns empty (boundary case the rank+hydrate
16648    ///    split could break by hydrating before honoring the limit).
16649    #[test]
16650    fn search_sqlite_fts5_rank_and_hydrate_split_preserves_limit_prefix_invariant() -> Result<()> {
16651        let conn = Connection::open(":memory:")?;
16652        conn.execute_batch(
16653            "CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
16654             CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL UNIQUE);
16655             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE);
16656             CREATE TABLE conversations (
16657                id INTEGER PRIMARY KEY,
16658                agent_id INTEGER,
16659                workspace_id INTEGER,
16660                source_id TEXT,
16661                origin_host TEXT,
16662                title TEXT,
16663                source_path TEXT
16664             );
16665             CREATE TABLE messages (
16666                id INTEGER PRIMARY KEY,
16667                conversation_id INTEGER,
16668                idx INTEGER,
16669                content TEXT,
16670                created_at INTEGER
16671             );
16672             CREATE VIRTUAL TABLE fts_messages USING fts5(
16673                content,
16674                title,
16675                agent,
16676                workspace,
16677                source_path,
16678                created_at UNINDEXED,
16679                message_id UNINDEXED,
16680                tokenize='porter'
16681             );",
16682        )?;
16683        conn.execute("INSERT INTO sources(id, kind) VALUES('local', 'local')")?;
16684        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
16685        conn.execute("INSERT INTO workspaces(id, path) VALUES(1, '/tmp/k0e5p')")?;
16686
16687        // Seed N=6 messages all matching the same query token. Each
16688        // gets a distinct message_id + content shape so the prefix
16689        // assertion can pin specific ordering rather than just
16690        // counts. The bm25 score depends on per-row term frequency;
16691        // we vary `rankprobe` repetition (1×..6×) so the rank phase
16692        // produces a deterministic descending order.
16693        for (i, repeats) in (1..=6_i64).enumerate() {
16694            let conv_id = i as i64 + 1;
16695            let msg_id = (i as i64 + 1) * 10;
16696            conn.execute_compat(
16697                "INSERT INTO conversations(id, agent_id, workspace_id, source_id, \
16698                 origin_host, title, source_path) \
16699                 VALUES(?1, 1, 1, 'local', NULL, ?2, ?3)",
16700                params![
16701                    conv_id,
16702                    format!("k0e5p-{}", i),
16703                    format!("/tmp/k0e5p/{}.jsonl", i),
16704                ],
16705            )?;
16706            let content = "rankprobe ".repeat(repeats as usize);
16707            conn.execute_compat(
16708                "INSERT INTO messages(id, conversation_id, idx, content, created_at) \
16709                 VALUES(?1, ?2, ?3, ?4, ?5)",
16710                params![
16711                    msg_id,
16712                    conv_id,
16713                    i as i64,
16714                    content.as_str(),
16715                    1_700_000_000_i64 + i as i64
16716                ],
16717            )?;
16718            conn.execute_compat(
16719                "INSERT INTO fts_messages(rowid, content, title, agent, workspace, \
16720                 source_path, created_at, message_id) \
16721                 VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
16722                params![
16723                    msg_id,
16724                    content.as_str(),
16725                    format!("k0e5p-{}", i),
16726                    "codex",
16727                    "/tmp/k0e5p",
16728                    format!("/tmp/k0e5p/{}.jsonl", i),
16729                    1_700_000_000_i64 + i as i64,
16730                    msg_id,
16731                ],
16732            )?;
16733        }
16734
16735        let client = SearchClient {
16736            reader: None,
16737            sqlite: Mutex::new(Some(SendConnection(conn))),
16738            sqlite_path: None,
16739            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
16740            reload_on_search: false,
16741            last_reload: Mutex::new(None),
16742            last_generation: Mutex::new(None),
16743            reload_epoch: Arc::new(AtomicU64::new(0)),
16744            warm_tx: None,
16745            _warm_handle: None,
16746            metrics: Metrics::default(),
16747            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:k0e5p"),
16748            semantic: Mutex::new(None),
16749            last_tantivy_total_count: Mutex::new(None),
16750        };
16751
16752        // Hit-key tuple: (source_path, line_number) is the stable
16753        // operator-visible identity. Two limits that share a prefix
16754        // must produce hits with the same identities in the same
16755        // order across that prefix.
16756        fn hit_keys(hits: &[SearchHit]) -> Vec<(String, Option<usize>)> {
16757            hits.iter()
16758                .map(|h| (h.source_path.clone(), h.line_number))
16759                .collect()
16760        }
16761
16762        let large_hits = client.search_sqlite_fts5(
16763            Path::new(":memory:"),
16764            "rankprobe",
16765            SearchFilters::default(),
16766            6,
16767            0,
16768            FieldMask::FULL,
16769        )?;
16770        assert_eq!(
16771            large_hits.len(),
16772            6,
16773            "limit=N must return all N candidates when the corpus has exactly N matches"
16774        );
16775
16776        let small_hits = client.search_sqlite_fts5(
16777            Path::new(":memory:"),
16778            "rankprobe",
16779            SearchFilters::default(),
16780            3,
16781            0,
16782            FieldMask::FULL,
16783        )?;
16784        assert_eq!(small_hits.len(), 3, "limit=3 must return exactly 3 hits");
16785
16786        // Invariant 1: smaller-limit hits are a STRICT prefix of the
16787        // larger-limit hits — same identity, same order.
16788        let large_keys = hit_keys(&large_hits);
16789        let small_keys = hit_keys(&small_hits);
16790        assert_eq!(
16791            small_keys,
16792            large_keys[..3],
16793            "limit=3 hit keys MUST be the first 3 of limit=6 hit keys (rank+hydrate \
16794             split must not re-order or re-filter); small={small_keys:?} \
16795             large_prefix={:?}",
16796            &large_keys[..3]
16797        );
16798
16799        // Invariant 2: hit content is also identical across the
16800        // shared prefix — the hydrate phase preserves the content
16801        // string the rank phase ranked. A regression where hydrate
16802        // pulled from a different DB row than rank pointed at would
16803        // trip this even if the keys aligned.
16804        for (idx, (small, large)) in small_hits.iter().zip(large_hits.iter()).enumerate() {
16805            assert_eq!(
16806                small.content, large.content,
16807                "hit[{idx}] content must agree across limit=3 and limit=6: \
16808                 small={:?} large={:?}",
16809                small.content, large.content
16810            );
16811            assert_eq!(
16812                small.title, large.title,
16813                "hit[{idx}] title must agree across limit=3 and limit=6"
16814            );
16815        }
16816
16817        // Invariant 3: limit=0 boundary. The rank+hydrate split could
16818        // break this by hydrating before honoring the limit; pinning
16819        // it directly catches that regression class.
16820        let zero_hits = client.search_sqlite_fts5(
16821            Path::new(":memory:"),
16822            "rankprobe",
16823            SearchFilters::default(),
16824            0,
16825            0,
16826            FieldMask::FULL,
16827        )?;
16828        assert!(
16829            zero_hits.is_empty(),
16830            "limit=0 must return zero hits even though the rank phase has candidates; \
16831             got {} hits",
16832            zero_hits.len()
16833        );
16834
16835        Ok(())
16836    }
16837
16838    // --- levenshtein_distance tests ---
16839
16840    #[test]
16841    fn levenshtein_distance_identical_strings() {
16842        assert_eq!(levenshtein_distance("hello", "hello"), 0);
16843        assert_eq!(levenshtein_distance("", ""), 0);
16844    }
16845
16846    #[test]
16847    fn levenshtein_distance_insertions() {
16848        assert_eq!(levenshtein_distance("", "abc"), 3);
16849        assert_eq!(levenshtein_distance("cat", "cats"), 1);
16850    }
16851
16852    #[test]
16853    fn levenshtein_distance_deletions() {
16854        assert_eq!(levenshtein_distance("abc", ""), 3);
16855        assert_eq!(levenshtein_distance("cats", "cat"), 1);
16856    }
16857
16858    #[test]
16859    fn levenshtein_distance_substitutions() {
16860        assert_eq!(levenshtein_distance("cat", "bat"), 1);
16861        assert_eq!(levenshtein_distance("kitten", "sitten"), 1);
16862    }
16863
16864    #[test]
16865    fn levenshtein_distance_mixed_operations() {
16866        assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
16867        assert_eq!(levenshtein_distance("saturday", "sunday"), 3);
16868    }
16869
16870    // --- is_tool_invocation_noise tests ---
16871
16872    #[test]
16873    fn is_tool_invocation_noise_allows_real_content() {
16874        assert!(!is_tool_invocation_noise("This is a normal message"));
16875        assert!(!is_tool_invocation_noise(
16876            "Let me use the Tool feature to accomplish this task. Here is the implementation..."
16877        ));
16878        // Long content that happens to start with [Tool: should be allowed if it's substantial
16879        let long_content = "[Tool: Read] Now here is a lot of useful content that explains the implementation details and provides context for the changes being made to the codebase.";
16880        assert!(!is_tool_invocation_noise(long_content));
16881    }
16882
16883    #[test]
16884    fn is_tool_invocation_noise_handles_short_tool_markers() {
16885        assert!(is_tool_invocation_noise("[tool: x]"));
16886        assert!(is_tool_invocation_noise("tool: bash"));
16887    }
16888
16889    // --- Integration tests for boolean queries through search ---
16890
16891    #[test]
16892    fn search_boolean_and_filters_results() -> Result<()> {
16893        let dir = TempDir::new()?;
16894        let mut index = TantivyIndex::open_or_create(dir.path())?;
16895
16896        // Create documents with different word combinations
16897        let conv1 = NormalizedConversation {
16898            agent_slug: "codex".into(),
16899            external_id: None,
16900            title: Some("doc1".into()),
16901            workspace: None,
16902            source_path: dir.path().join("1.jsonl"),
16903            started_at: Some(1),
16904            ended_at: None,
16905            metadata: serde_json::json!({}),
16906            messages: vec![NormalizedMessage {
16907                idx: 0,
16908                role: "user".into(),
16909                author: None,
16910                created_at: Some(1),
16911                content: "alpha beta gamma".into(),
16912                extra: serde_json::json!({}),
16913                snippets: vec![],
16914                invocations: Vec::new(),
16915            }],
16916        };
16917        let conv2 = NormalizedConversation {
16918            agent_slug: "codex".into(),
16919            external_id: None,
16920            title: Some("doc2".into()),
16921            workspace: None,
16922            source_path: dir.path().join("2.jsonl"),
16923            started_at: Some(2),
16924            ended_at: None,
16925            metadata: serde_json::json!({}),
16926            messages: vec![NormalizedMessage {
16927                idx: 0,
16928                role: "user".into(),
16929                author: None,
16930                created_at: Some(2),
16931                content: "alpha delta".into(),
16932                extra: serde_json::json!({}),
16933                snippets: vec![],
16934                invocations: Vec::new(),
16935            }],
16936        };
16937        index.add_conversation(&conv1)?;
16938        index.add_conversation(&conv2)?;
16939        index.commit()?;
16940
16941        let client = SearchClient::open(dir.path(), None)?.expect("index present");
16942
16943        // "alpha AND beta" should only match doc1
16944        let hits = client.search(
16945            "alpha AND beta",
16946            SearchFilters::default(),
16947            10,
16948            0,
16949            FieldMask::FULL,
16950        )?;
16951        assert_eq!(hits.len(), 1);
16952        assert!(hits[0].content.contains("gamma"));
16953
16954        // "alpha AND delta" should only match doc2
16955        let hits = client.search(
16956            "alpha AND delta",
16957            SearchFilters::default(),
16958            10,
16959            0,
16960            FieldMask::FULL,
16961        )?;
16962        assert_eq!(hits.len(), 1);
16963        assert!(hits[0].content.contains("delta"));
16964
16965        Ok(())
16966    }
16967
16968    #[test]
16969    fn search_boolean_or_expands_results() -> Result<()> {
16970        let dir = TempDir::new()?;
16971        let mut index = TantivyIndex::open_or_create(dir.path())?;
16972
16973        let conv1 = NormalizedConversation {
16974            agent_slug: "codex".into(),
16975            external_id: None,
16976            title: Some("doc1".into()),
16977            workspace: None,
16978            source_path: dir.path().join("1.jsonl"),
16979            started_at: Some(1),
16980            ended_at: None,
16981            metadata: serde_json::json!({}),
16982            messages: vec![NormalizedMessage {
16983                idx: 0,
16984                role: "user".into(),
16985                author: None,
16986                created_at: Some(1),
16987                content: "unique xyzzy term".into(),
16988                extra: serde_json::json!({}),
16989                snippets: vec![],
16990                invocations: Vec::new(),
16991            }],
16992        };
16993        let conv2 = NormalizedConversation {
16994            agent_slug: "codex".into(),
16995            external_id: None,
16996            title: Some("doc2".into()),
16997            workspace: None,
16998            source_path: dir.path().join("2.jsonl"),
16999            started_at: Some(2),
17000            ended_at: None,
17001            metadata: serde_json::json!({}),
17002            messages: vec![NormalizedMessage {
17003                idx: 0,
17004                role: "user".into(),
17005                author: None,
17006                created_at: Some(2),
17007                content: "unique plugh term".into(),
17008                extra: serde_json::json!({}),
17009                snippets: vec![],
17010                invocations: Vec::new(),
17011            }],
17012        };
17013        index.add_conversation(&conv1)?;
17014        index.add_conversation(&conv2)?;
17015        index.commit()?;
17016
17017        let client = SearchClient::open(dir.path(), None)?.expect("index present");
17018
17019        // "xyzzy OR plugh" should match both docs
17020        let hits = client.search(
17021            "xyzzy OR plugh",
17022            SearchFilters::default(),
17023            10,
17024            0,
17025            FieldMask::FULL,
17026        )?;
17027        assert_eq!(hits.len(), 2);
17028
17029        Ok(())
17030    }
17031
17032    #[test]
17033    fn search_boolean_not_excludes_results() -> Result<()> {
17034        let dir = TempDir::new()?;
17035        let mut index = TantivyIndex::open_or_create(dir.path())?;
17036
17037        let conv1 = NormalizedConversation {
17038            agent_slug: "codex".into(),
17039            external_id: None,
17040            title: Some("doc1".into()),
17041            workspace: None,
17042            source_path: dir.path().join("1.jsonl"),
17043            started_at: Some(1),
17044            ended_at: None,
17045            metadata: serde_json::json!({}),
17046            messages: vec![NormalizedMessage {
17047                idx: 0,
17048                role: "user".into(),
17049                author: None,
17050                created_at: Some(1),
17051                content: "nottest keep this".into(),
17052                extra: serde_json::json!({}),
17053                snippets: vec![],
17054                invocations: Vec::new(),
17055            }],
17056        };
17057        let conv2 = NormalizedConversation {
17058            agent_slug: "codex".into(),
17059            external_id: None,
17060            title: Some("doc2".into()),
17061            workspace: None,
17062            source_path: dir.path().join("2.jsonl"),
17063            started_at: Some(2),
17064            ended_at: None,
17065            metadata: serde_json::json!({}),
17066            messages: vec![NormalizedMessage {
17067                idx: 0,
17068                role: "user".into(),
17069                author: None,
17070                created_at: Some(2),
17071                content: "nottest exclude this".into(),
17072                extra: serde_json::json!({}),
17073                snippets: vec![],
17074                invocations: Vec::new(),
17075            }],
17076        };
17077        index.add_conversation(&conv1)?;
17078        index.add_conversation(&conv2)?;
17079        index.commit()?;
17080
17081        let client = SearchClient::open(dir.path(), None)?.expect("index present");
17082
17083        // "nottest NOT exclude" should only match doc1 (has nottest but NOT exclude)
17084        let hits = client.search(
17085            "nottest NOT exclude",
17086            SearchFilters::default(),
17087            10,
17088            0,
17089            FieldMask::FULL,
17090        )?;
17091        assert_eq!(hits.len(), 1);
17092        // Verify we got the right doc by checking it doesn't contain "exclude"
17093        assert!(
17094            !hits[0].content.contains("exclude"),
17095            "NOT exclude should filter out doc with 'exclude'"
17096        );
17097
17098        // Prefix "-" exclusion should behave like NOT for simple queries.
17099        let hits = client.search(
17100            "nottest -exclude",
17101            SearchFilters::default(),
17102            10,
17103            0,
17104            FieldMask::FULL,
17105        )?;
17106        assert_eq!(hits.len(), 1);
17107        assert!(
17108            !hits[0].content.contains("exclude"),
17109            "Prefix -exclude should filter out doc with 'exclude'"
17110        );
17111
17112        Ok(())
17113    }
17114
17115    #[test]
17116    fn search_phrase_query_matches_exact_sequence() -> Result<()> {
17117        let dir = TempDir::new()?;
17118        let mut index = TantivyIndex::open_or_create(dir.path())?;
17119
17120        let conv1 = NormalizedConversation {
17121            agent_slug: "codex".into(),
17122            external_id: None,
17123            title: Some("doc1".into()),
17124            workspace: None,
17125            source_path: dir.path().join("1.jsonl"),
17126            started_at: Some(1),
17127            ended_at: None,
17128            metadata: serde_json::json!({}),
17129            messages: vec![NormalizedMessage {
17130                idx: 0,
17131                role: "user".into(),
17132                author: None,
17133                created_at: Some(1),
17134                content: "the quick brown fox".into(),
17135                extra: serde_json::json!({}),
17136                snippets: vec![],
17137                invocations: Vec::new(),
17138            }],
17139        };
17140        let conv2 = NormalizedConversation {
17141            agent_slug: "codex".into(),
17142            external_id: None,
17143            title: Some("doc2".into()),
17144            workspace: None,
17145            source_path: dir.path().join("2.jsonl"),
17146            started_at: Some(2),
17147            ended_at: None,
17148            metadata: serde_json::json!({}),
17149            messages: vec![NormalizedMessage {
17150                idx: 0,
17151                role: "user".into(),
17152                author: None,
17153                created_at: Some(2),
17154                content: "the brown quick fox".into(),
17155                extra: serde_json::json!({}),
17156                snippets: vec![],
17157                invocations: Vec::new(),
17158            }],
17159        };
17160        index.add_conversation(&conv1)?;
17161        index.add_conversation(&conv2)?;
17162        index.commit()?;
17163
17164        let client = SearchClient::open(dir.path(), None)?.expect("index present");
17165
17166        // "quick brown" (without quotes) should match both (words just need to be present)
17167        let hits = client.search(
17168            "quick brown",
17169            SearchFilters::default(),
17170            10,
17171            0,
17172            FieldMask::FULL,
17173        )?;
17174        assert_eq!(hits.len(), 2);
17175
17176        // "\"quick brown\"" should match exact order only
17177        let hits = client.search(
17178            "\"quick brown\"",
17179            SearchFilters::default(),
17180            10,
17181            0,
17182            FieldMask::FULL,
17183        )?;
17184        assert_eq!(hits.len(), 1);
17185        assert!(hits[0].content.contains("quick brown"));
17186
17187        Ok(())
17188    }
17189
17190    #[test]
17191    fn search_dot_punctuation_splits_terms_but_hyphens_preserve_compound_semantics() -> Result<()> {
17192        let dir = TempDir::new()?;
17193        let mut index = TantivyIndex::open_or_create(dir.path())?;
17194
17195        let conv = NormalizedConversation {
17196            agent_slug: "codex".into(),
17197            external_id: None,
17198            title: Some("doc".into()),
17199            workspace: None,
17200            source_path: dir.path().join("3.jsonl"),
17201            started_at: Some(1),
17202            ended_at: None,
17203            metadata: serde_json::json!({}),
17204            messages: vec![NormalizedMessage {
17205                idx: 0,
17206                role: "user".into(),
17207                author: None,
17208                created_at: Some(1),
17209                content: "foo bar baz".into(),
17210                extra: serde_json::json!({}),
17211                snippets: vec![],
17212                invocations: Vec::new(),
17213            }],
17214        };
17215        index.add_conversation(&conv)?;
17216        index.commit()?;
17217
17218        let client = SearchClient::open(dir.path(), None)?.expect("index present");
17219
17220        let hits = client.search("foo.bar", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
17221        assert_eq!(hits.len(), 1);
17222
17223        let hits = client.search("foo-bar", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
17224        assert_eq!(hits.len(), 0);
17225
17226        Ok(())
17227    }
17228
17229    // ========================================================================
17230    // QueryExplanation tests
17231    // ========================================================================
17232
17233    #[test]
17234    fn explanation_classifies_simple_query() {
17235        let exp = QueryExplanation::analyze("hello", &SearchFilters::default());
17236        assert_eq!(exp.query_type, QueryType::Simple);
17237        assert_eq!(exp.index_strategy, IndexStrategy::EdgeNgram);
17238        assert_eq!(exp.estimated_cost, QueryCost::Low);
17239        assert!(exp.parsed.terms.len() == 1);
17240        assert_eq!(exp.parsed.terms[0].text, "hello");
17241        assert!(!exp.parsed.terms[0].subterms.is_empty());
17242        assert_eq!(exp.parsed.terms[0].subterms[0].pattern, "exact");
17243    }
17244
17245    #[test]
17246    fn explanation_classifies_wildcard_query() {
17247        let exp = QueryExplanation::analyze("*handler*", &SearchFilters::default());
17248        assert_eq!(exp.query_type, QueryType::Wildcard);
17249        assert_eq!(exp.index_strategy, IndexStrategy::RegexScan);
17250        assert_eq!(exp.estimated_cost, QueryCost::High);
17251        assert!(!exp.parsed.terms[0].subterms.is_empty());
17252        assert!(
17253            exp.parsed.terms[0].subterms[0]
17254                .pattern
17255                .contains("substring")
17256        );
17257        assert!(exp.warnings.iter().any(|w| w.contains("regex scan")));
17258    }
17259
17260    #[test]
17261    fn explanation_classifies_boolean_query() {
17262        let exp = QueryExplanation::analyze("foo AND bar", &SearchFilters::default());
17263        assert_eq!(exp.query_type, QueryType::Boolean);
17264        assert_eq!(exp.index_strategy, IndexStrategy::BooleanCombination);
17265        assert!(exp.parsed.operators.contains(&"AND".to_string()));
17266    }
17267
17268    #[test]
17269    fn explanation_classifies_phrase_query() {
17270        let exp = QueryExplanation::analyze("\"exact phrase\"", &SearchFilters::default());
17271        assert_eq!(exp.query_type, QueryType::Phrase);
17272        assert!(exp.parsed.phrases.contains(&"exact phrase".to_string()));
17273    }
17274
17275    #[test]
17276    fn explanation_handles_filtered_query() {
17277        let mut filters = SearchFilters::default();
17278        filters.agents.insert("codex".to_string());
17279
17280        let exp = QueryExplanation::analyze("test", &filters);
17281        assert_eq!(exp.query_type, QueryType::Filtered);
17282        assert_eq!(exp.filters_summary.agent_count, 1);
17283        assert!(
17284            exp.filters_summary
17285                .description
17286                .as_ref()
17287                .unwrap()
17288                .contains("1 agent")
17289        );
17290        assert!(exp.warnings.iter().any(|w| w.contains("codex")));
17291    }
17292
17293    #[test]
17294    fn explanation_handles_empty_query() {
17295        let exp = QueryExplanation::analyze("", &SearchFilters::default());
17296        assert_eq!(exp.query_type, QueryType::Empty);
17297        assert_eq!(exp.index_strategy, IndexStrategy::FullScan);
17298        assert_eq!(exp.estimated_cost, QueryCost::High);
17299        assert!(exp.warnings.iter().any(|w| w.contains("Empty query")));
17300    }
17301
17302    #[test]
17303    fn explanation_warns_short_terms() {
17304        let exp = QueryExplanation::analyze("a", &SearchFilters::default());
17305        assert!(exp.warnings.iter().any(|w| w.contains("Very short term")));
17306    }
17307
17308    #[test]
17309    fn explanation_with_wildcard_fallback() {
17310        let exp = QueryExplanation::analyze("test", &SearchFilters::default())
17311            .with_wildcard_fallback(true);
17312        assert!(exp.wildcard_applied);
17313        // Message starts with capital W: "Wildcard fallback was applied..."
17314        assert!(exp.warnings.iter().any(|w| w.contains("Wildcard fallback")));
17315    }
17316
17317    #[test]
17318    fn explanation_complex_query_has_higher_cost() {
17319        let exp = QueryExplanation::analyze(
17320            "foo AND bar OR baz NOT qux AND \"phrase here\"",
17321            &SearchFilters::default(),
17322        );
17323        assert_eq!(exp.query_type, QueryType::Boolean);
17324        // Complex query should have Medium or High cost
17325        assert!(matches!(
17326            exp.estimated_cost,
17327            QueryCost::Medium | QueryCost::High
17328        ));
17329    }
17330
17331    #[test]
17332    fn explanation_preserves_original_query() {
17333        let exp = QueryExplanation::analyze("Hello World!", &SearchFilters::default());
17334        assert_eq!(exp.original_query, "Hello World!");
17335        // Sanitized replaces special chars with spaces but preserves case
17336        assert!(exp.sanitized_query.contains("Hello"));
17337        // ! is replaced with space
17338        assert!(!exp.sanitized_query.contains("!"));
17339    }
17340
17341    #[test]
17342    fn explanation_detects_not_operator() {
17343        let exp = QueryExplanation::analyze("foo NOT bar", &SearchFilters::default());
17344        assert!(exp.parsed.operators.contains(&"NOT".to_string()));
17345        // Second term should be marked as negated
17346        assert!(
17347            exp.parsed
17348                .terms
17349                .iter()
17350                .any(|t| t.negated && t.text == "bar")
17351        );
17352    }
17353
17354    #[test]
17355    fn explanation_implicit_and() {
17356        let exp = QueryExplanation::analyze("foo bar", &SearchFilters::default());
17357        assert!(exp.parsed.implicit_and);
17358        assert_eq!(exp.parsed.terms.len(), 2);
17359    }
17360
17361    #[test]
17362    fn explanation_serializes_to_json() {
17363        let exp = QueryExplanation::analyze("test query", &SearchFilters::default());
17364        let json = serde_json::to_value(&exp).expect("should serialize");
17365        assert!(json["original_query"].is_string());
17366        assert!(json["query_type"].is_string());
17367        assert!(json["index_strategy"].is_string());
17368        assert!(json["estimated_cost"].is_string());
17369        assert!(json["parsed"]["terms"].is_array());
17370    }
17371
17372    // =========================================================================
17373    // Multi-filter combination tests (bead yln.2)
17374    // =========================================================================
17375
17376    #[test]
17377    fn search_multi_filter_agent_workspace_time() -> Result<()> {
17378        // Test combining agent, workspace, and time range filters
17379        let dir = TempDir::new()?;
17380        let mut index = TantivyIndex::open_or_create(dir.path())?;
17381
17382        // Create 4 conversations with different combinations
17383        let convs = [
17384            ("codex", "/ws/alpha", 100, "needle alpha codex"),
17385            ("claude", "/ws/alpha", 200, "needle alpha claude"),
17386            ("codex", "/ws/beta", 150, "needle beta codex"),
17387            ("codex", "/ws/alpha", 300, "needle alpha codex late"),
17388        ];
17389
17390        for (i, (agent, ws, ts, content)) in convs.iter().enumerate() {
17391            let conv = NormalizedConversation {
17392                agent_slug: (*agent).into(),
17393                external_id: None,
17394                title: Some(format!("conv-{i}")),
17395                workspace: Some(std::path::PathBuf::from(*ws)),
17396                source_path: dir.path().join(format!("{i}.jsonl")),
17397                started_at: Some(*ts),
17398                ended_at: None,
17399                metadata: serde_json::json!({}),
17400                messages: vec![NormalizedMessage {
17401                    idx: 0,
17402                    role: "user".into(),
17403                    author: None,
17404                    created_at: Some(*ts),
17405                    content: (*content).into(),
17406                    extra: serde_json::json!({}),
17407                    snippets: vec![],
17408                    invocations: Vec::new(),
17409                }],
17410            };
17411            index.add_conversation(&conv)?;
17412        }
17413        index.commit()?;
17414
17415        let client = SearchClient::open(dir.path(), None)?.expect("index present");
17416
17417        // Filter: codex + alpha + time 50-250
17418        let mut filters = SearchFilters::default();
17419        filters.agents.insert("codex".into());
17420        filters.workspaces.insert("/ws/alpha".into());
17421        filters.created_from = Some(50);
17422        filters.created_to = Some(250);
17423
17424        let hits = client.search("needle", filters, 10, 0, FieldMask::FULL)?;
17425        assert_eq!(
17426            hits.len(),
17427            1,
17428            "Should match only one conv (codex + alpha + ts=100)"
17429        );
17430        assert_eq!(hits[0].agent, "codex");
17431        assert_eq!(hits[0].workspace, "/ws/alpha");
17432        assert!(hits[0].content.contains("alpha codex"));
17433        assert!(!hits[0].content.contains("late")); // Not the ts=300 one
17434
17435        Ok(())
17436    }
17437
17438    #[test]
17439    fn search_multi_agent_filter() -> Result<()> {
17440        // Test filtering by multiple agents
17441        let dir = TempDir::new()?;
17442        let mut index = TantivyIndex::open_or_create(dir.path())?;
17443
17444        for agent in ["codex", "claude", "cline", "gemini"] {
17445            let conv = NormalizedConversation {
17446                agent_slug: agent.into(),
17447                external_id: None,
17448                title: Some(format!("{agent}-conv")),
17449                workspace: Some(std::path::PathBuf::from("/ws")),
17450                source_path: dir.path().join(format!("{agent}.jsonl")),
17451                started_at: Some(100),
17452                ended_at: None,
17453                metadata: serde_json::json!({}),
17454                messages: vec![NormalizedMessage {
17455                    idx: 0,
17456                    role: "user".into(),
17457                    author: None,
17458                    created_at: Some(100),
17459                    content: format!("needle from {agent}"),
17460                    extra: serde_json::json!({}),
17461                    snippets: vec![],
17462                    invocations: Vec::new(),
17463                }],
17464            };
17465            index.add_conversation(&conv)?;
17466        }
17467        index.commit()?;
17468
17469        let client = SearchClient::open(dir.path(), None)?.expect("index present");
17470
17471        // Filter for codex and claude only
17472        let mut filters = SearchFilters::default();
17473        filters.agents.insert("codex".into());
17474        filters.agents.insert("claude".into());
17475
17476        let hits = client.search("needle", filters, 10, 0, FieldMask::FULL)?;
17477        assert_eq!(hits.len(), 2);
17478        let agents: Vec<_> = hits.iter().map(|h| h.agent.as_str()).collect();
17479        assert!(agents.contains(&"codex"));
17480        assert!(agents.contains(&"claude"));
17481        assert!(!agents.contains(&"cline"));
17482        assert!(!agents.contains(&"gemini"));
17483
17484        Ok(())
17485    }
17486
17487    // =========================================================================
17488    // Cache metrics tests (bead yln.2)
17489    // =========================================================================
17490
17491    #[test]
17492    fn cache_metrics_incremented_on_operations() {
17493        let client = SearchClient {
17494            reader: None,
17495            sqlite: Mutex::new(None),
17496            sqlite_path: None,
17497            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
17498            reload_on_search: true,
17499            last_reload: Mutex::new(None),
17500            last_generation: Mutex::new(None),
17501            reload_epoch: Arc::new(AtomicU64::new(0)),
17502            warm_tx: None,
17503            _warm_handle: None,
17504            metrics: Metrics::default(),
17505            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
17506            semantic: Mutex::new(None),
17507            last_tantivy_total_count: Mutex::new(None),
17508        };
17509
17510        // Initial metrics should be zero
17511        let (hits, miss, shortfall, reloads, _) = client.metrics.snapshot_all();
17512        assert_eq!((hits, miss, shortfall, reloads), (0, 0, 0, 0));
17513
17514        // Simulate operations
17515        client.metrics.inc_cache_hits();
17516        client.metrics.inc_cache_hits();
17517        client.metrics.inc_cache_miss();
17518        client.metrics.inc_cache_shortfall();
17519        client.metrics.inc_reload();
17520
17521        let (hits, miss, shortfall, reloads, _) = client.metrics.snapshot_all();
17522        assert_eq!(hits, 2);
17523        assert_eq!(miss, 1);
17524        assert_eq!(shortfall, 1);
17525        assert_eq!(reloads, 1);
17526    }
17527
17528    #[test]
17529    fn cache_shard_name_deterministic() {
17530        // Verify that shard name generation is deterministic for same filters
17531        let client = SearchClient {
17532            reader: None,
17533            sqlite: Mutex::new(None),
17534            sqlite_path: None,
17535            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
17536            reload_on_search: true,
17537            last_reload: Mutex::new(None),
17538            last_generation: Mutex::new(None),
17539            reload_epoch: Arc::new(AtomicU64::new(0)),
17540            warm_tx: None,
17541            _warm_handle: None,
17542            metrics: Metrics::default(),
17543            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
17544            semantic: Mutex::new(None),
17545            last_tantivy_total_count: Mutex::new(None),
17546        };
17547
17548        let filters1 = SearchFilters::default();
17549        let mut filters2 = SearchFilters::default();
17550        filters2.agents.insert("codex".into());
17551        let mut filters3 = SearchFilters::default();
17552        filters3.workspaces.insert("/tmp/cass-workspace".into());
17553
17554        // Same filters should always produce same shard name
17555        let shard1_first = client.shard_name(&filters1);
17556        let shard1_second = client.shard_name(&filters1);
17557        assert_eq!(
17558            shard1_first, shard1_second,
17559            "Same filters should produce same shard name"
17560        );
17561
17562        // Different filters produce different shard names
17563        let shard2 = client.shard_name(&filters2);
17564        assert_ne!(
17565            shard1_first, shard2,
17566            "Different filters should produce different shard names"
17567        );
17568
17569        // Shard name is deterministic
17570        assert_eq!(shard2, client.shard_name(&filters2));
17571        assert_eq!(
17572            client.shard_name(&filters3),
17573            "workspace:/tmp/cass-workspace"
17574        );
17575    }
17576
17577    // =========================================================================
17578    // Wildcard fallback edge cases (bead yln.2)
17579    // =========================================================================
17580
17581    #[test]
17582    fn wildcard_fallback_respects_filter_constraints() -> Result<()> {
17583        let dir = TempDir::new()?;
17584        let mut index = TantivyIndex::open_or_create(dir.path())?;
17585
17586        // Create conversations that would match wildcard but not filter
17587        let conv_match = NormalizedConversation {
17588            agent_slug: "codex".into(),
17589            external_id: None,
17590            title: Some("match".into()),
17591            workspace: Some(std::path::PathBuf::from("/target")),
17592            source_path: dir.path().join("match.jsonl"),
17593            started_at: Some(100),
17594            ended_at: None,
17595            metadata: serde_json::json!({}),
17596            messages: vec![NormalizedMessage {
17597                idx: 0,
17598                role: "user".into(),
17599                author: None,
17600                created_at: Some(100),
17601                content: "unique specific term here".into(),
17602                extra: serde_json::json!({}),
17603                snippets: vec![],
17604                invocations: Vec::new(),
17605            }],
17606        };
17607
17608        let conv_other = NormalizedConversation {
17609            agent_slug: "claude".into(),
17610            external_id: None,
17611            title: Some("other".into()),
17612            workspace: Some(std::path::PathBuf::from("/other")),
17613            source_path: dir.path().join("other.jsonl"),
17614            started_at: Some(100),
17615            ended_at: None,
17616            metadata: serde_json::json!({}),
17617            messages: vec![NormalizedMessage {
17618                idx: 0,
17619                role: "user".into(),
17620                author: None,
17621                created_at: Some(100),
17622                content: "unique specific also here".into(),
17623                extra: serde_json::json!({}),
17624                snippets: vec![],
17625                invocations: Vec::new(),
17626            }],
17627        };
17628
17629        index.add_conversation(&conv_match)?;
17630        index.add_conversation(&conv_other)?;
17631        index.commit()?;
17632
17633        let client = SearchClient::open(dir.path(), None)?.expect("index present");
17634
17635        // Search with filter that only matches conv_match
17636        let mut filters = SearchFilters::default();
17637        filters.agents.insert("codex".into());
17638
17639        let result =
17640            client.search_with_fallback("unique", filters.clone(), 10, 0, 100, FieldMask::FULL)?;
17641        // Should only return the codex conversation, not claude
17642        assert!(result.hits.iter().all(|h| h.agent == "codex"));
17643
17644        Ok(())
17645    }
17646
17647    #[test]
17648    fn wildcard_fallback_short_query_triggers_prefix() -> Result<()> {
17649        let dir = TempDir::new()?;
17650        let mut index = TantivyIndex::open_or_create(dir.path())?;
17651
17652        let conv = NormalizedConversation {
17653            agent_slug: "codex".into(),
17654            external_id: None,
17655            title: Some("test".into()),
17656            workspace: None,
17657            source_path: dir.path().join("test.jsonl"),
17658            started_at: Some(100),
17659            ended_at: None,
17660            metadata: serde_json::json!({}),
17661            messages: vec![NormalizedMessage {
17662                idx: 0,
17663                role: "user".into(),
17664                author: None,
17665                created_at: Some(100),
17666                content: "authentication authorization oauth".into(),
17667                extra: serde_json::json!({}),
17668                snippets: vec![],
17669                invocations: Vec::new(),
17670            }],
17671        };
17672        index.add_conversation(&conv)?;
17673        index.commit()?;
17674
17675        let client = SearchClient::open(dir.path(), None)?.expect("index present");
17676
17677        // Short prefix "auth" should match "authentication" and "authorization"
17678        let result = client.search_with_fallback(
17679            "auth",
17680            SearchFilters::default(),
17681            10,
17682            0,
17683            100,
17684            FieldMask::FULL,
17685        )?;
17686        assert!(
17687            !result.hits.is_empty(),
17688            "Short prefix should match via prefix search"
17689        );
17690        assert!(result.hits[0].content.contains("auth"));
17691
17692        Ok(())
17693    }
17694
17695    // =========================================================================
17696    // Real fixture tests with metrics (bead yln.2)
17697    // =========================================================================
17698
17699    #[test]
17700    fn search_real_fixture_multiple_messages() -> Result<()> {
17701        let dir = TempDir::new()?;
17702        let mut index = TantivyIndex::open_or_create(dir.path())?;
17703
17704        // Create a realistic conversation with multiple messages
17705        let conv = NormalizedConversation {
17706            agent_slug: "claude_code".into(),
17707            external_id: Some("conv-123".into()),
17708            title: Some("Implementing authentication".into()),
17709            workspace: Some(std::path::PathBuf::from("/home/user/project")),
17710            source_path: dir.path().join("session-1.jsonl"),
17711            started_at: Some(1700000000000),
17712            ended_at: Some(1700000060000),
17713            metadata: serde_json::json!({
17714                "model": "claude-3-sonnet",
17715                "tokens": 1500
17716            }),
17717            messages: vec![
17718                NormalizedMessage {
17719                    idx: 0,
17720                    role: "user".into(),
17721                    author: Some("developer".into()),
17722                    created_at: Some(1700000000000),
17723                    content: "Help me implement JWT authentication for my Express API".into(),
17724                    extra: serde_json::json!({}),
17725                    snippets: vec![],
17726                    invocations: Vec::new(),
17727                },
17728                NormalizedMessage {
17729                    idx: 1,
17730                    role: "assistant".into(),
17731                    author: Some("claude".into()),
17732                    created_at: Some(1700000010000),
17733                    content: "I'll help you implement JWT authentication. First, let's install the required packages.".into(),
17734                    extra: serde_json::json!({}),
17735                    snippets: vec![NormalizedSnippet {
17736                        file_path: Some("package.json".into()),
17737                        start_line: Some(1),
17738                        end_line: Some(5),
17739                        language: Some("json".into()),
17740                        snippet_text: Some(r#"{"dependencies":{"jsonwebtoken":"^9.0.0"}}"#.into()),
17741                    }],
17742                    invocations: Vec::new(),
17743                },
17744                NormalizedMessage {
17745                    idx: 2,
17746                    role: "user".into(),
17747                    author: Some("developer".into()),
17748                    created_at: Some(1700000030000),
17749                    content: "Can you also add refresh token support?".into(),
17750                    extra: serde_json::json!({}),
17751                    snippets: vec![],
17752                    invocations: Vec::new(),
17753                },
17754            ],
17755        };
17756        index.add_conversation(&conv)?;
17757        index.commit()?;
17758
17759        let client = SearchClient::open(dir.path(), None)?.expect("index present");
17760
17761        // Search for various terms that should match
17762        let hits = client.search(
17763            "JWT authentication",
17764            SearchFilters::default(),
17765            10,
17766            0,
17767            FieldMask::FULL,
17768        )?;
17769        assert!(!hits.is_empty(), "Should find JWT authentication");
17770        assert!(hits.iter().any(|h| h.agent == "claude_code"));
17771        assert!(
17772            hits.iter()
17773                .any(|h| h.snippet.contains("JWT") || h.snippet.contains("authentication"))
17774        );
17775
17776        // Search for assistant response content
17777        let hits = client.search(
17778            "required packages",
17779            SearchFilters::default(),
17780            10,
17781            0,
17782            FieldMask::FULL,
17783        )?;
17784        assert!(
17785            !hits.is_empty(),
17786            "Should find 'required packages' in assistant response"
17787        );
17788
17789        // Search for user question about refresh tokens
17790        let hits = client.search(
17791            "refresh token",
17792            SearchFilters::default(),
17793            10,
17794            0,
17795            FieldMask::FULL,
17796        )?;
17797        assert!(!hits.is_empty(), "Should find refresh token");
17798        assert!(hits.iter().any(|h| h.content.contains("refresh")));
17799
17800        Ok(())
17801    }
17802
17803    #[test]
17804    fn search_deduplication_with_similar_content() -> Result<()> {
17805        let dir = TempDir::new()?;
17806        let mut index = TantivyIndex::open_or_create(dir.path())?;
17807
17808        // Create two conversations with very similar content
17809        for i in 0..2 {
17810            let conv = NormalizedConversation {
17811                agent_slug: "codex".into(),
17812                external_id: None,
17813                title: Some(format!("similar-{i}")),
17814                workspace: Some(std::path::PathBuf::from("/ws")),
17815                source_path: dir.path().join(format!("similar-{i}.jsonl")),
17816                started_at: Some(100 + i),
17817                ended_at: None,
17818                metadata: serde_json::json!({}),
17819                messages: vec![NormalizedMessage {
17820                    idx: 0,
17821                    role: "user".into(),
17822                    author: None,
17823                    created_at: Some(100 + i),
17824                    // Exactly the same content
17825                    content: "implement the sorting algorithm".into(),
17826                    extra: serde_json::json!({}),
17827                    snippets: vec![],
17828                    invocations: Vec::new(),
17829                }],
17830            };
17831            index.add_conversation(&conv)?;
17832        }
17833        index.commit()?;
17834
17835        let client = SearchClient::open(dir.path(), None)?.expect("index present");
17836        let result = client.search_with_fallback(
17837            "sorting algorithm",
17838            SearchFilters::default(),
17839            10,
17840            0,
17841            100,
17842            FieldMask::FULL,
17843        )?;
17844
17845        // Both should be returned (different source_paths mean different conversations)
17846        // but if they have exact same content from same source, dedup should apply
17847        assert!(!result.hits.is_empty());
17848
17849        Ok(())
17850    }
17851
17852    // =========================================================================
17853    // Session paths filter tests (chained searches)
17854    // =========================================================================
17855
17856    #[test]
17857    fn search_session_paths_filter() -> Result<()> {
17858        // Test filtering by specific session source paths (for chained searches)
17859        let dir = TempDir::new()?;
17860        let mut index = TantivyIndex::open_or_create(dir.path())?;
17861
17862        // Create 3 conversations with different source paths
17863        let paths = [
17864            dir.path().join("session-a.jsonl"),
17865            dir.path().join("session-b.jsonl"),
17866            dir.path().join("session-c.jsonl"),
17867        ];
17868
17869        for (i, path) in paths.iter().enumerate() {
17870            let conv = NormalizedConversation {
17871                agent_slug: "claude".into(),
17872                external_id: None,
17873                title: Some(format!("session-{}", i)),
17874                workspace: Some(std::path::PathBuf::from("/ws")),
17875                source_path: path.clone(),
17876                started_at: Some(100 + i as i64),
17877                ended_at: None,
17878                metadata: serde_json::json!({}),
17879                messages: vec![NormalizedMessage {
17880                    idx: 0,
17881                    role: "user".into(),
17882                    author: None,
17883                    created_at: Some(100 + i as i64),
17884                    content: format!("needle content for session {}", i),
17885                    extra: serde_json::json!({}),
17886                    snippets: vec![],
17887                    invocations: Vec::new(),
17888                }],
17889            };
17890            index.add_conversation(&conv)?;
17891        }
17892        index.commit()?;
17893
17894        let client = SearchClient::open(dir.path(), None)?.expect("index present");
17895
17896        // First, search without filter - should get all 3
17897        let hits_all = client.search("needle", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
17898        assert_eq!(hits_all.len(), 3, "Should find all 3 sessions");
17899
17900        // Now filter to only sessions A and C
17901        let mut filters = SearchFilters::default();
17902        filters
17903            .session_paths
17904            .insert(paths[0].to_string_lossy().to_string());
17905        filters
17906            .session_paths
17907            .insert(paths[2].to_string_lossy().to_string());
17908
17909        let hits_filtered = client.search("needle", filters, 10, 0, FieldMask::FULL)?;
17910        assert_eq!(
17911            hits_filtered.len(),
17912            2,
17913            "Should find only 2 sessions (A and C)"
17914        );
17915
17916        // Verify the correct sessions are returned
17917        let filtered_paths: HashSet<&str> = hits_filtered
17918            .iter()
17919            .map(|h| h.source_path.as_str())
17920            .collect();
17921        assert!(filtered_paths.contains(paths[0].to_string_lossy().as_ref()));
17922        assert!(filtered_paths.contains(paths[2].to_string_lossy().as_ref()));
17923        assert!(!filtered_paths.contains(paths[1].to_string_lossy().as_ref()));
17924
17925        Ok(())
17926    }
17927
17928    #[test]
17929    fn lexical_session_paths_filter_retries_past_initial_page() -> Result<()> {
17930        let dir = TempDir::new()?;
17931        let mut index = TantivyIndex::open_or_create(dir.path())?;
17932        let requested_path = dir.path().join("requested-session.jsonl");
17933
17934        for i in 0..4 {
17935            let conv = NormalizedConversation {
17936                agent_slug: "claude".into(),
17937                external_id: None,
17938                title: Some(format!("distractor-{i}")),
17939                workspace: Some(std::path::PathBuf::from("/ws")),
17940                source_path: dir.path().join(format!("distractor-{i}.jsonl")),
17941                started_at: Some(100 + i as i64),
17942                ended_at: None,
17943                metadata: serde_json::json!({}),
17944                messages: vec![NormalizedMessage {
17945                    idx: 0,
17946                    role: "user".into(),
17947                    author: None,
17948                    created_at: Some(100 + i as i64),
17949                    content: "needle needle needle high ranking distractor".into(),
17950                    extra: serde_json::json!({}),
17951                    snippets: vec![],
17952                    invocations: Vec::new(),
17953                }],
17954            };
17955            index.add_conversation(&conv)?;
17956        }
17957
17958        let requested = NormalizedConversation {
17959            agent_slug: "claude".into(),
17960            external_id: None,
17961            title: Some("requested".into()),
17962            workspace: Some(std::path::PathBuf::from("/ws")),
17963            source_path: requested_path.clone(),
17964            started_at: Some(200),
17965            ended_at: None,
17966            metadata: serde_json::json!({}),
17967            messages: vec![NormalizedMessage {
17968                idx: 0,
17969                role: "user".into(),
17970                author: None,
17971                created_at: Some(200),
17972                content: "needle requested session should survive post-filter paging".into(),
17973                extra: serde_json::json!({}),
17974                snippets: vec![],
17975                invocations: Vec::new(),
17976            }],
17977        };
17978        index.add_conversation(&requested)?;
17979        index.commit()?;
17980
17981        let client = SearchClient::open(dir.path(), None)?.expect("index present");
17982        let mut filters = SearchFilters::default();
17983        filters
17984            .session_paths
17985            .insert(requested_path.to_string_lossy().to_string());
17986
17987        let hits = client.search("needle", filters, 1, 0, FieldMask::FULL)?;
17988
17989        assert_eq!(hits.len(), 1);
17990        assert_eq!(hits[0].source_path, requested_path.to_string_lossy());
17991
17992        Ok(())
17993    }
17994
17995    #[test]
17996    fn search_session_paths_empty_filter_returns_all() -> Result<()> {
17997        // Empty session_paths filter should not restrict results
17998        let dir = TempDir::new()?;
17999        let mut index = TantivyIndex::open_or_create(dir.path())?;
18000
18001        let conv = NormalizedConversation {
18002            agent_slug: "claude".into(),
18003            external_id: None,
18004            title: Some("test".into()),
18005            workspace: Some(std::path::PathBuf::from("/ws")),
18006            source_path: dir.path().join("test.jsonl"),
18007            started_at: Some(100),
18008            ended_at: None,
18009            metadata: serde_json::json!({}),
18010            messages: vec![NormalizedMessage {
18011                idx: 0,
18012                role: "user".into(),
18013                author: None,
18014                created_at: Some(100),
18015                content: "needle content".into(),
18016                extra: serde_json::json!({}),
18017                snippets: vec![],
18018                invocations: Vec::new(),
18019            }],
18020        };
18021        index.add_conversation(&conv)?;
18022        index.commit()?;
18023
18024        let client = SearchClient::open(dir.path(), None)?.expect("index present");
18025
18026        // Empty session_paths should not filter
18027        let filters = SearchFilters::default();
18028        assert!(filters.session_paths.is_empty());
18029
18030        let hits = client.search("needle", filters, 10, 0, FieldMask::FULL)?;
18031        assert_eq!(hits.len(), 1);
18032
18033        Ok(())
18034    }
18035
18036    #[test]
18037    fn search_client_reads_federated_lexical_bundle_as_one_corpus() -> Result<()> {
18038        let root = TempDir::new()?;
18039        let shard_a = root.path().join("shard-a");
18040        let shard_b = root.path().join("shard-b");
18041        let published = root.path().join("published");
18042
18043        let mut shard_a_index = TantivyIndex::open_or_create(&shard_a)?;
18044        let mut shard_b_index = TantivyIndex::open_or_create(&shard_b)?;
18045
18046        let make_conv =
18047            |external_id: &str, title: &str, source_path: &str, tag: &str| NormalizedConversation {
18048                agent_slug: "codex".into(),
18049                external_id: Some(external_id.into()),
18050                title: Some(title.into()),
18051                workspace: Some(std::path::PathBuf::from("/ws")),
18052                source_path: std::path::PathBuf::from(source_path),
18053                started_at: Some(1_700_000_100_000),
18054                ended_at: Some(1_700_000_100_100),
18055                metadata: json!({}),
18056                messages: vec![
18057                    NormalizedMessage {
18058                        idx: 0,
18059                        role: "user".into(),
18060                        author: None,
18061                        created_at: Some(1_700_000_100_010),
18062                        content: format!("shared federated needle {tag} user"),
18063                        extra: json!({}),
18064                        snippets: vec![],
18065                        invocations: Vec::new(),
18066                    },
18067                    NormalizedMessage {
18068                        idx: 1,
18069                        role: "assistant".into(),
18070                        author: None,
18071                        created_at: Some(1_700_000_100_020),
18072                        content: format!("shared federated needle {tag} assistant"),
18073                        extra: json!({}),
18074                        snippets: vec![],
18075                        invocations: Vec::new(),
18076                    },
18077                ],
18078            };
18079
18080        let conv_a = make_conv(
18081            "fed-query-a",
18082            "Fed Query A",
18083            "/tmp/fed-query-a.jsonl",
18084            "alpha",
18085        );
18086        let conv_b = make_conv(
18087            "fed-query-b",
18088            "Fed Query B",
18089            "/tmp/fed-query-b.jsonl",
18090            "beta",
18091        );
18092
18093        shard_a_index.add_conversation(&conv_a)?;
18094        shard_b_index.add_conversation(&conv_b)?;
18095        shard_a_index.commit()?;
18096        shard_b_index.commit()?;
18097        drop(shard_a_index);
18098        drop(shard_b_index);
18099
18100        crate::search::tantivy::publish_federated_searchable_index_directories(
18101            &published,
18102            &[&shard_a, &shard_b],
18103        )?;
18104
18105        let client = SearchClient::open(&published, None)?.expect("federated index present");
18106        assert!(client.has_tantivy());
18107        assert_eq!(client.total_docs(), 4);
18108
18109        let hits = client.search(
18110            "shared federated needle",
18111            SearchFilters::default(),
18112            10,
18113            0,
18114            FieldMask::FULL,
18115        )?;
18116        assert_eq!(hits.len(), 4);
18117        let observed_order = hits
18118            .iter()
18119            .map(|hit| {
18120                (
18121                    hit.source_path.clone(),
18122                    hit.line_number,
18123                    hit.content.clone(),
18124                    hit.score.to_bits(),
18125                )
18126            })
18127            .collect::<Vec<_>>();
18128        let hit_paths = hits
18129            .iter()
18130            .map(|hit| hit.source_path.as_str())
18131            .collect::<std::collections::HashSet<_>>();
18132        assert!(hit_paths.contains("/tmp/fed-query-a.jsonl"));
18133        assert!(hit_paths.contains("/tmp/fed-query-b.jsonl"));
18134
18135        for attempt in 0..3 {
18136            let repeated = client.search(
18137                "shared federated needle",
18138                SearchFilters::default(),
18139                10,
18140                0,
18141                FieldMask::FULL,
18142            )?;
18143            let repeated_order = repeated
18144                .iter()
18145                .map(|hit| {
18146                    (
18147                        hit.source_path.clone(),
18148                        hit.line_number,
18149                        hit.content.clone(),
18150                        hit.score.to_bits(),
18151                    )
18152                })
18153                .collect::<Vec<_>>();
18154            assert_eq!(
18155                repeated_order, observed_order,
18156                "federated lexical query order drifted on repeated attempt {attempt}"
18157            );
18158        }
18159
18160        Ok(())
18161    }
18162
18163    #[test]
18164    fn semantic_search_session_paths_filter_retries_past_initial_candidates() -> Result<()> {
18165        let fixture = build_semantic_test_fixture()?;
18166        let mut filters = SearchFilters::default();
18167        filters
18168            .session_paths
18169            .insert(fixture.source_paths[2].clone());
18170
18171        let (hits, ann_stats) = fixture.client.search_semantic(
18172            "semantic fixture query",
18173            filters,
18174            1,
18175            0,
18176            FieldMask::FULL,
18177            false,
18178        )?;
18179
18180        assert!(
18181            ann_stats.is_none(),
18182            "exact search should not emit ANN stats"
18183        );
18184        assert_eq!(
18185            hits.len(),
18186            1,
18187            "filtered semantic search should still return a hit"
18188        );
18189        assert_eq!(
18190            hits[0].source_path, fixture.source_paths[2],
18191            "semantic search should keep searching until it finds the requested session path"
18192        );
18193
18194        Ok(())
18195    }
18196
18197    #[test]
18198    fn semantic_search_offsets_after_session_paths_filtering() -> Result<()> {
18199        let fixture = build_semantic_test_fixture()?;
18200        let mut filters = SearchFilters::default();
18201        filters
18202            .session_paths
18203            .insert(fixture.source_paths[1].clone());
18204        filters
18205            .session_paths
18206            .insert(fixture.source_paths[2].clone());
18207
18208        let (hits, _) = fixture.client.search_semantic(
18209            "semantic fixture query",
18210            filters,
18211            1,
18212            1,
18213            FieldMask::FULL,
18214            false,
18215        )?;
18216
18217        assert_eq!(
18218            hits.len(),
18219            1,
18220            "second filtered page should still return one hit"
18221        );
18222        assert_eq!(
18223            hits[0].source_path, fixture.source_paths[2],
18224            "offset must apply after semantic deduplication and session path filtering"
18225        );
18226
18227        Ok(())
18228    }
18229
18230    #[test]
18231    fn semantic_search_merges_sharded_vector_indexes() -> Result<()> {
18232        let fixture = build_sharded_semantic_test_fixture()?;
18233        let (hits, ann_stats) = fixture.client.search_semantic(
18234            "semantic fixture query",
18235            SearchFilters::default(),
18236            3,
18237            0,
18238            FieldMask::FULL,
18239            false,
18240        )?;
18241
18242        assert!(
18243            ann_stats.is_none(),
18244            "sharded exact search should not emit ANN stats"
18245        );
18246        assert_eq!(hits.len(), 3);
18247        assert_eq!(hits[0].source_path, fixture.source_paths[0]);
18248        assert_eq!(hits[1].source_path, fixture.source_paths[1]);
18249        assert_eq!(hits[2].source_path, fixture.source_paths[2]);
18250
18251        Ok(())
18252    }
18253
18254    #[test]
18255    fn progressive_phase_overfetches_before_session_paths_filtering() -> Result<()> {
18256        let fixture = build_semantic_test_fixture()?;
18257        let mut filters = SearchFilters::default();
18258        filters
18259            .session_paths
18260            .insert(fixture.source_paths[2].clone());
18261
18262        let results = vec![
18263            FsScoredResult {
18264                doc_id: fixture.doc_ids[0].clone(),
18265                score: 1.0,
18266                source: FsScoreSource::SemanticFast,
18267                index: None,
18268                fast_score: Some(1.0),
18269                quality_score: None,
18270                lexical_score: None,
18271                rerank_score: None,
18272                explanation: None,
18273                metadata: None,
18274            },
18275            FsScoredResult {
18276                doc_id: fixture.doc_ids[1].clone(),
18277                score: 0.9,
18278                source: FsScoreSource::SemanticFast,
18279                index: None,
18280                fast_score: Some(0.9),
18281                quality_score: None,
18282                lexical_score: None,
18283                rerank_score: None,
18284                explanation: None,
18285                metadata: None,
18286            },
18287            FsScoredResult {
18288                doc_id: fixture.doc_ids[2].clone(),
18289                score: 0.8,
18290                source: FsScoreSource::SemanticFast,
18291                index: None,
18292                fast_score: Some(0.8),
18293                quality_score: None,
18294                lexical_score: None,
18295                rerank_score: None,
18296                explanation: None,
18297                metadata: None,
18298            },
18299        ];
18300
18301        let result = fixture.client.progressive_phase_to_result(
18302            &results,
18303            ProgressivePhaseContext {
18304                query: "session path filter",
18305                filters: &filters,
18306                field_mask: FieldMask::FULL,
18307                lexical_cache: None,
18308                limit: 1,
18309                fetch_limit: 3,
18310            },
18311        )?;
18312
18313        assert_eq!(
18314            result.hits.len(),
18315            1,
18316            "progressive phase should retain enough overfetched hits to satisfy post-search session path filtering"
18317        );
18318        assert_eq!(
18319            result.hits[0].source_path, fixture.source_paths[2],
18320            "progressive phase should page after session path filtering"
18321        );
18322
18323        Ok(())
18324    }
18325
18326    // =============================================================================
18327    // SQL Placeholder Builder Tests (Opt 4.5: Pre-sized String Buffers)
18328    // =============================================================================
18329
18330    #[test]
18331    fn sql_placeholders_empty() {
18332        assert_eq!(sql_placeholders(0), "");
18333    }
18334
18335    #[test]
18336    fn sql_placeholders_single() {
18337        assert_eq!(sql_placeholders(1), "?");
18338    }
18339
18340    #[test]
18341    fn sql_placeholders_multiple() {
18342        assert_eq!(sql_placeholders(3), "?,?,?");
18343        assert_eq!(sql_placeholders(5), "?,?,?,?,?");
18344    }
18345
18346    #[test]
18347    fn sql_placeholders_capacity_efficient() {
18348        // For count=3, capacity should be exactly 2*3-1=5 ("?,?,?" = 5 chars)
18349        let result = sql_placeholders(3);
18350        assert_eq!(result.len(), 5);
18351        assert!(result.capacity() >= 5); // Should have allocated at least 5
18352
18353        // For count=10, capacity should be exactly 2*10-1=19
18354        let result = sql_placeholders(10);
18355        assert_eq!(result.len(), 19);
18356        assert!(result.capacity() >= 19);
18357    }
18358
18359    #[test]
18360    fn sql_placeholders_large_count() {
18361        // Test with a large count to ensure no off-by-one errors
18362        let result = sql_placeholders(100);
18363        assert_eq!(result.len(), 199); // 100 "?" + 99 ","
18364        assert_eq!(result.chars().filter(|c| *c == '?').count(), 100);
18365        assert_eq!(result.chars().filter(|c| *c == ',').count(), 99);
18366    }
18367
18368    #[test]
18369    fn hybrid_budget_identifier_biases_lexical() {
18370        let budget = hybrid_candidate_budget("src/main.rs", 20, 20, 5, 10_000);
18371        assert!(
18372            budget.lexical_candidates > budget.semantic_candidates,
18373            "identifier queries should allocate more lexical than semantic fanout"
18374        );
18375        assert!(budget.lexical_candidates >= 25);
18376    }
18377
18378    #[test]
18379    fn hybrid_budget_natural_language_biases_semantic() {
18380        let budget = hybrid_candidate_budget(
18381            "how do we fix authentication middleware latency",
18382            20,
18383            20,
18384            5,
18385            10_000,
18386        );
18387        assert!(
18388            budget.semantic_candidates > budget.lexical_candidates,
18389            "natural language queries should allocate more semantic than lexical fanout"
18390        );
18391    }
18392
18393    #[test]
18394    fn hybrid_budget_no_limit_caps_both_lexical_and_semantic() {
18395        // Regression: a "no limit" hybrid search on a large corpus used to
18396        // set `lexical_candidates = total_docs`, which let a single
18397        // `cass search` request grow to tens of GB of RAM on a ~500k-row
18398        // user history and saturate disk IO. Both lexical and semantic
18399        // fanout are now bounded, lexical against the RAM-proportional
18400        // `no_limit_result_cap()` ceiling and semantic against the narrower
18401        // `HYBRID_NO_LIMIT_SEMANTIC_CAP` ceiling.
18402        let total_docs = 2_000_000;
18403        let budget =
18404            hybrid_candidate_budget("authentication middleware", 0, total_docs, 0, total_docs);
18405        let cap = no_limit_result_cap();
18406        assert!(
18407            budget.lexical_candidates <= cap,
18408            "lexical fanout must respect no_limit_result_cap() = {cap}; got {}",
18409            budget.lexical_candidates
18410        );
18411        assert!(
18412            budget.lexical_candidates <= NO_LIMIT_RESULT_MAX,
18413            "lexical fanout must respect the absolute NO_LIMIT_RESULT_MAX; got {}",
18414            budget.lexical_candidates
18415        );
18416        assert!(budget.semantic_candidates <= HYBRID_NO_LIMIT_SEMANTIC_CAP);
18417        // Invariant preserved by the `.min(lexical)` clamp inside
18418        // hybrid_candidate_budget: semantic fanout never exceeds
18419        // lexical fanout. On typical hosts lexical >> semantic, but
18420        // the cheaper `<=` assertion also holds on edge-case tiny
18421        // boxes where the overall cap pulls lexical down to the
18422        // planning window.
18423        assert!(
18424            budget.semantic_candidates <= budget.lexical_candidates,
18425            "semantic ({}) must not exceed lexical ({}) fanout",
18426            budget.semantic_candidates,
18427            budget.lexical_candidates
18428        );
18429    }
18430
18431    #[test]
18432    fn compute_no_limit_result_cap_clamps_explicit_over_ceiling_env_override() {
18433        // A naively large explicit override must still be clamped. The
18434        // old implementation returned the env value unclamped, which
18435        // reintroduced the unbounded-result failure mode. Driven via
18436        // the pure `*_from` helper so we can't race with other
18437        // concurrent tests that read the real env.
18438        let cap = compute_no_limit_result_cap_from(Some("999999999999".to_string()), None, None);
18439        assert!(
18440            cap <= NO_LIMIT_RESULT_MAX,
18441            "explicit override must still clamp to ceiling; got {cap} > {NO_LIMIT_RESULT_MAX}"
18442        );
18443        assert!(cap >= NO_LIMIT_RESULT_MIN);
18444    }
18445
18446    #[test]
18447    fn compute_no_limit_result_cap_clamps_tiny_explicit_override_up_to_floor() {
18448        // Mirror case: an explicit override under the floor is lifted.
18449        let cap = compute_no_limit_result_cap_from(Some("1".to_string()), None, None);
18450        assert_eq!(cap, NO_LIMIT_RESULT_MIN);
18451    }
18452
18453    #[test]
18454    fn compute_no_limit_result_cap_uses_meminfo_when_no_env_override() {
18455        // 128 GiB available → 128 / 16 = 8 GiB budget (under the 16 GiB
18456        // ceiling, above the 256 MiB floor) → 8 GiB / 80 KiB ≈ 104k
18457        // hits. That lands inside [MIN, MAX] and above floor.
18458        let cap = compute_no_limit_result_cap_from(None, None, Some(128u64 * 1024 * 1024 * 1024));
18459        assert!(cap >= NO_LIMIT_RESULT_MIN, "cap {cap} below floor");
18460        assert!(cap <= NO_LIMIT_RESULT_MAX, "cap {cap} above ceiling");
18461        // Sanity: 128 GiB / 16 / 80 KiB is nowhere near 1k.
18462        assert!(cap > NO_LIMIT_RESULT_MIN * 10);
18463    }
18464
18465    #[test]
18466    fn compute_no_limit_result_cap_falls_back_to_floor_when_meminfo_unavailable() {
18467        // Simulates non-Linux (no /proc/meminfo): must still produce a
18468        // finite, in-envelope cap. The floor budget (256 MiB) / 80 KiB
18469        // ≈ 3276 hits — above MIN, below MAX.
18470        let cap = compute_no_limit_result_cap_from(None, None, None);
18471        assert!(cap >= NO_LIMIT_RESULT_MIN);
18472        assert!(cap <= NO_LIMIT_RESULT_MAX);
18473    }
18474
18475    #[test]
18476    fn compute_no_limit_result_cap_bytes_env_takes_priority_over_meminfo() {
18477        // Explicit bytes override wins over MemAvailable. 4 GiB bytes
18478        // / 80 KiB ≈ 52k hits, distinct from what a large MemAvailable
18479        // hint would otherwise produce (which would hit the 16 GiB
18480        // ceiling → ~209k hits).
18481        let four_gib = (4u64 * 1024 * 1024 * 1024).to_string();
18482        let cap = compute_no_limit_result_cap_from(
18483            None,
18484            Some(four_gib),
18485            Some(1024u64 * 1024 * 1024 * 1024), // 1 TiB (would ceiling otherwise)
18486        );
18487        let expected_hits = ((4u64 * 1024 * 1024 * 1024) / AVG_HIT_BYTES) as usize;
18488        let expected = expected_hits.clamp(NO_LIMIT_RESULT_MIN, NO_LIMIT_RESULT_MAX);
18489        assert_eq!(cap, expected, "bytes env must win over meminfo");
18490    }
18491
18492    #[test]
18493    fn no_limit_budget_bytes_preserves_fallback_priority() {
18494        let huge_meminfo = Some(1024u64 * 1024 * 1024 * 1024);
18495        let four_gib = 4u64 * 1024 * 1024 * 1024;
18496
18497        assert_eq!(
18498            no_limit_budget_bytes(Some(four_gib.to_string()), huge_meminfo),
18499            four_gib
18500        );
18501        assert_eq!(
18502            no_limit_budget_bytes(Some("0".to_string()), huge_meminfo),
18503            NO_LIMIT_BYTES_CEILING
18504        );
18505        assert_eq!(no_limit_budget_bytes(None, None), NO_LIMIT_BYTES_FLOOR);
18506    }
18507
18508    #[test]
18509    fn compute_no_limit_result_cap_ignores_malformed_env() {
18510        // Garbage or zero values fall back to meminfo / floor, not crash.
18511        for bad in ["", "abc", "0", "-1"] {
18512            let cap = compute_no_limit_result_cap_from(
18513                Some(bad.to_string()),
18514                Some(bad.to_string()),
18515                None,
18516            );
18517            assert!(cap >= NO_LIMIT_RESULT_MIN, "bad={bad:?} cap={cap}");
18518            assert!(cap <= NO_LIMIT_RESULT_MAX, "bad={bad:?} cap={cap}");
18519        }
18520    }
18521
18522    // =============================================================================
18523    // RRF (Reciprocal Rank Fusion) Tests
18524    // =============================================================================
18525
18526    fn make_test_hit(id: &str, score: f32) -> SearchHit {
18527        SearchHit {
18528            title: id.to_string(),
18529            snippet: String::new(),
18530            content: id.to_string(),
18531            content_hash: stable_content_hash(id),
18532            score,
18533            source_path: format!("/path/{}.jsonl", id),
18534            agent: "test".to_string(),
18535            workspace: "/workspace".to_string(),
18536            workspace_original: None,
18537            created_at: Some(1_700_000_000_000),
18538            line_number: Some(1),
18539            match_type: MatchType::Exact,
18540            source_id: "local".to_string(),
18541            origin_kind: "local".to_string(),
18542            origin_host: None,
18543            conversation_id: None,
18544        }
18545    }
18546
18547    #[test]
18548    fn test_rrf_fusion_ordering() {
18549        // Test that RRF correctly combines rankings from both lists
18550        // Higher ranks in both lists should result in higher final ranking
18551        let lexical = vec![
18552            make_test_hit("A", 10.0),
18553            make_test_hit("B", 8.0),
18554            make_test_hit("C", 6.0),
18555        ];
18556        let semantic = vec![
18557            make_test_hit("A", 0.9),
18558            make_test_hit("B", 0.7),
18559            make_test_hit("D", 0.5),
18560        ];
18561
18562        let fused = rrf_fuse_hits(&lexical, &semantic, "", 10, 0);
18563
18564        // A and B should be top (in both lists), A first (rank 0 in both)
18565        assert_eq!(fused.len(), 4);
18566        assert_eq!(fused[0].title, "A"); // Rank 0 in both
18567        assert_eq!(fused[1].title, "B"); // Rank 1 in both
18568        // C and D are in only one list each, order depends on their ranks
18569    }
18570
18571    #[test]
18572    fn test_rrf_handles_disjoint_sets() {
18573        // Test with no overlap between lexical and semantic results
18574        let lexical = vec![make_test_hit("A", 10.0), make_test_hit("B", 8.0)];
18575        let semantic = vec![make_test_hit("C", 0.9), make_test_hit("D", 0.7)];
18576
18577        let fused = rrf_fuse_hits(&lexical, &semantic, "", 10, 0);
18578
18579        // All 4 items should be present
18580        assert_eq!(fused.len(), 4);
18581        let titles: Vec<&str> = fused.iter().map(|h| h.title.as_str()).collect();
18582        assert!(titles.contains(&"A"));
18583        assert!(titles.contains(&"B"));
18584        assert!(titles.contains(&"C"));
18585        assert!(titles.contains(&"D"));
18586    }
18587
18588    #[test]
18589    fn test_rrf_tie_breaking_deterministic() {
18590        // Test that results are deterministic - same input always produces same output
18591        let lexical = vec![
18592            make_test_hit("X", 5.0),
18593            make_test_hit("Y", 5.0),
18594            make_test_hit("Z", 5.0),
18595        ];
18596        let semantic = vec![]; // Empty semantic list
18597
18598        // Run multiple times and verify same order
18599        let fused1 = rrf_fuse_hits(&lexical, &semantic, "", 10, 0);
18600        let fused2 = rrf_fuse_hits(&lexical, &semantic, "", 10, 0);
18601        let fused3 = rrf_fuse_hits(&lexical, &semantic, "", 10, 0);
18602
18603        // Order should be deterministic based on key comparison
18604        assert_eq!(fused1.len(), fused2.len());
18605        assert_eq!(fused2.len(), fused3.len());
18606
18607        for i in 0..fused1.len() {
18608            assert_eq!(fused1[i].title, fused2[i].title, "Mismatch at index {}", i);
18609            assert_eq!(fused2[i].title, fused3[i].title, "Mismatch at index {}", i);
18610        }
18611    }
18612
18613    #[test]
18614    fn test_rrf_both_lists_bonus() {
18615        // Documents appearing in both lists should rank higher than those in only one
18616        // Even if their individual ranks are lower
18617        let lexical = vec![
18618            make_test_hit("solo_lex", 10.0), // Rank 0 lexical only
18619            make_test_hit("both", 5.0),      // Rank 1 lexical
18620        ];
18621        let semantic = vec![
18622            make_test_hit("solo_sem", 0.9), // Rank 0 semantic only
18623            make_test_hit("both", 0.5),     // Rank 1 semantic
18624        ];
18625
18626        let fused = rrf_fuse_hits(&lexical, &semantic, "", 10, 0);
18627
18628        // "both" should be first due to appearing in both lists
18629        // It gets RRF score from rank 1 in both lists = 1/(60+2) * 2 = 0.0322
18630        // vs solo items get 1/(60+1) = 0.0164 each
18631        assert_eq!(
18632            fused[0].title, "both",
18633            "Doc in both lists should rank first"
18634        );
18635    }
18636
18637    #[test]
18638    fn test_rrf_respects_limit_and_offset() {
18639        let lexical = vec![
18640            make_test_hit("A", 10.0),
18641            make_test_hit("B", 8.0),
18642            make_test_hit("C", 6.0),
18643        ];
18644        let semantic = vec![];
18645
18646        // Test limit
18647        let fused = rrf_fuse_hits(&lexical, &semantic, "", 2, 0);
18648        assert_eq!(fused.len(), 2);
18649
18650        // Test offset
18651        let fused_offset = rrf_fuse_hits(&lexical, &semantic, "", 10, 1);
18652        assert_eq!(fused_offset.len(), 2); // Skipped first one
18653
18654        // Test limit 0
18655        let fused_empty = rrf_fuse_hits(&lexical, &semantic, "", 0, 0);
18656        assert!(fused_empty.is_empty());
18657    }
18658
18659    #[test]
18660    fn test_rrf_empty_inputs() {
18661        let empty: Vec<SearchHit> = vec![];
18662        let non_empty = vec![make_test_hit("A", 10.0)];
18663
18664        // Both empty
18665        assert!(rrf_fuse_hits(&empty, &empty, "", 10, 0).is_empty());
18666
18667        // Lexical empty
18668        let fused = rrf_fuse_hits(&empty, &non_empty, "", 10, 0);
18669        assert_eq!(fused.len(), 1);
18670        assert_eq!(fused[0].title, "A");
18671
18672        // Semantic empty
18673        let fused = rrf_fuse_hits(&non_empty, &empty, "", 10, 0);
18674        assert_eq!(fused.len(), 1);
18675        assert_eq!(fused[0].title, "A");
18676    }
18677
18678    #[test]
18679    fn test_rrf_coalesces_empty_title_hits_across_search_modes() {
18680        let mut lexical = make_test_hit("shared", 10.0);
18681        lexical.title.clear();
18682        lexical.source_path = "/shared/untitled.jsonl".into();
18683        lexical.content = "same untitled body".into();
18684        lexical.content_hash = stable_content_hash("same untitled body");
18685
18686        let mut semantic = lexical.clone();
18687        semantic.score = 0.9;
18688
18689        let fused = rrf_fuse_hits(&[lexical], &[semantic], "", 10, 0);
18690        assert_eq!(fused.len(), 1);
18691        assert_eq!(fused[0].title, "");
18692    }
18693
18694    #[test]
18695    fn test_rrf_coalesces_blank_local_source_id_hits_across_search_modes() {
18696        let mut lexical = make_test_hit("shared-local", 10.0);
18697        lexical.source_path = "/shared/local.jsonl".into();
18698        lexical.content = "same local body".into();
18699        lexical.content_hash = stable_content_hash("same local body");
18700        lexical.source_id = "local".into();
18701        lexical.origin_kind = "local".into();
18702
18703        let mut semantic = lexical.clone();
18704        semantic.source_id = "   ".into();
18705        semantic.origin_kind = "local".into();
18706        semantic.score = 0.9;
18707
18708        let fused = rrf_fuse_hits(&[lexical], &[semantic], "", 10, 0);
18709        assert_eq!(fused.len(), 1);
18710        assert_eq!(fused[0].source_id, "local");
18711    }
18712
18713    #[test]
18714    fn test_rrf_keeps_repeated_same_content_at_different_lines() {
18715        let mut first = make_test_hit("same", 10.0);
18716        first.title = "Shared Session".into();
18717        first.source_path = "/shared/session.jsonl".into();
18718        first.content = "repeat me".into();
18719        first.content_hash = stable_content_hash("repeat me");
18720        first.line_number = Some(1);
18721        first.created_at = Some(100);
18722
18723        let mut second = first.clone();
18724        second.line_number = Some(2);
18725        second.created_at = Some(200);
18726        second.score = 0.9;
18727
18728        let fused = rrf_fuse_hits(&[first], &[second], "", 10, 0);
18729        assert_eq!(fused.len(), 2);
18730        assert_eq!(fused[0].line_number, Some(1));
18731        assert_eq!(fused[1].line_number, Some(2));
18732    }
18733
18734    #[test]
18735    fn test_rrf_coalesces_present_and_missing_conversation_id_for_same_message() {
18736        let mut lexical = make_test_hit("same", 10.0);
18737        lexical.title = "Shared Session".into();
18738        lexical.source_path = "/shared/session.jsonl".into();
18739        lexical.content = "identical body".into();
18740        lexical.content_hash = stable_content_hash("identical body");
18741        lexical.created_at = Some(100);
18742        lexical.line_number = Some(1);
18743        lexical.conversation_id = None;
18744
18745        let mut semantic = lexical.clone();
18746        semantic.conversation_id = Some(42);
18747        semantic.score = 0.9;
18748
18749        let fused = rrf_fuse_hits(&[lexical], &[semantic], "", 10, 0);
18750        assert_eq!(fused.len(), 1);
18751        assert_eq!(fused[0].conversation_id, Some(42));
18752    }
18753
18754    #[test]
18755    fn test_rrf_coalesces_present_and_missing_conversation_id_despite_blank_local_source_id() {
18756        let mut lexical = make_test_hit("same", 10.0);
18757        lexical.title = "Shared Session".into();
18758        lexical.source_path = "/shared/session.jsonl".into();
18759        lexical.content = "identical body".into();
18760        lexical.content_hash = stable_content_hash("identical body");
18761        lexical.created_at = Some(100);
18762        lexical.line_number = Some(1);
18763        lexical.conversation_id = None;
18764        lexical.source_id = "local".into();
18765        lexical.origin_kind = "local".into();
18766
18767        let mut semantic = lexical.clone();
18768        semantic.conversation_id = Some(42);
18769        semantic.source_id = "   ".into();
18770        semantic.origin_kind = "local".into();
18771        semantic.score = 0.9;
18772
18773        let fused = rrf_fuse_hits(&[lexical], &[semantic], "", 10, 0);
18774        assert_eq!(fused.len(), 1);
18775        assert_eq!(fused[0].conversation_id, Some(42));
18776    }
18777
18778    #[test]
18779    fn test_rrf_keeps_distinct_conversation_ids_for_shared_path_and_content() {
18780        let mut first = make_test_hit("same", 10.0);
18781        first.title = "Shared Session".into();
18782        first.source_path = "/shared/session.jsonl".into();
18783        first.content = "identical body".into();
18784        first.content_hash = stable_content_hash("identical body");
18785        first.conversation_id = Some(1);
18786
18787        let mut second = first.clone();
18788        second.conversation_id = Some(2);
18789        second.score = 0.9;
18790
18791        let fused = rrf_fuse_hits(&[first], &[second], "", 10, 0);
18792        assert_eq!(fused.len(), 2);
18793        assert!(fused.iter().any(|hit| hit.conversation_id == Some(1)));
18794        assert!(fused.iter().any(|hit| hit.conversation_id == Some(2)));
18795    }
18796
18797    #[test]
18798    fn test_rrf_coalesces_same_conversation_id_despite_title_drift() {
18799        let mut lexical = make_test_hit("same", 10.0);
18800        lexical.title = "Morning Session".into();
18801        lexical.source_path = "/shared/session.jsonl".into();
18802        lexical.content = "identical body".into();
18803        lexical.content_hash = stable_content_hash("identical body");
18804        lexical.conversation_id = Some(9);
18805
18806        let mut semantic = lexical.clone();
18807        semantic.title = "Evening Session".into();
18808        semantic.score = 0.9;
18809
18810        let fused = rrf_fuse_hits(&[lexical], &[semantic], "", 10, 0);
18811        assert_eq!(fused.len(), 1);
18812        assert_eq!(fused[0].conversation_id, Some(9));
18813    }
18814
18815    #[test]
18816    fn test_rrf_keeps_distinct_titles_for_shared_path_and_content() {
18817        let mut morning = make_test_hit("same", 10.0);
18818        morning.title = "Morning Session".into();
18819        morning.source_path = "/shared/session.jsonl".into();
18820        morning.content = "identical body".into();
18821        morning.content_hash = stable_content_hash("identical body");
18822        morning.created_at = None;
18823
18824        let mut evening = morning.clone();
18825        evening.title = "Evening Session".into();
18826        evening.score = 0.9;
18827
18828        let fused = rrf_fuse_hits(&[morning], &[evening], "", 10, 0);
18829        assert_eq!(fused.len(), 2);
18830        assert!(fused.iter().any(|hit| hit.title == "Morning Session"));
18831        assert!(fused.iter().any(|hit| hit.title == "Evening Session"));
18832    }
18833
18834    #[test]
18835    fn test_rrf_candidate_depth() {
18836        // Test with many candidates to ensure proper fusion
18837        let lexical: Vec<_> = (0..50)
18838            .map(|i| make_test_hit(&format!("L{}", i), 100.0 - i as f32))
18839            .collect();
18840        let semantic: Vec<_> = (0..50)
18841            .map(|i| make_test_hit(&format!("S{}", i), 1.0 - 0.01 * i as f32))
18842            .collect();
18843
18844        let fused = rrf_fuse_hits(&lexical, &semantic, "", 20, 0);
18845
18846        // Should return 20 items
18847        assert_eq!(fused.len(), 20);
18848
18849        // All items should be unique
18850        let mut seen = std::collections::HashSet::new();
18851        for hit in &fused {
18852            assert!(seen.insert(&hit.title), "Duplicate hit: {}", hit.title);
18853        }
18854    }
18855
18856    // ==========================================================================
18857    // QueryTokenList Behavior Tests (Opt 4.4)
18858    // ==========================================================================
18859
18860    #[test]
18861    fn query_token_list_parses_small_queries() {
18862        let cases = [
18863            ("hello", 1),
18864            ("hello world", 2),
18865            ("hello AND world", 3),
18866            ("hello world foo bar", 4),
18867        ];
18868
18869        for (query, expected_len) in cases {
18870            let tokens = parse_boolean_query(query);
18871            assert_eq!(tokens.len(), expected_len, "{query}");
18872        }
18873    }
18874
18875    #[test]
18876    fn query_token_list_parses_large_queries() {
18877        let tokens = parse_boolean_query("a b c d e f g h i");
18878        assert_eq!(tokens.len(), 9);
18879    }
18880
18881    #[test]
18882    fn query_token_list_handles_quoted_phrases() {
18883        let tokens = parse_boolean_query("\"hello world\" test");
18884        assert_eq!(tokens.len(), 2);
18885
18886        // Verify the phrase is correctly parsed
18887        assert!(
18888            matches!(&tokens[0], QueryToken::Phrase(phrase) if phrase == "hello world"),
18889            "Expected Phrase token"
18890        );
18891    }
18892
18893    #[test]
18894    fn query_token_list_handles_operators() {
18895        let tokens = parse_boolean_query("foo AND bar OR baz");
18896        assert_eq!(tokens.len(), 5);
18897        assert_eq!(tokens[1], QueryToken::And);
18898        assert_eq!(tokens[3], QueryToken::Or);
18899    }
18900
18901    #[test]
18902    fn query_token_list_empty_query() {
18903        let tokens = parse_boolean_query("");
18904        assert!(tokens.is_empty());
18905    }
18906
18907    #[test]
18908    fn query_token_list_iteration_works() {
18909        let tokens = parse_boolean_query("a b c");
18910        let terms: Vec<_> = tokens
18911            .iter()
18912            .filter_map(|t| match t {
18913                QueryToken::Term(s) => Some(s.as_str()),
18914                _ => None,
18915            })
18916            .collect();
18917        assert_eq!(terms, vec!["a", "b", "c"]);
18918    }
18919
18920    // ==========================================================================
18921    // Unicode Query Parsing Tests (br-327c)
18922    // Comprehensive Unicode handling tests covering emoji, CJK, RTL, mixed
18923    // scripts, zero-width characters, combining characters, normalization,
18924    // supplementary plane characters, and bidirectional text.
18925    // ==========================================================================
18926
18927    // --- Emoji queries ---
18928
18929    #[test]
18930    fn unicode_emoji_treated_as_separator() {
18931        // Emoji are not alphanumeric per Unicode, so sanitize_query replaces them with spaces
18932        let sanitized = sanitize_query("🚀 launch");
18933        assert_eq!(sanitized, "  launch", "Emoji should become space");
18934    }
18935
18936    #[test]
18937    fn unicode_emoji_splits_terms() {
18938        // Emoji between words acts as a separator
18939        let sanitized = sanitize_query("hot🔥code");
18940        assert_eq!(sanitized, "hot code", "Emoji between words splits them");
18941    }
18942
18943    #[test]
18944    fn unicode_multiple_emoji_become_spaces() {
18945        let sanitized = sanitize_query("🚀🔥💻");
18946        assert_eq!(
18947            sanitized.trim(),
18948            "",
18949            "All-emoji query sanitizes to whitespace"
18950        );
18951    }
18952
18953    #[test]
18954    fn unicode_emoji_query_parses_without_panic() {
18955        let tokens = parse_boolean_query("🚀 launch code 🔥");
18956        let terms: Vec<_> = tokens
18957            .iter()
18958            .filter_map(|t| match t {
18959                QueryToken::Term(s) => Some(s.clone()),
18960                _ => None,
18961            })
18962            .collect();
18963        // Emoji removed by sanitization in normalize_term_parts, only words remain
18964        assert!(
18965            terms
18966                .iter()
18967                .any(|t| t.contains("launch") || t.contains("code"))
18968        );
18969    }
18970
18971    #[test]
18972    fn unicode_emoji_query_terms_lower() {
18973        let terms = QueryTermsLower::from_query("🚀 LAUNCH");
18974        // Emoji becomes space, LAUNCH lowercased
18975        let tokens: Vec<&str> = terms.tokens().collect();
18976        assert!(
18977            tokens.contains(&"launch"),
18978            "Should extract 'launch' from emoji query"
18979        );
18980    }
18981
18982    // --- CJK character queries ---
18983
18984    #[test]
18985    fn unicode_cjk_chinese_preserved() {
18986        assert_eq!(sanitize_query("测试代码"), "测试代码");
18987        assert_eq!(sanitize_query("测试 代码"), "测试 代码");
18988    }
18989
18990    #[test]
18991    fn unicode_cjk_japanese_preserved() {
18992        assert_eq!(sanitize_query("テスト"), "テスト");
18993        // Hiragana and Katakana are alphanumeric
18994        assert_eq!(sanitize_query("こんにちは世界"), "こんにちは世界");
18995    }
18996
18997    #[test]
18998    fn unicode_cjk_korean_preserved() {
18999        assert_eq!(sanitize_query("테스트"), "테스트");
19000        assert_eq!(sanitize_query("안녕하세요"), "안녕하세요");
19001    }
19002
19003    #[test]
19004    fn unicode_cjk_parsed_as_terms() {
19005        let tokens = parse_boolean_query("测试 代码 search");
19006        let terms: Vec<_> = tokens
19007            .iter()
19008            .filter_map(|t| match t {
19009                QueryToken::Term(s) => Some(s.as_str()),
19010                _ => None,
19011            })
19012            .collect();
19013        assert_eq!(terms, vec!["测试", "代码", "search"]);
19014    }
19015
19016    #[test]
19017    fn unicode_cjk_query_terms_lower() {
19018        let terms = QueryTermsLower::from_query("测试 代码");
19019        let tokens: Vec<&str> = terms.tokens().collect();
19020        assert_eq!(tokens, vec!["测试", "代码"]);
19021    }
19022
19023    // --- RTL text queries ---
19024
19025    #[test]
19026    fn unicode_hebrew_preserved() {
19027        assert_eq!(sanitize_query("שלום עולם"), "שלום עולם");
19028    }
19029
19030    #[test]
19031    fn unicode_arabic_preserved() {
19032        assert_eq!(sanitize_query("مرحبا"), "مرحبا");
19033    }
19034
19035    #[test]
19036    fn unicode_hebrew_parsed_as_terms() {
19037        let tokens = parse_boolean_query("שלום עולם");
19038        let terms: Vec<_> = tokens
19039            .iter()
19040            .filter_map(|t| match t {
19041                QueryToken::Term(s) => Some(s.as_str()),
19042                _ => None,
19043            })
19044            .collect();
19045        assert_eq!(terms, vec!["שלום", "עולם"]);
19046    }
19047
19048    #[test]
19049    fn unicode_arabic_query_terms_lower() {
19050        // Arabic doesn't have case, so lowercasing is a no-op
19051        let terms = QueryTermsLower::from_query("مرحبا بالعالم");
19052        let tokens: Vec<&str> = terms.tokens().collect();
19053        assert_eq!(tokens, vec!["مرحبا", "بالعالم"]);
19054    }
19055
19056    // --- Mixed script queries ---
19057
19058    #[test]
19059    fn unicode_mixed_scripts_preserved() {
19060        let sanitized = sanitize_query("Hello 世界 мир");
19061        assert_eq!(sanitized, "Hello 世界 мир");
19062    }
19063
19064    #[test]
19065    fn unicode_mixed_scripts_parsed() {
19066        let tokens = parse_boolean_query("Hello 世界 мир");
19067        let terms: Vec<_> = tokens
19068            .iter()
19069            .filter_map(|t| match t {
19070                QueryToken::Term(s) => Some(s.as_str()),
19071                _ => None,
19072            })
19073            .collect();
19074        assert_eq!(terms, vec!["Hello", "世界", "мир"]);
19075    }
19076
19077    #[test]
19078    fn unicode_mixed_scripts_with_emoji() {
19079        // Emoji stripped, scripts preserved
19080        let sanitized = sanitize_query("Hello 🌍 世界");
19081        assert_eq!(sanitized, "Hello   世界");
19082    }
19083
19084    #[test]
19085    fn unicode_latin_cyrillic_arabic_query() {
19086        let terms = QueryTermsLower::from_query("Hello Мир مرحبا");
19087        let tokens: Vec<&str> = terms.tokens().collect();
19088        assert_eq!(tokens, vec!["hello", "мир", "مرحبا"]);
19089    }
19090
19091    // --- Zero-width characters ---
19092
19093    #[test]
19094    fn unicode_zero_width_joiner_removed() {
19095        // Zero-width joiner (U+200D) is not alphanumeric → becomes space
19096        let sanitized = sanitize_query("test\u{200D}query");
19097        assert_eq!(sanitized, "test query");
19098    }
19099
19100    #[test]
19101    fn unicode_zero_width_non_joiner_removed() {
19102        // Zero-width non-joiner (U+200C) is not alphanumeric → becomes space
19103        let sanitized = sanitize_query("test\u{200C}query");
19104        assert_eq!(sanitized, "test query");
19105    }
19106
19107    #[test]
19108    fn unicode_zero_width_space_removed() {
19109        // Zero-width space (U+200B) is not alphanumeric → becomes space
19110        let sanitized = sanitize_query("test\u{200B}query");
19111        assert_eq!(sanitized, "test query");
19112    }
19113
19114    #[test]
19115    fn unicode_bom_removed() {
19116        // Byte-order mark (U+FEFF) should not appear in search terms
19117        let sanitized = sanitize_query("\u{FEFF}test");
19118        assert_eq!(sanitized, " test");
19119    }
19120
19121    // --- Combining characters ---
19122
19123    #[test]
19124    fn unicode_precomposed_accent_preserved() {
19125        // Precomposed é (U+00E9) is a single letter → alphanumeric
19126        let sanitized = sanitize_query("café");
19127        assert_eq!(sanitized, "café");
19128    }
19129
19130    #[test]
19131    fn unicode_combining_accent_becomes_separator() {
19132        // Decomposed: 'e' + combining acute accent (U+0301)
19133        // nfc_sanitize_query first normalizes to NFC, composing e + U+0301
19134        // into precomposed é (U+00E9), which is alphanumeric and preserved.
19135        let input = "cafe\u{0301}";
19136        let sanitized = sanitize_query(input);
19137        assert_eq!(sanitized, "caf\u{00e9}");
19138    }
19139
19140    #[test]
19141    fn unicode_nfc_and_nfd_produce_same_sanitized_query() {
19142        // NFC (precomposed): é = U+00E9 (single char, alphanumeric)
19143        let nfc = "caf\u{00E9}";
19144        // NFD (decomposed): e + ◌́ = U+0065 U+0301 (two chars, accent not alphanumeric)
19145        let nfd = "cafe\u{0301}";
19146
19147        let san_nfc = sanitize_query(nfc);
19148        let san_nfd = sanitize_query(nfd);
19149
19150        // Both produce "café" because nfc_sanitize_query normalizes to NFC
19151        // before sanitization, matching the NFC-indexed content from
19152        // DefaultCanonicalizer.
19153        assert_eq!(san_nfc, "café");
19154        assert_eq!(san_nfd, "café");
19155        assert_eq!(san_nfc, san_nfd);
19156    }
19157
19158    #[test]
19159    fn unicode_combining_marks_do_not_panic() {
19160        // Multiple combining marks stacked (e.g., Zalgo text)
19161        let zalgo = "t\u{0301}\u{0302}\u{0303}e\u{0304}\u{0305}st";
19162        let sanitized = sanitize_query(zalgo);
19163        // Should not panic; combining marks become spaces
19164        assert!(sanitized.contains('t'));
19165        assert!(sanitized.contains('s'));
19166    }
19167
19168    // --- Supplementary plane characters (outside BMP) ---
19169
19170    #[test]
19171    fn unicode_mathematical_bold_letters_preserved() {
19172        // Mathematical Bold Capital A (U+1D400) — classified as Letter
19173        let input = "\u{1D400}\u{1D401}\u{1D402}";
19174        let sanitized = sanitize_query(input);
19175        assert_eq!(
19176            sanitized, input,
19177            "Mathematical bold letters are alphanumeric"
19178        );
19179    }
19180
19181    #[test]
19182    fn unicode_supplementary_ideograph_preserved() {
19183        // CJK Unified Ideographs Extension B character (U+20000)
19184        let input = "\u{20000}";
19185        let sanitized = sanitize_query(input);
19186        assert_eq!(
19187            sanitized, input,
19188            "Supplementary CJK ideographs are alphanumeric"
19189        );
19190    }
19191
19192    #[test]
19193    fn unicode_supplementary_emoji_removed() {
19194        // Grinning face (U+1F600) — Symbol, not alphanumeric
19195        let input = "test\u{1F600}query";
19196        let sanitized = sanitize_query(input);
19197        assert_eq!(sanitized, "test query");
19198    }
19199
19200    // --- Bidirectional text ---
19201
19202    #[test]
19203    fn unicode_bidi_mixed_ltr_rtl_no_panic() {
19204        let input = "hello שלום world עולם";
19205        let tokens = parse_boolean_query(input);
19206        let terms: Vec<_> = tokens
19207            .iter()
19208            .filter_map(|t| match t {
19209                QueryToken::Term(s) => Some(s.as_str()),
19210                _ => None,
19211            })
19212            .collect();
19213        assert_eq!(terms.len(), 4);
19214        assert!(terms.contains(&"hello"));
19215        assert!(terms.contains(&"שלום"));
19216        assert!(terms.contains(&"world"));
19217        assert!(terms.contains(&"עולם"));
19218    }
19219
19220    #[test]
19221    fn unicode_bidi_override_chars_removed() {
19222        // Left-to-right override (U+202D) and pop directional (U+202C)
19223        // These are format characters, not alphanumeric
19224        let input = "test\u{202D}content\u{202C}end";
19225        let sanitized = sanitize_query(input);
19226        assert_eq!(sanitized, "test content end");
19227    }
19228
19229    #[test]
19230    fn unicode_bidi_rtl_mark_removed() {
19231        // Right-to-left mark (U+200F) is not alphanumeric
19232        let input = "test\u{200F}content";
19233        let sanitized = sanitize_query(input);
19234        assert_eq!(sanitized, "test content");
19235    }
19236
19237    // --- Full pipeline integration tests ---
19238
19239    #[test]
19240    fn unicode_full_pipeline_cjk_query() {
19241        let explanation = QueryExplanation::analyze("测试 代码", &SearchFilters::default());
19242        assert_eq!(explanation.parsed.terms.len(), 2);
19243        assert!(!explanation.parsed.terms[0].text.is_empty());
19244        assert!(!explanation.parsed.terms[1].text.is_empty());
19245    }
19246
19247    #[test]
19248    fn unicode_full_pipeline_mixed_script_boolean() {
19249        let explanation =
19250            QueryExplanation::analyze("Hello AND 世界 OR مرحبا", &SearchFilters::default());
19251        // Should parse operators correctly even with mixed scripts
19252        assert!(
19253            explanation.parsed.operators.iter().any(|op| op == "AND"),
19254            "AND operator should be recognized in mixed-script query"
19255        );
19256    }
19257
19258    #[test]
19259    fn unicode_full_pipeline_emoji_query_type() {
19260        // An all-emoji query sanitizes to empty — should handle gracefully
19261        let explanation = QueryExplanation::analyze("🚀🔥💻", &SearchFilters::default());
19262        // Should not panic; terms may be empty after sanitization
19263        assert!(
19264            explanation.parsed.terms.is_empty()
19265                || explanation
19266                    .parsed
19267                    .terms
19268                    .iter()
19269                    .all(|t| t.subterms.is_empty()),
19270            "All-emoji query should produce no meaningful terms"
19271        );
19272    }
19273
19274    #[test]
19275    fn unicode_full_pipeline_phrase_with_cjk() {
19276        let explanation = QueryExplanation::analyze("\"测试代码\"", &SearchFilters::default());
19277        assert!(
19278            !explanation.parsed.phrases.is_empty(),
19279            "CJK phrase should be recognized"
19280        );
19281    }
19282
19283    #[test]
19284    fn unicode_full_pipeline_wildcard_with_unicode() {
19285        let explanation = QueryExplanation::analyze("*测试*", &SearchFilters::default());
19286        assert!(
19287            !explanation.parsed.terms.is_empty(),
19288            "Wildcard with CJK should produce terms"
19289        );
19290        // Check that the term has a substring/wildcard pattern
19291        if let Some(term) = explanation.parsed.terms.first() {
19292            assert!(
19293                term.subterms
19294                    .iter()
19295                    .any(|s| s.pattern.contains("*") || s.pattern == "exact"),
19296                "CJK wildcard should produce wildcard or exact pattern"
19297            );
19298        }
19299    }
19300
19301    #[test]
19302    fn unicode_query_terms_lower_case_folding() {
19303        // German sharp s (ß) lowercases to ß (not ss in Rust)
19304        let terms = QueryTermsLower::from_query("STRAßE");
19305        assert_eq!(terms.query_lower, "straße");
19306
19307        // Turkish dotless I (İ → i with dot below in some locales, but
19308        // Rust uses simple Unicode case mapping)
19309        let terms2 = QueryTermsLower::from_query("HELLO");
19310        assert_eq!(terms2.query_lower, "hello");
19311    }
19312
19313    #[test]
19314    fn unicode_normalize_term_parts_cjk() {
19315        let parts = normalize_term_parts("测试 代码");
19316        assert_eq!(parts, vec!["测试", "代码"]);
19317    }
19318
19319    #[test]
19320    fn unicode_normalize_term_parts_strips_emoji() {
19321        let parts = normalize_term_parts("🚀launch🔥code");
19322        // Emoji replaced with space, splitting into two terms
19323        assert!(parts.contains(&"launch".to_string()));
19324        assert!(parts.contains(&"code".to_string()));
19325    }
19326
19327    // ── Special character query tests (br-g650) ────────────────────────────
19328
19329    // Category 1: Unbalanced quotes
19330
19331    #[test]
19332    fn special_char_unbalanced_quote_no_panic() {
19333        let tokens = parse_boolean_query("\"hello world");
19334        assert!(
19335            tokens
19336                .iter()
19337                .any(|t| matches!(t, QueryToken::Phrase(p) if p.contains("hello"))),
19338            "Unbalanced quote should still produce a phrase: {tokens:?}"
19339        );
19340    }
19341
19342    #[test]
19343    fn special_char_unbalanced_trailing_quote() {
19344        let tokens = parse_boolean_query("test\"");
19345        assert!(
19346            tokens
19347                .iter()
19348                .any(|t| matches!(t, QueryToken::Term(w) if w == "test")),
19349            "Text before trailing quote should parse as term: {tokens:?}"
19350        );
19351    }
19352
19353    #[test]
19354    fn special_char_multiple_unbalanced_quotes() {
19355        let tokens = parse_boolean_query("\"foo \"bar");
19356        assert!(
19357            !tokens.is_empty(),
19358            "Should parse despite odd quotes: {tokens:?}"
19359        );
19360    }
19361
19362    #[test]
19363    fn special_char_empty_quotes() {
19364        let tokens = parse_boolean_query("\"\" test");
19365        assert!(
19366            tokens
19367                .iter()
19368                .any(|t| matches!(t, QueryToken::Term(w) if w == "test")),
19369            "Empty quotes should be skipped: {tokens:?}"
19370        );
19371    }
19372
19373    #[test]
19374    fn special_char_unbalanced_via_sanitize() {
19375        let sanitized = sanitize_query("\"hello world");
19376        assert!(
19377            sanitized.contains('"'),
19378            "Quotes preserved by sanitize_query"
19379        );
19380    }
19381
19382    // Category 2: Escaped quotes
19383
19384    #[test]
19385    fn special_char_backslash_quote_sanitize() {
19386        let sanitized = sanitize_query("\\\"test\\\"");
19387        assert!(sanitized.contains('"'));
19388        assert!(!sanitized.contains('\\'), "Backslash should be stripped");
19389    }
19390
19391    #[test]
19392    fn special_char_backslash_quote_parse() {
19393        let tokens = parse_boolean_query("\\\"test\\\"");
19394        assert!(!tokens.is_empty(), "Should parse without panic: {tokens:?}");
19395    }
19396
19397    #[test]
19398    fn special_char_inner_escaped_quotes() {
19399        let tokens = parse_boolean_query("\"test \\\"inner\\\" test\"");
19400        assert!(
19401            !tokens.is_empty(),
19402            "Nested escaped quotes should not panic: {tokens:?}"
19403        );
19404    }
19405
19406    // Category 3: Backslash sequences
19407
19408    #[test]
19409    fn special_char_windows_path_sanitize() {
19410        let sanitized = sanitize_query("C:\\Users\\test");
19411        assert_eq!(sanitized, "C  Users test");
19412    }
19413
19414    #[test]
19415    fn special_char_unc_path_sanitize() {
19416        let sanitized = sanitize_query("\\\\server\\share");
19417        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19418        assert!(parts.contains(&"server"));
19419        assert!(parts.contains(&"share"));
19420    }
19421
19422    #[test]
19423    fn special_char_windows_path_terms() {
19424        let parts = normalize_term_parts("C:\\Users\\test\\file.rs");
19425        assert!(parts.contains(&"C".to_string()));
19426        assert!(parts.contains(&"Users".to_string()));
19427        assert!(parts.contains(&"test".to_string()));
19428        assert!(parts.contains(&"file".to_string()));
19429        assert!(parts.contains(&"rs".to_string()));
19430    }
19431
19432    // Category 4: Regex metacharacters
19433
19434    #[test]
19435    fn special_char_regex_dot_star() {
19436        let sanitized = sanitize_query("foo.*bar");
19437        assert_eq!(sanitized, "foo *bar");
19438    }
19439
19440    #[test]
19441    fn special_char_regex_char_class() {
19442        let sanitized = sanitize_query("[a-z]+");
19443        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19444        assert_eq!(parts, vec!["a-z"]);
19445        assert_eq!(normalize_term_parts("[a-z]+"), vec!["a", "z"]);
19446    }
19447
19448    #[test]
19449    fn special_char_regex_anchors() {
19450        let sanitized = sanitize_query("^start$");
19451        assert_eq!(sanitized.trim(), "start");
19452    }
19453
19454    #[test]
19455    fn special_char_regex_pipe_groups() {
19456        let sanitized = sanitize_query("(foo|bar)");
19457        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19458        assert_eq!(parts, vec!["foo", "bar"]);
19459    }
19460
19461    // Category 5: SQL injection patterns
19462
19463    #[test]
19464    fn special_char_sql_injection_or() {
19465        let sanitized = sanitize_query("'OR 1=1--");
19466        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19467        assert!(parts.contains(&"OR"));
19468        assert!(parts.contains(&"1"));
19469        assert!(!sanitized.contains('\''));
19470        assert!(!sanitized.contains('='));
19471    }
19472
19473    #[test]
19474    fn special_char_sql_injection_drop() {
19475        let sanitized = sanitize_query("; DROP TABLE users;--");
19476        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19477        assert!(parts.contains(&"DROP"));
19478        assert!(parts.contains(&"TABLE"));
19479        assert!(parts.contains(&"users"));
19480        assert!(!sanitized.contains(';'));
19481    }
19482
19483    #[test]
19484    fn special_char_sql_injection_union() {
19485        let sanitized = sanitize_query("' UNION SELECT * FROM passwords --");
19486        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19487        assert!(parts.contains(&"UNION"));
19488        assert!(parts.contains(&"SELECT"));
19489        assert!(parts.contains(&"*"));
19490        assert!(parts.contains(&"FROM"));
19491        assert!(parts.contains(&"passwords"));
19492    }
19493
19494    #[test]
19495    fn special_char_sql_parse_as_literal() {
19496        let tokens = parse_boolean_query("OR 1=1");
19497        assert!(
19498            tokens.iter().any(|t| matches!(t, QueryToken::Or)),
19499            "OR should be parsed as Or operator: {tokens:?}"
19500        );
19501    }
19502
19503    // Category 6: Shell injection patterns
19504
19505    #[test]
19506    fn special_char_shell_subshell() {
19507        let sanitized = sanitize_query("$(cmd)");
19508        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19509        assert_eq!(parts, vec!["cmd"]);
19510    }
19511
19512    #[test]
19513    fn special_char_shell_backticks() {
19514        let sanitized = sanitize_query("`cmd`");
19515        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19516        assert_eq!(parts, vec!["cmd"]);
19517    }
19518
19519    #[test]
19520    fn special_char_shell_pipe_rm() {
19521        let sanitized = sanitize_query("| rm -rf /");
19522        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19523        assert!(parts.contains(&"rm"));
19524        assert!(parts.contains(&"-rf"));
19525        assert_eq!(normalize_term_parts("| rm -rf /"), vec!["rm", "rf"]);
19526        assert!(!sanitized.contains('|'));
19527        assert!(!sanitized.contains('/'));
19528    }
19529
19530    #[test]
19531    fn special_char_shell_semicolon_chain() {
19532        let sanitized = sanitize_query("test; echo pwned; cat /etc/passwd");
19533        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19534        assert!(parts.contains(&"test"));
19535        assert!(parts.contains(&"echo"));
19536        assert!(parts.contains(&"pwned"));
19537        assert!(!sanitized.contains(';'));
19538    }
19539
19540    // Category 7: Null bytes
19541
19542    #[test]
19543    fn special_char_null_byte_mid_string() {
19544        let sanitized = sanitize_query("test\x00hidden");
19545        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19546        assert_eq!(parts, vec!["test", "hidden"]);
19547    }
19548
19549    #[test]
19550    fn special_char_null_byte_leading() {
19551        let sanitized = sanitize_query("\x00\x00attack");
19552        assert_eq!(sanitized.trim(), "attack");
19553    }
19554
19555    #[test]
19556    fn special_char_null_byte_trailing() {
19557        let sanitized = sanitize_query("query\x00\x00\x00");
19558        assert_eq!(sanitized.trim(), "query");
19559    }
19560
19561    #[test]
19562    fn special_char_null_byte_parse() {
19563        let tokens = parse_boolean_query("test\x00hidden");
19564        assert!(
19565            !tokens.is_empty(),
19566            "Null bytes should not prevent parsing: {tokens:?}"
19567        );
19568    }
19569
19570    // Category 8: Control characters
19571
19572    #[test]
19573    fn special_char_control_newline() {
19574        let sanitized = sanitize_query("line1\nline2");
19575        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19576        assert_eq!(parts, vec!["line1", "line2"]);
19577    }
19578
19579    #[test]
19580    fn special_char_control_tab_cr() {
19581        let sanitized = sanitize_query("tab\there\r\nend");
19582        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19583        assert_eq!(parts, vec!["tab", "here", "end"]);
19584    }
19585
19586    #[test]
19587    fn special_char_control_parse_whitespace() {
19588        let tokens = parse_boolean_query("hello\tworld\ntest");
19589        let terms: Vec<&str> = tokens
19590            .iter()
19591            .filter_map(|t| match t {
19592                QueryToken::Term(s) => Some(s.as_str()),
19593                _ => None,
19594            })
19595            .collect();
19596        assert_eq!(terms, vec!["hello", "world", "test"]);
19597    }
19598
19599    #[test]
19600    fn special_char_control_bell_escape() {
19601        let sanitized = sanitize_query("test\x07\x1b[31mred");
19602        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19603        assert!(parts.contains(&"test"));
19604        assert!(parts.contains(&"31mred"));
19605    }
19606
19607    // Category 9: HTML/XML entities
19608
19609    #[test]
19610    fn special_char_html_entity_lt() {
19611        let sanitized = sanitize_query("&lt;script&gt;");
19612        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19613        assert_eq!(parts, vec!["lt", "script", "gt"]);
19614    }
19615
19616    #[test]
19617    fn special_char_html_numeric_entity() {
19618        let sanitized = sanitize_query("&#x3C;script&#x3E;");
19619        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19620        assert!(parts.contains(&"x3C"));
19621        assert!(parts.contains(&"script"));
19622        assert!(parts.contains(&"x3E"));
19623    }
19624
19625    #[test]
19626    fn special_char_html_tags_stripped() {
19627        let sanitized = sanitize_query("<script>alert('xss')</script>");
19628        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19629        assert!(parts.contains(&"script"));
19630        assert!(parts.contains(&"alert"));
19631        assert!(parts.contains(&"xss"));
19632    }
19633
19634    #[test]
19635    fn special_char_html_attribute() {
19636        let sanitized = sanitize_query("<img src=\"evil.js\" onerror=\"alert(1)\">");
19637        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19638        assert!(parts.contains(&"img"));
19639        assert!(parts.contains(&"src"));
19640        assert!(parts.contains(&"onerror"));
19641    }
19642
19643    // Category 10: URL encoding
19644
19645    #[test]
19646    fn special_char_url_percent_encoding() {
19647        let sanitized = sanitize_query("%20space%2Fslash");
19648        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19649        assert_eq!(parts, vec!["20space", "2Fslash"]);
19650    }
19651
19652    #[test]
19653    fn special_char_url_null_byte_encoded() {
19654        let sanitized = sanitize_query("test%00hidden");
19655        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19656        assert_eq!(parts, vec!["test", "00hidden"]);
19657    }
19658
19659    #[test]
19660    fn special_char_url_full_query_string() {
19661        let sanitized = sanitize_query("search?q=hello&lang=en");
19662        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19663        assert_eq!(parts, vec!["search", "q", "hello", "lang", "en"]);
19664    }
19665
19666    // Cross-cutting: full pipeline integration
19667
19668    #[test]
19669    fn special_char_explain_sql_injection() {
19670        let filters = SearchFilters::default();
19671        let explanation = QueryExplanation::analyze("'OR 1=1--", &filters);
19672        assert!(
19673            !explanation.parsed.terms.is_empty() || !explanation.parsed.phrases.is_empty(),
19674            "SQL injection should produce parseable terms"
19675        );
19676    }
19677
19678    #[test]
19679    fn special_char_explain_shell_injection() {
19680        let filters = SearchFilters::default();
19681        let explanation = QueryExplanation::analyze("$(rm -rf /)", &filters);
19682        assert!(
19683            !explanation.parsed.terms.is_empty(),
19684            "Shell injection should produce parseable terms"
19685        );
19686    }
19687
19688    #[test]
19689    fn special_char_explain_html_xss() {
19690        let filters = SearchFilters::default();
19691        let explanation = QueryExplanation::analyze("<script>alert('xss')</script>", &filters);
19692        assert!(
19693            !explanation.parsed.terms.is_empty(),
19694            "XSS payload should produce parseable terms"
19695        );
19696    }
19697
19698    #[test]
19699    fn special_char_terms_lower_injection() {
19700        let qt = QueryTermsLower::from_query("'; DROP TABLE--");
19701        let tokens: Vec<&str> = qt.tokens().collect();
19702        for token in &tokens {
19703            assert!(
19704                token.chars().all(|c| c.is_alphanumeric()),
19705                "Token should only contain alphanumeric characters: {token}"
19706            );
19707        }
19708    }
19709
19710    #[test]
19711    fn special_char_terms_lower_null_bytes() {
19712        let qt = QueryTermsLower::from_query("test\x00hidden");
19713        let tokens: Vec<&str> = qt.tokens().collect();
19714        assert!(tokens.contains(&"test"));
19715        assert!(tokens.contains(&"hidden"));
19716    }
19717
19718    #[test]
19719    fn special_char_boolean_with_injection() {
19720        let tokens = parse_boolean_query("search AND 'OR 1=1-- NOT drop");
19721        assert!(
19722            tokens.iter().any(|t| matches!(t, QueryToken::And)),
19723            "Boolean AND should still be recognized: {tokens:?}"
19724        );
19725        assert!(
19726            tokens.iter().any(|t| matches!(t, QueryToken::Not)),
19727            "Boolean NOT should still be recognized: {tokens:?}"
19728        );
19729    }
19730
19731    // ==========================================================================
19732    // Query Length Stress Tests (coding_agent_session_search-z1bk)
19733    // Tests for extreme input sizes to ensure parser robustness.
19734    // ==========================================================================
19735
19736    #[test]
19737    fn stress_query_100k_chars_completes_quickly() {
19738        // 100k character query - must complete in <1 second
19739        let long_query = "a ".repeat(50000);
19740        assert_eq!(long_query.len(), 100000);
19741
19742        let start = std::time::Instant::now();
19743        let sanitized = sanitize_query(&long_query);
19744        let elapsed_sanitize = start.elapsed();
19745
19746        let start = std::time::Instant::now();
19747        let tokens = parse_boolean_query(&sanitized);
19748        let elapsed_parse = start.elapsed();
19749
19750        assert!(
19751            elapsed_sanitize < std::time::Duration::from_secs(1),
19752            "sanitize_query with 100k chars took {:?} (>1s)",
19753            elapsed_sanitize
19754        );
19755        assert!(
19756            elapsed_parse < std::time::Duration::from_secs(1),
19757            "parse_boolean_query with 100k chars took {:?} (>1s)",
19758            elapsed_parse
19759        );
19760        assert!(!tokens.is_empty(), "100k char query should produce tokens");
19761    }
19762
19763    #[test]
19764    fn stress_query_1000_terms() {
19765        // 1000 space-separated words
19766        let words: Vec<String> = (0..1000).map(|i| format!("word{}", i)).collect();
19767        let query = words.join(" ");
19768
19769        let start = std::time::Instant::now();
19770        let sanitized = sanitize_query(&query);
19771        let tokens = parse_boolean_query(&sanitized);
19772        let elapsed = start.elapsed();
19773
19774        assert!(
19775            elapsed < std::time::Duration::from_secs(1),
19776            "1000 terms query took {:?} (>1s)",
19777            elapsed
19778        );
19779        // Should have roughly 1000 Term tokens
19780        let term_count = tokens
19781            .iter()
19782            .filter(|t| matches!(t, QueryToken::Term(_)))
19783            .count();
19784        assert!(
19785            term_count >= 900,
19786            "Expected ~1000 terms, got {} terms",
19787            term_count
19788        );
19789    }
19790
19791    #[test]
19792    fn stress_query_1000_identical_terms() {
19793        // Same word repeated 1000 times
19794        let query = "test ".repeat(1000);
19795
19796        let start = std::time::Instant::now();
19797        let sanitized = sanitize_query(&query);
19798        let tokens = parse_boolean_query(&sanitized);
19799        let elapsed = start.elapsed();
19800
19801        assert!(
19802            elapsed < std::time::Duration::from_secs(1),
19803            "1000 identical terms query took {:?} (>1s)",
19804            elapsed
19805        );
19806
19807        // Verify parse_boolean_query produced expected tokens
19808        let parsed_term_count = tokens
19809            .iter()
19810            .filter(|t| matches!(t, QueryToken::Term(_)))
19811            .count();
19812        assert_eq!(parsed_term_count, 1000, "Parser should produce 1000 terms");
19813
19814        // QueryTermsLower should handle this efficiently
19815        let qt = QueryTermsLower::from_query(&query);
19816        let tokens_lower: Vec<&str> = qt.tokens().collect();
19817        assert_eq!(
19818            tokens_lower.len(),
19819            1000,
19820            "All 1000 identical terms should be preserved"
19821        );
19822        assert!(
19823            tokens_lower.iter().all(|t| *t == "test"),
19824            "All tokens should be 'test'"
19825        );
19826    }
19827
19828    #[test]
19829    fn stress_query_10k_char_single_term() {
19830        // 10k character single continuous string (no spaces)
19831        let long_term = "a".repeat(10000);
19832
19833        let start = std::time::Instant::now();
19834        let sanitized = sanitize_query(&long_term);
19835        let tokens = parse_boolean_query(&sanitized);
19836        let elapsed = start.elapsed();
19837
19838        assert!(
19839            elapsed < std::time::Duration::from_secs(1),
19840            "10k char single term took {:?} (>1s)",
19841            elapsed
19842        );
19843        assert_eq!(tokens.len(), 1, "Should produce exactly one token");
19844        assert!(
19845            matches!(&tokens[0], QueryToken::Term(t) if t.len() == 10000),
19846            "Expected Term token"
19847        );
19848    }
19849
19850    #[test]
19851    fn stress_deeply_nested_parentheses() {
19852        // 100+ levels of nested parentheses (though parser doesn't use them,
19853        // they become spaces and shouldn't cause issues)
19854        let open_parens = "(".repeat(100);
19855        let close_parens = ")".repeat(100);
19856        let query = format!("{}test{}", open_parens, close_parens);
19857
19858        let start = std::time::Instant::now();
19859        let sanitized = sanitize_query(&query);
19860        let tokens = parse_boolean_query(&sanitized);
19861        let elapsed = start.elapsed();
19862
19863        assert!(
19864            elapsed < std::time::Duration::from_millis(100),
19865            "Deeply nested parens took {:?} (>100ms)",
19866            elapsed
19867        );
19868        // Parentheses become spaces, leaving just "test"
19869        let term_count = tokens
19870            .iter()
19871            .filter(|t| matches!(t, QueryToken::Term(_)))
19872            .count();
19873        assert_eq!(term_count, 1, "Should have 1 term after sanitizing parens");
19874    }
19875
19876    #[test]
19877    fn stress_many_boolean_operators() {
19878        // 100+ boolean operators: "a AND b AND c AND ..."
19879        let terms: Vec<String> = (0..101).map(|i| format!("term{}", i)).collect();
19880        let query = terms.join(" AND ");
19881
19882        let start = std::time::Instant::now();
19883        let tokens = parse_boolean_query(&query);
19884        let elapsed = start.elapsed();
19885
19886        assert!(
19887            elapsed < std::time::Duration::from_secs(1),
19888            "100+ boolean ops took {:?} (>1s)",
19889            elapsed
19890        );
19891
19892        let and_count = tokens
19893            .iter()
19894            .filter(|t| matches!(t, QueryToken::And))
19895            .count();
19896        let term_count = tokens
19897            .iter()
19898            .filter(|t| matches!(t, QueryToken::Term(_)))
19899            .count();
19900
19901        assert_eq!(and_count, 100, "Should have 100 AND operators");
19902        assert_eq!(term_count, 101, "Should have 101 terms");
19903    }
19904
19905    #[test]
19906    fn stress_many_or_operators() {
19907        // 100+ OR operators: "a OR b OR c OR ..."
19908        let terms: Vec<String> = (0..101).map(|i| format!("opt{}", i)).collect();
19909        let query = terms.join(" OR ");
19910
19911        let start = std::time::Instant::now();
19912        let tokens = parse_boolean_query(&query);
19913        let elapsed = start.elapsed();
19914
19915        assert!(
19916            elapsed < std::time::Duration::from_secs(1),
19917            "100+ OR ops took {:?} (>1s)",
19918            elapsed
19919        );
19920
19921        let or_count = tokens
19922            .iter()
19923            .filter(|t| matches!(t, QueryToken::Or))
19924            .count();
19925        assert_eq!(or_count, 100, "Should have 100 OR operators");
19926    }
19927
19928    #[test]
19929    fn stress_mixed_boolean_operators() {
19930        // Complex query with many mixed operators
19931        let query = "a AND b OR c NOT d AND e OR f NOT g ".repeat(50);
19932
19933        let start = std::time::Instant::now();
19934        let tokens = parse_boolean_query(&query);
19935        let elapsed = start.elapsed();
19936
19937        assert!(
19938            elapsed < std::time::Duration::from_secs(1),
19939            "Mixed boolean ops took {:?} (>1s)",
19940            elapsed
19941        );
19942        assert!(
19943            !tokens.is_empty(),
19944            "Complex boolean query should produce tokens"
19945        );
19946    }
19947
19948    #[test]
19949    fn stress_memory_bounds_large_query() {
19950        // Verify no excessive memory allocation with large input
19951        // We can't easily measure memory in a unit test, but we can verify
19952        // the output size is reasonable relative to input.
19953        let large_query = "x".repeat(100000);
19954
19955        let sanitized = sanitize_query(&large_query);
19956        let tokens = parse_boolean_query(&sanitized);
19957
19958        // Sanitized output shouldn't be larger than input
19959        assert!(
19960            sanitized.len() <= large_query.len(),
19961            "Sanitized output should not exceed input size"
19962        );
19963
19964        // Should produce exactly 1 token
19965        assert_eq!(tokens.len(), 1);
19966
19967        // QueryTermsLower internal storage should be bounded
19968        let qt = QueryTermsLower::from_query(&large_query);
19969        let token_count = qt.tokens().count();
19970        assert_eq!(token_count, 1, "Should be 1 token of 100k chars");
19971    }
19972
19973    #[test]
19974    fn stress_concurrent_queries() {
19975        use std::thread;
19976
19977        let queries: Vec<String> = (0..100)
19978            .map(|i| format!("concurrent_query_{} test search", i))
19979            .collect();
19980
19981        let handles: Vec<_> = queries
19982            .into_iter()
19983            .map(|query| {
19984                thread::spawn(move || {
19985                    let sanitized = sanitize_query(&query);
19986                    let tokens = parse_boolean_query(&sanitized);
19987                    let qt = QueryTermsLower::from_query(&query);
19988                    (tokens.len(), qt.tokens().count())
19989                })
19990            })
19991            .collect();
19992
19993        for (i, handle) in handles.into_iter().enumerate() {
19994            let (token_len, qt_len) = handle.join().expect("Thread panicked");
19995            assert!(token_len > 0, "Query {} should produce tokens", i);
19996            assert!(qt_len > 0, "Query {} QueryTermsLower should have tokens", i);
19997        }
19998    }
19999
20000    #[test]
20001    fn stress_many_quoted_phrases() {
20002        // 50 quoted phrases
20003        let phrases: Vec<String> = (0..50)
20004            .map(|i| format!("\"phrase number {}\"", i))
20005            .collect();
20006        let query = phrases.join(" AND ");
20007
20008        let start = std::time::Instant::now();
20009        let tokens = parse_boolean_query(&query);
20010        let elapsed = start.elapsed();
20011
20012        assert!(
20013            elapsed < std::time::Duration::from_secs(1),
20014            "50 quoted phrases took {:?} (>1s)",
20015            elapsed
20016        );
20017
20018        let phrase_count = tokens
20019            .iter()
20020            .filter(|t| matches!(t, QueryToken::Phrase(_)))
20021            .count();
20022        assert_eq!(phrase_count, 50, "Should have 50 phrases");
20023    }
20024
20025    #[test]
20026    fn stress_alternating_quotes() {
20027        // Alternating quoted and unquoted: "a" b "c" d "e" ...
20028        let parts: Vec<String> = (0..100)
20029            .map(|i| {
20030                if i % 2 == 0 {
20031                    format!("\"word{}\"", i)
20032                } else {
20033                    format!("word{}", i)
20034                }
20035            })
20036            .collect();
20037        let query = parts.join(" ");
20038
20039        let start = std::time::Instant::now();
20040        let tokens = parse_boolean_query(&query);
20041        let elapsed = start.elapsed();
20042
20043        assert!(
20044            elapsed < std::time::Duration::from_secs(1),
20045            "100 alternating quotes took {:?} (>1s)",
20046            elapsed
20047        );
20048
20049        let phrase_count = tokens
20050            .iter()
20051            .filter(|t| matches!(t, QueryToken::Phrase(_)))
20052            .count();
20053        let term_count = tokens
20054            .iter()
20055            .filter(|t| matches!(t, QueryToken::Term(_)))
20056            .count();
20057
20058        assert_eq!(phrase_count, 50, "Should have 50 phrases");
20059        assert_eq!(term_count, 50, "Should have 50 terms");
20060    }
20061
20062    #[test]
20063    fn stress_many_wildcards() {
20064        // Many wildcard patterns
20065        let patterns: Vec<&str> = vec!["pre*", "*suf", "*sub*", "a*b", "test*", "*ing", "*tion*"];
20066        let query = patterns
20067            .iter()
20068            .cycle()
20069            .take(100)
20070            .cloned()
20071            .collect::<Vec<_>>()
20072            .join(" ");
20073
20074        let start = std::time::Instant::now();
20075        let sanitized = sanitize_query(&query);
20076        let tokens = parse_boolean_query(&sanitized);
20077        let elapsed = start.elapsed();
20078
20079        assert!(
20080            elapsed < std::time::Duration::from_secs(1),
20081            "100 wildcards took {:?} (>1s)",
20082            elapsed
20083        );
20084        assert!(!tokens.is_empty());
20085    }
20086
20087    #[test]
20088    fn stress_query_explanation_large_query() {
20089        // Test QueryExplanation with a large query
20090        let words: Vec<String> = (0..100).map(|i| format!("term{}", i)).collect();
20091        let query = words.join(" ");
20092        let filters = SearchFilters::default();
20093
20094        let start = std::time::Instant::now();
20095        let explanation = QueryExplanation::analyze(&query, &filters);
20096        let elapsed = start.elapsed();
20097
20098        assert!(
20099            elapsed < std::time::Duration::from_secs(2),
20100            "QueryExplanation for 100 terms took {:?} (>2s)",
20101            elapsed
20102        );
20103        assert!(
20104            !explanation.parsed.terms.is_empty(),
20105            "Should parse terms successfully"
20106        );
20107    }
20108
20109    #[test]
20110    fn stress_very_long_single_quoted_phrase() {
20111        // Single quoted phrase with many words
20112        let words: Vec<String> = (0..500).map(|i| format!("word{}", i)).collect();
20113        let phrase = format!("\"{}\"", words.join(" "));
20114
20115        let start = std::time::Instant::now();
20116        let tokens = parse_boolean_query(&phrase);
20117        let elapsed = start.elapsed();
20118
20119        assert!(
20120            elapsed < std::time::Duration::from_secs(1),
20121            "500-word phrase took {:?} (>1s)",
20122            elapsed
20123        );
20124
20125        let phrase_count = tokens
20126            .iter()
20127            .filter(|t| matches!(t, QueryToken::Phrase(_)))
20128            .count();
20129        assert_eq!(phrase_count, 1, "Should have exactly 1 phrase");
20130    }
20131
20132    #[test]
20133    fn stress_not_prefix_many() {
20134        // Many NOT prefixes: -a -b -c -d ...
20135        let terms: Vec<String> = (0..100).map(|i| format!("-term{}", i)).collect();
20136        let query = terms.join(" ");
20137
20138        let start = std::time::Instant::now();
20139        let tokens = parse_boolean_query(&query);
20140        let elapsed = start.elapsed();
20141
20142        assert!(
20143            elapsed < std::time::Duration::from_secs(1),
20144            "100 NOT prefixes took {:?} (>1s)",
20145            elapsed
20146        );
20147
20148        let not_count = tokens
20149            .iter()
20150            .filter(|t| matches!(t, QueryToken::Not))
20151            .count();
20152        assert_eq!(not_count, 100, "Should have 100 NOT operators");
20153    }
20154
20155    #[test]
20156    fn stress_unicode_large_cjk_query() {
20157        // Large CJK query (each char is alphanumeric)
20158        let cjk_chars = "中文日本語한국어".repeat(1000);
20159
20160        let start = std::time::Instant::now();
20161        let sanitized = sanitize_query(&cjk_chars);
20162        let qt = QueryTermsLower::from_query(&sanitized);
20163        let elapsed = start.elapsed();
20164
20165        assert!(
20166            elapsed < std::time::Duration::from_secs(1),
20167            "Large CJK query took {:?} (>1s)",
20168            elapsed
20169        );
20170        assert!(!qt.is_empty(), "CJK query should produce tokens");
20171    }
20172
20173    #[test]
20174    fn stress_unicode_many_emoji() {
20175        // Query with many emoji (non-alphanumeric, become spaces)
20176        let emoji_query = "🚀 🔍 📝 💻 🎯 ".repeat(500);
20177
20178        let start = std::time::Instant::now();
20179        let sanitized = sanitize_query(&emoji_query);
20180        let tokens = parse_boolean_query(&sanitized);
20181        let elapsed = start.elapsed();
20182
20183        assert!(
20184            elapsed < std::time::Duration::from_secs(1),
20185            "Emoji query took {:?} (>1s)",
20186            elapsed
20187        );
20188        // Emoji are stripped, leaving empty
20189        assert!(
20190            tokens.is_empty(),
20191            "Emoji-only query should produce no tokens"
20192        );
20193    }
20194
20195    #[test]
20196    fn stress_mixed_content_large() {
20197        // Mixed content: code, prose, symbols, unicode
20198        let mixed = r#"
20199            function test() { return x + y; }
20200            SELECT * FROM users WHERE id = 1;
20201            The quick brown fox 狐狸 jumps over lazy dog
20202            Error: "undefined is not a function" at line 42
20203            https://example.com/path?query=value&other=123
20204        "#
20205        .repeat(100);
20206
20207        let start = std::time::Instant::now();
20208        let sanitized = sanitize_query(&mixed);
20209        let tokens = parse_boolean_query(&sanitized);
20210        let qt = QueryTermsLower::from_query(&mixed);
20211        let elapsed = start.elapsed();
20212
20213        assert!(
20214            elapsed < std::time::Duration::from_secs(2),
20215            "Mixed content query took {:?} (>2s)",
20216            elapsed
20217        );
20218        assert!(!tokens.is_empty());
20219        assert!(!qt.is_empty());
20220    }
20221
20222    // ==========================================================================
20223    // Query Parser Unit Tests (br-335y) - Unicode, Special Chars, Edge Cases
20224    // ==========================================================================
20225
20226    // --- Unicode queries with emoji in terms ---
20227
20228    #[test]
20229    fn unicode_emoji_mixed_with_alphanumeric() {
20230        // Emoji surrounded by alphanumeric text
20231        let tokens = parse_boolean_query("rocket🚀launch");
20232        assert_eq!(tokens.len(), 1);
20233        // sanitize_query strips emoji (non-alphanumeric), so this becomes "rocket launch"
20234        let sanitized = sanitize_query("rocket🚀launch");
20235        assert_eq!(sanitized, "rocket launch");
20236
20237        // Multiple emoji between words
20238        let sanitized2 = sanitize_query("test🔥🎯code");
20239        assert_eq!(sanitized2, "test  code");
20240    }
20241
20242    #[test]
20243    fn unicode_emoji_with_boolean_operators() {
20244        // AND/OR/NOT with queries containing emoji
20245        let tokens = parse_boolean_query("🚀code AND test");
20246        // After parsing, we should have 3 tokens (emoji becomes space/empty)
20247        let term_count = tokens
20248            .iter()
20249            .filter(|t| matches!(t, QueryToken::Term(_)))
20250            .count();
20251        assert!(term_count >= 1, "Should have at least one term");
20252
20253        // OR with emoji
20254        let tokens_or = parse_boolean_query("deploy OR 🎯target");
20255        let has_or = tokens_or.iter().any(|t| matches!(t, QueryToken::Or));
20256        assert!(has_or, "Should detect OR operator");
20257    }
20258
20259    #[test]
20260    fn unicode_emoji_at_word_boundaries() {
20261        // Emoji at start of query
20262        let sanitized_start = sanitize_query("🔍search");
20263        assert_eq!(sanitized_start, " search");
20264
20265        // Emoji at end of query
20266        let sanitized_end = sanitize_query("complete✅");
20267        assert_eq!(sanitized_end, "complete ");
20268
20269        // Only emoji - becomes empty
20270        let sanitized_only = sanitize_query("🎉🎊🎁");
20271        assert!(
20272            sanitized_only.trim().is_empty(),
20273            "Emoji-only should be empty after trimming"
20274        );
20275    }
20276
20277    // --- RTL (Right-to-Left) text: Arabic and Hebrew ---
20278
20279    #[test]
20280    fn unicode_arabic_text_preserved() {
20281        // Arabic text should be preserved as alphanumeric
20282        let arabic = "مرحبا بالعالم"; // "Hello World" in Arabic
20283        let sanitized = sanitize_query(arabic);
20284        assert_eq!(
20285            sanitized, arabic,
20286            "Arabic alphanumeric chars should be preserved"
20287        );
20288
20289        let tokens = parse_boolean_query(arabic);
20290        assert!(!tokens.is_empty(), "Arabic query should produce tokens");
20291    }
20292
20293    #[test]
20294    fn unicode_hebrew_text_preserved() {
20295        // Hebrew text should be preserved
20296        let hebrew = "שלום עולם"; // "Hello World" in Hebrew
20297        let sanitized = sanitize_query(hebrew);
20298        assert_eq!(
20299            sanitized, hebrew,
20300            "Hebrew alphanumeric chars should be preserved"
20301        );
20302
20303        let tokens = parse_boolean_query(hebrew);
20304        assert!(!tokens.is_empty(), "Hebrew query should produce tokens");
20305    }
20306
20307    #[test]
20308    fn unicode_mixed_rtl_and_ltr() {
20309        // Mixed RTL (Arabic) and LTR (English) text
20310        let mixed = "hello مرحبا world";
20311        let sanitized = sanitize_query(mixed);
20312        assert_eq!(sanitized, mixed, "Mixed RTL/LTR should be preserved");
20313
20314        let tokens = parse_boolean_query(mixed);
20315        let term_count = tokens
20316            .iter()
20317            .filter(|t| matches!(t, QueryToken::Term(_)))
20318            .count();
20319        assert_eq!(term_count, 3, "Should have 3 terms");
20320    }
20321
20322    #[test]
20323    fn unicode_rtl_with_boolean_operators() {
20324        // Hebrew with AND operator
20325        let hebrew_and = "שלום AND עולם";
20326        let tokens = parse_boolean_query(hebrew_and);
20327        let has_and = tokens.iter().any(|t| matches!(t, QueryToken::And));
20328        assert!(has_and, "Should detect AND operator in Hebrew query");
20329
20330        // Arabic with NOT operator
20331        let arabic_not = "مرحبا NOT بالعالم";
20332        let tokens_not = parse_boolean_query(arabic_not);
20333        let has_not = tokens_not.iter().any(|t| matches!(t, QueryToken::Not));
20334        assert!(has_not, "Should detect NOT operator in Arabic query");
20335    }
20336
20337    // --- Backslash handling ---
20338
20339    #[test]
20340    fn special_chars_backslash_stripped() {
20341        // Backslash is not alphanumeric, so it becomes space
20342        let query = r"path\to\file";
20343        let sanitized = sanitize_query(query);
20344        assert_eq!(sanitized, "path to file");
20345    }
20346
20347    #[test]
20348    fn special_chars_escaped_quotes_handling() {
20349        // Backslash before quote - backslash stripped, quote preserved
20350        let query = r#"say \"hello\""#;
20351        let sanitized = sanitize_query(query);
20352        // Backslash becomes space, quotes preserved
20353        assert!(sanitized.contains('"'), "Quotes should be preserved");
20354    }
20355
20356    #[test]
20357    fn special_chars_windows_paths() {
20358        // Windows-style paths with backslashes
20359        let path = r"C:\Users\test\Documents";
20360        let sanitized = sanitize_query(path);
20361        assert_eq!(sanitized, "C  Users test Documents");
20362    }
20363
20364    // --- Nested/Complex boolean operators ---
20365
20366    #[test]
20367    fn boolean_deeply_nested_operators() {
20368        // Complex nested expression (parser treats this as linear)
20369        let query = "a AND b OR c NOT d AND e";
20370        let tokens = parse_boolean_query(query);
20371
20372        let mut and_count = 0;
20373        let mut or_count = 0;
20374        let mut not_count = 0;
20375        for token in &tokens {
20376            match token {
20377                QueryToken::And => and_count += 1,
20378                QueryToken::Or => or_count += 1,
20379                QueryToken::Not => not_count += 1,
20380                _ => {}
20381            }
20382        }
20383
20384        assert_eq!(and_count, 2, "Should have 2 AND operators");
20385        assert_eq!(or_count, 1, "Should have 1 OR operator");
20386        assert_eq!(not_count, 1, "Should have 1 NOT operator");
20387    }
20388
20389    #[test]
20390    fn boolean_consecutive_operators_degenerate() {
20391        // Consecutive operators: "AND AND" - second AND becomes a term
20392        let tokens = parse_boolean_query("foo AND AND bar");
20393        // "AND" as the final part of "AND AND" is treated as operator, then next "bar" is term
20394        let term_count = tokens
20395            .iter()
20396            .filter(|t| matches!(t, QueryToken::Term(_)))
20397            .count();
20398        assert!(
20399            term_count >= 2,
20400            "Should have at least 2 terms (foo and bar)"
20401        );
20402    }
20403
20404    #[test]
20405    fn boolean_operator_at_start() {
20406        // Operator at start of query
20407        let tokens = parse_boolean_query("AND foo");
20408        let has_and = tokens.iter().any(|t| matches!(t, QueryToken::And));
20409        assert!(has_and, "Leading AND should be detected");
20410
20411        let tokens_or = parse_boolean_query("OR test");
20412        let has_or = tokens_or.iter().any(|t| matches!(t, QueryToken::Or));
20413        assert!(has_or, "Leading OR should be detected");
20414    }
20415
20416    #[test]
20417    fn boolean_operator_at_end() {
20418        // Operator at end of query
20419        let tokens = parse_boolean_query("foo AND");
20420        let has_and = tokens.iter().any(|t| matches!(t, QueryToken::And));
20421        assert!(has_and, "Trailing AND should be detected");
20422    }
20423
20424    // --- Numeric-only queries ---
20425
20426    #[test]
20427    fn numeric_query_digits_only() {
20428        // Query with only digits
20429        let tokens = parse_boolean_query("12345");
20430        assert_eq!(tokens.len(), 1);
20431        assert_eq!(tokens[0], QueryToken::Term("12345".to_string()));
20432
20433        let sanitized = sanitize_query("12345");
20434        assert_eq!(sanitized, "12345");
20435    }
20436
20437    #[test]
20438    fn numeric_query_with_text() {
20439        // Mixed numeric and text
20440        let tokens = parse_boolean_query("error 404 not found");
20441        let term_count = tokens
20442            .iter()
20443            .filter(|t| matches!(t, QueryToken::Term(_)))
20444            .count();
20445        // "404", "error", "found" are terms, "not" is NOT operator
20446        assert!(term_count >= 3, "Should have at least 3 terms");
20447    }
20448
20449    #[test]
20450    fn numeric_versions_with_dots() {
20451        // Version numbers like "1.2.3"
20452        let sanitized = sanitize_query("version 1.2.3");
20453        assert_eq!(sanitized, "version 1 2 3"); // dots become spaces
20454    }
20455
20456    // --- Tab and newline handling ---
20457
20458    #[test]
20459    fn whitespace_tabs_treated_as_separators() {
20460        let tokens = parse_boolean_query("foo\tbar\tbaz");
20461        let term_count = tokens
20462            .iter()
20463            .filter(|t| matches!(t, QueryToken::Term(_)))
20464            .count();
20465        assert_eq!(term_count, 3, "Tabs should separate terms");
20466    }
20467
20468    #[test]
20469    fn whitespace_newlines_treated_as_separators() {
20470        let tokens = parse_boolean_query("foo\nbar\nbaz");
20471        let term_count = tokens
20472            .iter()
20473            .filter(|t| matches!(t, QueryToken::Term(_)))
20474            .count();
20475        assert_eq!(term_count, 3, "Newlines should separate terms");
20476    }
20477
20478    #[test]
20479    fn whitespace_mixed_types() {
20480        let tokens = parse_boolean_query("a \t b \n c   d");
20481        let term_count = tokens
20482            .iter()
20483            .filter(|t| matches!(t, QueryToken::Term(_)))
20484            .count();
20485        assert_eq!(term_count, 4, "Mixed whitespace should separate properly");
20486    }
20487
20488    // --- Very long single terms (no spaces) ---
20489
20490    #[test]
20491    fn stress_very_long_single_term() {
20492        // Single term with 10K characters (no spaces)
20493        let long_term = "a".repeat(10_000);
20494
20495        let start = std::time::Instant::now();
20496        let tokens = parse_boolean_query(&long_term);
20497        let elapsed = start.elapsed();
20498
20499        assert!(
20500            elapsed < std::time::Duration::from_secs(1),
20501            "10K char term took {:?} (>1s)",
20502            elapsed
20503        );
20504        assert_eq!(tokens.len(), 1);
20505        assert!(
20506            matches!(tokens.first(), Some(QueryToken::Term(t)) if t.len() == 10_000),
20507            "Expected 10K Term token, got {tokens:?}"
20508        );
20509    }
20510
20511    #[test]
20512    fn stress_very_long_term_with_wildcard() {
20513        // Long term with wildcard suffix
20514        let long_pattern = format!("{}*", "prefix".repeat(1000));
20515
20516        let start = std::time::Instant::now();
20517        let sanitized = sanitize_query(&long_pattern);
20518        let pattern = WildcardPattern::parse(&sanitized);
20519        let elapsed = start.elapsed();
20520
20521        assert!(
20522            elapsed < std::time::Duration::from_secs(1),
20523            "Long wildcard pattern took {:?} (>1s)",
20524            elapsed
20525        );
20526        assert!(
20527            matches!(pattern, WildcardPattern::Prefix(_)),
20528            "Should parse as prefix pattern"
20529        );
20530    }
20531
20532    // --- QueryExplanation edge cases ---
20533
20534    #[test]
20535    fn query_explanation_empty_query() {
20536        let explanation = QueryExplanation::analyze("", &SearchFilters::default());
20537        assert_eq!(explanation.query_type, QueryType::Empty);
20538    }
20539
20540    #[test]
20541    fn search_mode_default_is_hybrid_preferred() {
20542        assert_eq!(SearchMode::default(), SearchMode::Hybrid);
20543    }
20544
20545    #[test]
20546    fn query_explanation_whitespace_only_query() {
20547        let explanation = QueryExplanation::analyze("   \t\n  ", &SearchFilters::default());
20548        assert_eq!(explanation.query_type, QueryType::Empty);
20549    }
20550
20551    #[test]
20552    fn query_explanation_unicode_query() {
20553        let explanation = QueryExplanation::analyze("日本語 search", &SearchFilters::default());
20554        // Should classify as Simple (no operators, multiple terms = implicit AND)
20555        assert!(!explanation.parsed.terms.is_empty());
20556    }
20557
20558    // --- QueryTermsLower edge cases ---
20559
20560    #[test]
20561    fn query_terms_lower_unicode_normalization() {
20562        // Accented characters should be lowercased properly
20563        let terms = QueryTermsLower::from_query("CAFÉ RÉSUMÉ");
20564        assert_eq!(terms.query_lower, "café résumé");
20565    }
20566
20567    #[test]
20568    fn query_terms_lower_mixed_case_unicode() {
20569        // Mixed case CJK and Latin
20570        let terms = QueryTermsLower::from_query("Hello日本語World");
20571        // CJK chars have no case, Latin chars should be lowercased
20572        assert!(terms.query_lower.contains("hello"));
20573        assert!(terms.query_lower.contains("world"));
20574    }
20575
20576    #[test]
20577    fn query_terms_lower_preserves_numbers() {
20578        let terms = QueryTermsLower::from_query("ABC123XYZ");
20579        assert_eq!(terms.query_lower, "abc123xyz");
20580    }
20581
20582    // --- WildcardPattern edge cases ---
20583
20584    #[test]
20585    fn wildcard_pattern_internal_asterisk() {
20586        // Internal wildcard: f*o
20587        let pattern = WildcardPattern::parse("f*o");
20588        assert!(
20589            matches!(pattern, WildcardPattern::Complex(_)),
20590            "Internal asterisk should be Complex"
20591        );
20592    }
20593
20594    #[test]
20595    fn wildcard_pattern_multiple_internal_asterisks() {
20596        // Multiple internal wildcards: a*b*c
20597        let pattern = WildcardPattern::parse("a*b*c");
20598        assert!(
20599            matches!(pattern, WildcardPattern::Complex(_)),
20600            "Multiple internal asterisks should be Complex"
20601        );
20602    }
20603
20604    #[test]
20605    fn wildcard_pattern_regex_escapes_special_chars() {
20606        // Pattern with regex-special characters
20607        let pattern = WildcardPattern::parse("*foo.bar*");
20608        if let Some(regex) = pattern.to_regex() {
20609            assert!(
20610                regex.contains("\\."),
20611                "Dot should be escaped in regex: {}",
20612                regex
20613            );
20614        }
20615    }
20616
20617    #[test]
20618    fn wildcard_pattern_complex_regex_generation() {
20619        let pattern = WildcardPattern::parse("f*o*o");
20620        if let Some(regex) = pattern.to_regex() {
20621            // Should handle internal wildcards
20622            assert!(
20623                regex.contains(".*"),
20624                "Should have .* for internal wildcards: {}",
20625                regex
20626            );
20627        }
20628    }
20629
20630    #[test]
20631    fn test_transpile_to_fts5() {
20632        // Simple terms
20633        assert_eq!(
20634            transpile_to_fts5("foo bar"),
20635            Some("foo AND bar".to_string())
20636        );
20637
20638        // Boolean operators
20639        assert_eq!(
20640            transpile_to_fts5("foo AND bar"),
20641            Some("foo AND bar".to_string())
20642        );
20643        assert_eq!(
20644            transpile_to_fts5("foo OR bar"),
20645            Some("(foo OR bar)".to_string())
20646        );
20647        assert_eq!(transpile_to_fts5("OR foo"), Some("foo".to_string()));
20648        assert_eq!(transpile_to_fts5("NOT foo"), None);
20649
20650        // Precedence: OR binds tighter than AND in our parser logic
20651        // "A AND B OR C" -> "A AND (B OR C)"
20652        assert_eq!(
20653            transpile_to_fts5("A AND B OR C"),
20654            Some("A AND (B OR C)".to_string())
20655        );
20656
20657        // "A OR B AND C" -> "(A OR B) AND C"
20658        assert_eq!(
20659            transpile_to_fts5("A OR B AND C"),
20660            Some("(A OR B) AND C".to_string())
20661        );
20662
20663        // "A OR B OR C" -> "(A OR B OR C)"
20664        assert_eq!(
20665            transpile_to_fts5("A OR B OR C"),
20666            Some("(A OR B OR C)".to_string())
20667        );
20668
20669        // Phrases
20670        assert_eq!(
20671            transpile_to_fts5("\"foo bar\""),
20672            Some("\"foo bar\"".to_string())
20673        );
20674
20675        // Wildcards (allowed trailing)
20676        assert_eq!(transpile_to_fts5("foo*"), Some("foo*".to_string()));
20677
20678        // Unsupported wildcards (leading/internal)
20679        assert_eq!(transpile_to_fts5("*foo"), None);
20680        assert_eq!(transpile_to_fts5("f*o"), None);
20681
20682        // SQLite FTS5's porter tokenizer splits punctuation into separate
20683        // fragments, so fallback queries must do the same.
20684        assert_eq!(
20685            transpile_to_fts5("foo-bar"),
20686            Some("(foo AND bar)".to_string())
20687        );
20688        assert_eq!(
20689            transpile_to_fts5("foo-bar*"),
20690            Some("(foo AND bar*)".to_string())
20691        );
20692        assert_eq!(
20693            transpile_to_fts5("br-123.jsonl"),
20694            Some("(br AND 123 AND jsonl)".to_string())
20695        );
20696        assert_eq!(
20697            transpile_to_fts5("br-123.json*"),
20698            Some("(br AND 123 AND json*)".to_string())
20699        );
20700
20701        // Leading unary-NOT forms are not valid FTS5 queries.
20702        assert_eq!(transpile_to_fts5("NOT A OR B"), None);
20703    }
20704
20705    #[test]
20706    fn semantic_doc_id_roundtrip_from_query() {
20707        let hash_hex = "00".repeat(32);
20708        let doc_id = format!("m|42|2|3|7|11|1|1700000000000|{hash_hex}");
20709        let parsed = parse_semantic_doc_id(&doc_id).expect("roundtrip parse");
20710        assert_eq!(parsed.message_id, 42);
20711        assert_eq!(parsed.chunk_idx, 2);
20712        assert_eq!(parsed.agent_id, 3);
20713        assert_eq!(parsed.workspace_id, 7);
20714        assert_eq!(parsed.source_id, 11);
20715        assert_eq!(parsed.role, 1);
20716        assert_eq!(parsed.created_at_ms, 1_700_000_000_000);
20717    }
20718
20719    #[test]
20720    fn semantic_filter_applies_all_constraints() {
20721        use frankensearch::core::filter::SearchFilter;
20722
20723        let filter = SemanticFilter {
20724            agents: Some(HashSet::from([3])),
20725            workspaces: Some(HashSet::from([7])),
20726            sources: Some(HashSet::from([11])),
20727            roles: Some(HashSet::from([1])),
20728            created_from: Some(1_700_000_000_000),
20729            created_to: Some(1_700_000_000_100),
20730        };
20731
20732        assert!(filter.matches("m|42|2|3|7|11|1|1700000000001", None));
20733        assert!(!filter.matches("m|42|2|99|7|11|1|1700000000001", None));
20734        assert!(!filter.matches("m|42|2|3|7|11|1|1699999999999", None));
20735        assert!(!filter.matches("not-a-doc-id", None));
20736    }
20737
20738    #[test]
20739    fn fs_semantic_index_runs_filtered_search() -> Result<()> {
20740        let temp = TempDir::new()?;
20741        let index_path = crate::search::vector_index::vector_index_path(temp.path(), "embed-fast");
20742        if let Some(parent) = index_path.parent() {
20743            std::fs::create_dir_all(parent)?;
20744        }
20745
20746        let hash_a = "00".repeat(32);
20747        let hash_b = "11".repeat(32);
20748        let doc_a = format!("m|101|0|1|10|100|1|1700000000001|{hash_a}");
20749        let doc_b = format!("m|202|0|2|20|200|1|1700000000002|{hash_b}");
20750
20751        let mut writer = VectorIndex::create_with_revision(
20752            &index_path,
20753            "embed-fast",
20754            "rev-1",
20755            2,
20756            frankensearch::index::Quantization::F16,
20757        )
20758        .map_err(|err| anyhow!("create fsvi index failed: {err}"))?;
20759        writer
20760            .write_record(&doc_a, &[1.0, 0.0])
20761            .map_err(|err| anyhow!("write_record failed: {err}"))?;
20762        writer
20763            .write_record(&doc_b, &[0.0, 1.0])
20764            .map_err(|err| anyhow!("write_record failed: {err}"))?;
20765        writer
20766            .finish()
20767            .map_err(|err| anyhow!("finish fsvi index failed: {err}"))?;
20768
20769        let fs_index =
20770            VectorIndex::open(&index_path).map_err(|err| anyhow!("open fsvi failed: {err}"))?;
20771        let filter = SemanticFilter {
20772            agents: Some(HashSet::from([1])),
20773            workspaces: None,
20774            sources: None,
20775            roles: None,
20776            created_from: None,
20777            created_to: None,
20778        };
20779        let fs_filter = semantic_filter_as_search_filter(&filter).expect("expected active filter");
20780        let hits = fs_index
20781            .search_top_k(&[1.0, 0.0], 5, Some(fs_filter))
20782            .map_err(|err| anyhow!("frankensearch search failed: {err}"))?;
20783        assert_eq!(hits.len(), 1);
20784        let parsed = parse_semantic_doc_id(&hits[0].doc_id).expect("parse bridged doc_id");
20785        assert_eq!(parsed.message_id, 101);
20786        assert_eq!(parsed.agent_id, 1);
20787        Ok(())
20788    }
20789
20790    // Regression guard for bead coding_agent_session_search-q6xf9
20791    // (`cass search --fields minimal` silently returned zero hits even when
20792    // matches existed). Root cause: the dedup pass called `hit_is_noise`,
20793    // which fell through to `is_search_noise_text("")` when both `content`
20794    // and `snippet` were stripped by the field_mask — treating every
20795    // projection-only hit as tool/acknowledgement noise and dropping it.
20796    //
20797    // Fix: when both fields are empty because the caller explicitly
20798    // requested a minimal projection, we cannot classify noise from text
20799    // alone. Default to "not noise" and let the hit through so downstream
20800    // field filtering emits the requested subset.
20801    #[test]
20802    fn hit_is_noise_returns_false_when_content_and_snippet_both_empty() {
20803        let hit = SearchHit {
20804            title: String::new(),
20805            snippet: String::new(),
20806            content: String::new(),
20807            content_hash: 0,
20808            conversation_id: Some(1),
20809            score: 1.0,
20810            source_path: "/tmp/session.jsonl".to_string(),
20811            agent: "codex".to_string(),
20812            workspace: String::new(),
20813            workspace_original: None,
20814            created_at: Some(1700000000000),
20815            line_number: Some(1),
20816            match_type: MatchType::Exact,
20817            source_id: "local".to_string(),
20818            origin_kind: "local".to_string(),
20819            origin_host: None,
20820        };
20821
20822        // Query text doesn't matter — the point is that a hit stripped of
20823        // content+snippet by --fields minimal must survive the noise filter
20824        // so `cass search --fields minimal` returns the projection.
20825        assert!(
20826            !hit_is_noise(&hit, "anything"),
20827            "hit with empty content AND snippet (projection-only) must NOT be classified as noise"
20828        );
20829        assert!(
20830            !hit_is_noise(&hit, ""),
20831            "noise classifier must not treat an empty-query projection-only hit as noise"
20832        );
20833    }
20834
20835    // Complementary guard: make sure the noise filter still flags legitimate
20836    // empty rows (no content_hash, etc.) when the content is actually empty
20837    // because the underlying message was empty — we don't want this fix to
20838    // re-introduce tool-ack noise into projection-full outputs.
20839    #[test]
20840    fn hit_is_noise_still_drops_tool_acknowledgement_when_content_present() {
20841        let hit = SearchHit {
20842            title: String::new(),
20843            snippet: String::new(),
20844            content: "ok".to_string(),
20845            content_hash: 0,
20846            conversation_id: Some(1),
20847            score: 1.0,
20848            source_path: "/tmp/session.jsonl".to_string(),
20849            agent: "codex".to_string(),
20850            workspace: String::new(),
20851            workspace_original: None,
20852            created_at: Some(1700000000000),
20853            line_number: Some(1),
20854            match_type: MatchType::Exact,
20855            source_id: "local".to_string(),
20856            origin_kind: "local".to_string(),
20857            origin_host: None,
20858        };
20859
20860        assert!(
20861            hit_is_noise(&hit, ""),
20862            "bare tool-ack 'ok' with content present should still be dropped as noise"
20863        );
20864    }
20865}