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, Occur, Query, ReloadPolicy,
8    Searcher, SnippetConfig as FsSnippetConfig, TantivyDocument, Term, TermQuery, TopDocs, Value,
9    cass_build_tantivy_query as fs_cass_build_tantivy_query,
10    cass_has_boolean_operators as fs_cass_has_boolean_operators,
11    cass_open_search_reader as fs_cass_open_search_reader,
12    cass_parse_boolean_query as fs_cass_parse_boolean_query,
13    cass_sanitize_query as fs_cass_sanitize_query, load_doc as fs_load_doc,
14    render_snippet_html as fs_render_snippet_html,
15    try_build_snippet_generator as fs_try_build_snippet_generator,
16};
17use frankensearch::{
18    Cx as FsCx, InMemoryTwoTierIndex as FsInMemoryTwoTierIndex,
19    InMemoryVectorIndex as FsInMemoryVectorIndex, LexicalSearch as FsLexicalSearch,
20    QueryClass as FsQueryClass, RrfConfig as FsRrfConfig, ScoreSource as FsScoreSource,
21    ScoredResult as FsScoredResult, SearchError as FsSearchError, SearchFuture as FsSearchFuture,
22    SearchPhase as FsSearchPhase, SyncEmbedderAdapter as FsSyncEmbedderAdapter,
23    SyncTwoTierSearcher as FsSyncTwoTierSearcher, TwoTierConfig as FsTwoTierConfig,
24    TwoTierIndex as FsTwoTierIndex, TwoTierSearcher as FsTwoTierSearcher, VectorHit as FsVectorHit,
25    candidate_count as fs_candidate_count,
26    core::filter::SearchFilter as FsSearchFilter,
27    index::{
28        HNSW_DEFAULT_EF_SEARCH as FS_HNSW_DEFAULT_EF_SEARCH, HnswIndex as FsHnswIndex,
29        VectorIndex as FsVectorIndex,
30    },
31    rrf_fuse as fs_rrf_fuse,
32};
33use lru::LruCache;
34use once_cell::sync::Lazy;
35use parking_lot::RwLock;
36use std::cell::RefCell;
37use std::cmp::Ordering as CmpOrdering;
38use std::collections::{HashMap, HashSet, VecDeque};
39use std::hash::{Hash, Hasher};
40use std::num::NonZeroUsize;
41use std::path::{Path, PathBuf};
42use std::sync::atomic::{AtomicU64, Ordering};
43use std::sync::{Arc, Mutex};
44use std::time::{Duration, Instant};
45
46use frankensqlite::Connection;
47#[cfg(test)]
48use frankensqlite::compat::OptionalExtension;
49use frankensqlite::compat::{ConnectionExt, ParamValue, RowExt};
50#[cfg(test)]
51use frankensqlite::params;
52
53/// Wrapper around `frankensqlite::Connection` that implements `Send`.
54///
55/// `frankensqlite::Connection` is `!Send` because it uses `Rc` internally.
56/// However, the `Rc` values are entirely self-contained within the Connection
57/// and are not shared with any external references.  When wrapped in a `Mutex`
58/// (as in `SearchClient`), exclusive access is guaranteed, making cross-thread
59/// transfer safe.
60struct SendConnection(Connection);
61
62type TantivyContentExactKey = (i64, i64);
63type TantivyContentFallbackKey = (String, String, i64);
64type TantivyHydratedContentMaps = (
65    HashMap<TantivyContentExactKey, String>,
66    HashMap<TantivyContentFallbackKey, String>,
67);
68type SqliteFtsHydratedRow = (
69    i64,
70    Option<i64>,
71    Option<String>,
72    Option<String>,
73    Option<String>,
74    Option<String>,
75    Option<String>,
76    Option<i64>,
77);
78type SqliteFtsMessageRow = (
79    i64,
80    String,
81    String,
82    String,
83    String,
84    String,
85    Option<i64>,
86    Option<i64>,
87    Option<i64>,
88    Option<String>,
89    Option<String>,
90    Option<String>,
91);
92type SqliteMessageScanAlternative = Vec<String>;
93type SqliteMessageScanGroup = Vec<SqliteMessageScanAlternative>;
94struct SqliteMessageScanQuery {
95    include_groups: Vec<SqliteMessageScanGroup>,
96    exclude_terms: Vec<String>,
97}
98
99#[derive(Clone, Copy)]
100struct SqliteMessageScanRequest<'a> {
101    raw_query: &'a str,
102    filters: &'a SearchFilters,
103    limit: usize,
104    offset: usize,
105    field_mask: FieldMask,
106    query_match_type: MatchType,
107}
108
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110enum SqliteFtsMatchMode {
111    Table,
112    IndexedColumns,
113}
114
115// Frankensqlite follows SQLite's bind-variable ceiling. Keep fallback
116// hydration IN-lists below that ceiling so large pages do not turn into
117// empty fallback result sets.
118const SQLITE_FTS5_HYDRATE_PARAM_CHUNK: usize = 30_000;
119const SQLITE_MAX_VARIABLE_NUMBER: usize = 32_766;
120const SQLITE_FTS5_POST_FILTER_SCAN_CHUNK: usize = 1_024;
121const SQLITE_FTS5_POST_FILTER_SCAN_LIMIT: usize = 30_000;
122const SQLITE_MESSAGE_SCAN_FALLBACK_LIMIT: usize = 30_000;
123const SEARCH_SQLITE_HYDRATION_CACHE_KIB: i64 = 4_096;
124const SEMANTIC_EXACT_CHUNK_OVERFETCH_MULTIPLIER: usize = 4;
125
126// Safety: Rc fields inside Connection are not cloned or shared externally.
127// The Mutex<Option<SendConnection>> in SearchClient ensures exclusive access.
128unsafe impl Send for SendConnection {}
129
130impl std::ops::Deref for SendConnection {
131    type Target = Connection;
132    fn deref(&self) -> &Connection {
133        &self.0
134    }
135}
136
137fn open_search_hydration_sqlite(path: &Path, timeout: Duration) -> Result<Connection> {
138    let conn =
139        crate::storage::sqlite::open_franken_raw_readonly_connection_with_timeout(path, timeout)?;
140    conn.execute("PRAGMA query_only = 1;")
141        .with_context(|| "setting search hydration query_only")?;
142    conn.execute("PRAGMA busy_timeout = 5000;")
143        .with_context(|| "setting search hydration busy_timeout")?;
144    conn.execute(&format!(
145        "PRAGMA cache_size = -{SEARCH_SQLITE_HYDRATION_CACHE_KIB};"
146    ))
147    .with_context(|| "setting search hydration cache_size")?;
148    Ok(conn)
149}
150
151/// NFC-normalize a query string before sanitization so that decomposed
152/// Unicode (NFD — common on macOS keyboard input) matches NFC-indexed content
153/// produced by `DefaultCanonicalizer`.
154fn nfc_sanitize_query(raw: &str) -> String {
155    use unicode_normalization::UnicodeNormalization;
156    let nfc: String = raw.nfc().collect();
157    fs_cass_sanitize_query(&nfc)
158}
159
160fn franken_query_map_collect_retry<T, F>(
161    conn: &Connection,
162    sql: &str,
163    params: &[ParamValue],
164    map: F,
165) -> Result<Vec<T>, frankensqlite::FrankenError>
166where
167    F: Copy + Fn(&frankensqlite::Row) -> Result<T, frankensqlite::FrankenError>,
168{
169    let deadline = Instant::now() + Duration::from_secs(2);
170    let mut backoff = Duration::from_millis(4);
171    loop {
172        match conn.query_map_collect(sql, params, |row| map(row)) {
173            Ok(values) => return Ok(values),
174            Err(err) if crate::storage::sqlite::retryable_franken_error(&err) => {
175                let now = Instant::now();
176                if now >= deadline {
177                    return Err(err);
178                }
179                let remaining = deadline.saturating_duration_since(now);
180                crate::storage::sqlite::sleep_with_franken_retry_backoff(
181                    &mut backoff,
182                    remaining,
183                    Duration::from_millis(64),
184                );
185            }
186            Err(err) => return Err(err),
187        }
188    }
189}
190
191fn hydrate_message_content_by_conversation(
192    conn: &Connection,
193    requests: &[TantivyContentExactKey],
194) -> Result<HashMap<TantivyContentExactKey, String>> {
195    if requests.is_empty() {
196        return Ok(HashMap::new());
197    }
198
199    let mut wanted_by_conversation: HashMap<i64, HashSet<i64>> = HashMap::new();
200    for &(conversation_id, line_idx) in requests {
201        wanted_by_conversation
202            .entry(conversation_id)
203            .or_default()
204            .insert(line_idx);
205    }
206
207    let mut conversation_ids = wanted_by_conversation.keys().copied().collect::<Vec<_>>();
208    conversation_ids.sort_unstable();
209    let mut hydrated = HashMap::with_capacity(requests.len());
210
211    for conversation_id in conversation_ids {
212        let Some(wanted_indices) = wanted_by_conversation.get(&conversation_id) else {
213            continue;
214        };
215        let mut wanted_indices = wanted_indices.iter().copied().collect::<Vec<_>>();
216        wanted_indices.sort_unstable();
217        let placeholders = sql_placeholders(wanted_indices.len());
218        let sql = format!(
219            "SELECT m.conversation_id, m.idx, m.content
220             FROM messages m INDEXED BY sqlite_autoindex_messages_1
221             WHERE m.conversation_id = ? AND m.idx IN ({placeholders})
222             ORDER BY m.idx"
223        );
224        let mut params = Vec::with_capacity(wanted_indices.len() + 1);
225        params.push(ParamValue::from(conversation_id));
226        params.extend(wanted_indices.iter().copied().map(ParamValue::from));
227        let rows: Vec<(i64, i64, String)> =
228            franken_query_map_collect_retry(conn, &sql, &params, |row| {
229                Ok((row.get_typed(0)?, row.get_typed(1)?, row.get_typed(2)?))
230            })?;
231        for (conversation_id, line_idx, content) in rows {
232            hydrated.insert((conversation_id, line_idx), content);
233        }
234    }
235
236    Ok(hydrated)
237}
238
239fn semantic_message_id_from_db(message_id: i64) -> std::io::Result<u64> {
240    u64::try_from(message_id).map_err(|_| std::io::Error::other("negative message_id"))
241}
242
243fn semantic_doc_component_id_from_db(raw: Option<i64>) -> u32 {
244    raw.map(|value| u32::try_from(value.max(0)).unwrap_or(u32::MAX))
245        .unwrap_or(0)
246}
247
248use crate::search::canonicalize::{canonicalize_for_embedding, content_hash, is_search_noise_text};
249use crate::search::embedder::Embedder;
250use crate::search::vector_index::{
251    ROLE_USER, SemanticDocId, SemanticFilter, SemanticFilterMaps, VectorIndex, VectorSearchResult,
252    parse_semantic_doc_id, role_code_from_str,
253};
254use crate::sources::provenance::SourceFilter;
255
256// ============================================================================
257// String Interner for Cache Keys (Opt 2.3)
258// ============================================================================
259//
260// Reduces memory usage and allocation overhead for repeated cache key patterns.
261// Uses LRU eviction to bound memory, Arc<str> for cheap cloning.
262
263/// Thread-safe string interner with bounded memory via LRU eviction.
264/// Uses LruCache<Arc<str>, Arc<str>> where key and value are the same Arc,
265/// enabling O(1) lookup via Borrow<str> trait while preserving LRU semantics.
266pub struct StringInterner {
267    cache: RwLock<LruCache<Arc<str>, Arc<str>>>,
268}
269
270impl StringInterner {
271    /// Create a new interner with the given capacity.
272    pub fn new(capacity: usize) -> Self {
273        Self {
274            cache: RwLock::new(LruCache::new(
275                NonZeroUsize::new(capacity).expect("capacity must be > 0"),
276            )),
277        }
278    }
279
280    /// Intern a string, returning a shared Arc<str>.
281    /// If the string is already interned, returns the existing Arc.
282    /// Otherwise, creates a new Arc and caches it.
283    ///
284    /// Performance: O(1) lookup via LruCache's internal HashMap.
285    pub fn intern(&self, s: &str) -> Arc<str> {
286        // Fast path: read-only check for existing entry (O(1) lookup)
287        {
288            let cache = self.cache.read();
289            // LruCache::peek allows O(1) lookup without updating LRU order
290            // Arc<str>: Borrow<str> enables lookup by &str
291            if let Some(arc) = cache.peek(s) {
292                return Arc::clone(arc);
293            }
294        }
295
296        // Slow path: acquire write lock and insert
297        let mut cache = self.cache.write();
298
299        // Double-check after acquiring write lock (another thread may have inserted)
300        // Use get() here to update LRU order since we're about to use this entry
301        if let Some(arc) = cache.get(s) {
302            return Arc::clone(arc);
303        }
304
305        // Create new Arc<str> and insert (same Arc as key and value)
306        let arc: Arc<str> = Arc::from(s);
307        cache.put(Arc::clone(&arc), Arc::clone(&arc));
308        arc
309    }
310
311    /// Get the current number of interned strings.
312    #[allow(dead_code)]
313    pub fn len(&self) -> usize {
314        self.cache.read().len()
315    }
316
317    /// Check if the interner is empty.
318    #[allow(dead_code)]
319    pub fn is_empty(&self) -> bool {
320        self.cache.read().is_empty()
321    }
322}
323
324/// Global cache key interner with 10K entry limit (~1MB for typical keys).
325/// Uses Lazy initialization for thread-safe singleton.
326static CACHE_KEY_INTERNER: Lazy<StringInterner> = Lazy::new(|| StringInterner::new(10_000));
327
328/// Intern a cache key string, returning a shared Arc<str>.
329#[inline]
330fn intern_cache_key(s: &str) -> Arc<str> {
331    CACHE_KEY_INTERNER.intern(s)
332}
333
334// ============================================================================
335// SQL Placeholder Builder (Opt 4.5: Pre-sized String Buffers)
336// ============================================================================
337
338/// Build a comma-separated list of SQL placeholders with pre-allocated capacity.
339///
340/// For `n` items, produces "?,?,?..." (n "?" with n-1 ",").
341/// Uses pre-sized String to avoid reallocations.
342///
343/// # Examples
344/// ```ignore
345/// assert_eq!(sql_placeholders(0), "");
346/// assert_eq!(sql_placeholders(1), "?");
347/// assert_eq!(sql_placeholders(3), "?,?,?");
348/// ```
349#[inline]
350pub fn sql_placeholders(count: usize) -> String {
351    if count == 0 {
352        return String::new();
353    }
354    // Capacity: n "?" + (n-1) "," = 2n - 1
355    let capacity = count.saturating_mul(2).saturating_sub(1);
356    let mut result = String::with_capacity(capacity);
357    for i in 0..count {
358        if i > 0 {
359            result.push(',');
360        }
361        result.push('?');
362    }
363    result
364}
365
366#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
367pub struct SearchFilters {
368    pub agents: HashSet<String>,
369    pub workspaces: HashSet<String>,
370    pub created_from: Option<i64>,
371    pub created_to: Option<i64>,
372    /// Filter by conversation source (local, remote, or specific source ID)
373    #[serde(skip_serializing_if = "SourceFilter::is_all")]
374    pub source_filter: SourceFilter,
375    /// Filter to specific session source paths (for chained searches)
376    #[serde(skip_serializing_if = "HashSet::is_empty")]
377    pub session_paths: HashSet<String>,
378}
379
380#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, clap::ValueEnum)]
381#[serde(rename_all = "snake_case")]
382pub enum SearchMode {
383    /// Lexical (BM25) search - keyword matching
384    Lexical,
385    /// Semantic search - embedding similarity
386    Semantic,
387    /// Hybrid-preferred search - RRF fusion of lexical and semantic when available
388    #[default]
389    Hybrid,
390}
391
392impl SearchMode {
393    pub fn next(self) -> Self {
394        match self {
395            SearchMode::Lexical => SearchMode::Semantic,
396            SearchMode::Semantic => SearchMode::Hybrid,
397            SearchMode::Hybrid => SearchMode::Lexical,
398        }
399    }
400}
401
402/// Execution strategy for semantic search.
403///
404/// `Single` preserves existing exact vector behavior.
405/// Other modes attempt to use frankensearch's sync two-tier searcher when a
406/// compatible in-memory two-tier index is available; otherwise they fall back
407/// to `Single`.
408#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
409#[serde(rename_all = "snake_case")]
410pub enum SemanticTierMode {
411    #[default]
412    Single,
413    Progressive,
414    FastOnly,
415    QualityOnly,
416}
417
418impl SemanticTierMode {
419    const fn wants_two_tier(self) -> bool {
420        !matches!(self, Self::Single)
421    }
422
423    fn to_frankensearch_config(self) -> FsTwoTierConfig {
424        let mut config = frankensearch_two_tier_config();
425        match self {
426            Self::Single | Self::Progressive => {}
427            Self::FastOnly => {
428                config.fast_only = true;
429            }
430            Self::QualityOnly => {
431                config.fast_only = false;
432                config.quality_weight = 1.0;
433            }
434        }
435        config
436    }
437}
438
439const PROGRESSIVE_EMBEDDING_CACHE_CAPACITY: usize = 64;
440const ANN_CANDIDATE_MULTIPLIER: usize = 4;
441const HYBRID_NO_LIMIT_PLANNING_WINDOW: usize = 64;
442const HYBRID_NO_LIMIT_SEMANTIC_CAP: usize = 2048;
443const AUTOMATIC_WILDCARD_FALLBACK_MAX_TOKEN_CHARS: usize = 16;
444
445/// Upper bound on how many documents a `limit == 0` ("no limit") search is
446/// allowed to materialize. Each `SearchHit` carries the full message
447/// `content` string (roughly 80 KB p99 in real corpora), so an unlimited
448/// search on a ~500k-row user history can easily allocate tens of
449/// gigabytes of heap AND drive sustained multi-GB/s reads off the Tantivy
450/// `.store` file and SQLite rows, crushing the whole machine.
451///
452/// The cap is computed dynamically from `/proc/meminfo` `MemAvailable`
453/// (Linux) so a dev box with 512 GB of RAM is allowed to return ~200k
454/// rows while a 2 GB laptop stops at the floor. The cap translates
455/// directly into an upper bound on disk-I/O per query because the
456/// per-hit hydration loop in `fs_load_doc()` / `hydrate_tantivy_hit_contents`
457/// does ~11 `.store` field reads per hit plus up to one SQLite row
458/// fetch — bounding hits bounds bytes read.
459///
460/// Override with `CASS_SEARCH_NO_LIMIT_CAP=<hits>` or
461/// `CASS_SEARCH_NO_LIMIT_BYTES=<bytes>`. Both overrides are still
462/// clamped to `[NO_LIMIT_RESULT_MIN, NO_LIMIT_RESULT_MAX]` on the way
463/// out — an unclamped override would re-open the same "crush the
464/// machine" hole this cap exists to close.
465pub const NO_LIMIT_RESULT_MIN: usize = 1_000;
466pub const NO_LIMIT_RESULT_MAX: usize = 1_000_000;
467
468/// Approximate on-heap size per `SearchHit` used to translate a
469/// memory budget into a hit-count cap. Kept conservatively high
470/// (p99-ish message content + metadata strings) so real workloads
471/// stay well under the computed bytes budget.
472const AVG_HIT_BYTES: u64 = 80 * 1024;
473
474/// Absolute ceiling on the memory budget for a single "no limit"
475/// search, regardless of how much RAM is free. 16 GiB keeps sustained
476/// disk reads on a single query bounded to <10 s on a 2 GB/s NVMe —
477/// long enough for a power user to wait, short enough not to block
478/// other workloads on a shared box.
479const NO_LIMIT_BYTES_CEILING: u64 = 16 * 1024 * 1024 * 1024;
480
481/// Floor on the memory budget. On a 2 GB laptop we still let a
482/// single "no limit" query use ~256 MiB — small enough to survive,
483/// large enough to be useful.
484const NO_LIMIT_BYTES_FLOOR: u64 = 256 * 1024 * 1024;
485
486/// Fraction of `MemAvailable` we're willing to spend on a single
487/// "no limit" search response. 1/16 leaves 93% of RAM for everything
488/// else on the box.
489const NO_LIMIT_RAM_DIVISOR: u64 = 16;
490
491/// Above this corpus size, exact Tantivy `Count` collection is not part of the
492/// default top-N path. Common-term counts on multi-million-document indexes can
493/// dominate the query and turn a five-hit search into a full corpus scan; robot
494/// output already reports lower-bound count precision when the exact total is
495/// not available.
496const DEFAULT_EXACT_TOTAL_COUNT_MAX_DOCS: usize = 50_000;
497const DEFAULT_AUTOMATIC_WILDCARD_FALLBACK_MAX_DOCS: usize = 10_000;
498
499fn exact_total_count_max_docs() -> usize {
500    static MAX_DOCS: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
501    *MAX_DOCS.get_or_init(|| {
502        dotenvy::var("CASS_SEARCH_EXACT_TOTAL_COUNT_MAX_DOCS")
503            .ok()
504            .and_then(|value| value.parse::<usize>().ok())
505            .unwrap_or(DEFAULT_EXACT_TOTAL_COUNT_MAX_DOCS)
506    })
507}
508
509fn should_collect_exact_total_count(
510    index_doc_count: usize,
511    max_docs_for_exact_count: usize,
512) -> bool {
513    max_docs_for_exact_count > 0 && index_doc_count <= max_docs_for_exact_count
514}
515
516fn automatic_wildcard_fallback_max_docs() -> usize {
517    static MAX_DOCS: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
518    *MAX_DOCS.get_or_init(|| {
519        dotenvy::var("CASS_AUTOMATIC_WILDCARD_FALLBACK_MAX_DOCS")
520            .ok()
521            .and_then(|value| value.parse::<usize>().ok())
522            .unwrap_or(DEFAULT_AUTOMATIC_WILDCARD_FALLBACK_MAX_DOCS)
523    })
524}
525
526fn should_allow_automatic_wildcard_fallback(
527    index_doc_count: usize,
528    max_docs_for_automatic_wildcard: usize,
529) -> bool {
530    max_docs_for_automatic_wildcard > 0 && index_doc_count <= max_docs_for_automatic_wildcard
531}
532
533fn available_memory_bytes() -> Option<u64> {
534    let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
535    for line in meminfo.lines() {
536        if let Some(rest) = line.strip_prefix("MemAvailable:") {
537            let kb: u64 = rest.split_whitespace().next()?.parse().ok()?;
538            return Some(kb.saturating_mul(1024));
539        }
540    }
541    None
542}
543
544fn no_limit_result_cap() -> usize {
545    static CAP: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
546    *CAP.get_or_init(|| {
547        compute_no_limit_result_cap_from(
548            dotenvy::var("CASS_SEARCH_NO_LIMIT_CAP").ok(),
549            dotenvy::var("CASS_SEARCH_NO_LIMIT_BYTES").ok(),
550            available_memory_bytes(),
551        )
552    })
553}
554
555/// Pure version of the cap-computation, with env + `/proc/meminfo`
556/// passed in as arguments. Kept pure so unit tests can drive it
557/// deterministically without mutating the process-global env (which
558/// would race with every other parallel test that reads env, including
559/// the search-query pipeline tests that transitively hit
560/// `no_limit_result_cap()`).
561fn compute_no_limit_result_cap_from(
562    cap_env: Option<String>,
563    bytes_env: Option<String>,
564    available_bytes: Option<u64>,
565) -> usize {
566    // Explicit hit-count override takes priority, but is still clamped
567    // to `[MIN, MAX]` so a typo like `CASS_SEARCH_NO_LIMIT_CAP=10000000000`
568    // can't reopen the unbounded-result bug this cap closes.
569    if let Some(hits) = cap_env
570        .and_then(|v| v.parse::<usize>().ok())
571        .filter(|v| *v > 0)
572    {
573        return hits.clamp(NO_LIMIT_RESULT_MIN, NO_LIMIT_RESULT_MAX);
574    }
575
576    let budget_bytes = no_limit_budget_bytes(bytes_env, available_bytes);
577    let hits = (budget_bytes / AVG_HIT_BYTES) as usize;
578    hits.clamp(NO_LIMIT_RESULT_MIN, NO_LIMIT_RESULT_MAX)
579}
580
581fn no_limit_budget_bytes(bytes_env: Option<String>, available_bytes: Option<u64>) -> u64 {
582    bytes_env
583        .and_then(|v| v.parse::<u64>().ok())
584        .filter(|v| *v > 0)
585        .or_else(|| no_limit_available_memory_budget(available_bytes))
586        .unwrap_or(NO_LIMIT_BYTES_FLOOR)
587}
588
589fn no_limit_available_memory_budget(available_bytes: Option<u64>) -> Option<u64> {
590    available_bytes.map(|avail| {
591        (avail / NO_LIMIT_RAM_DIVISOR).clamp(NO_LIMIT_BYTES_FLOOR, NO_LIMIT_BYTES_CEILING)
592    })
593}
594
595static FRANKENSEARCH_TWO_TIER_CONFIG: Lazy<FsTwoTierConfig> =
596    Lazy::new(|| FsTwoTierConfig::optimized().with_env_overrides());
597
598fn frankensearch_two_tier_config() -> FsTwoTierConfig {
599    FRANKENSEARCH_TWO_TIER_CONFIG.clone()
600}
601
602#[inline]
603const fn progressive_phase_fetch_limit(limit: usize) -> usize {
604    let limit = if limit == 0 { 1 } else { limit };
605    limit.saturating_mul(3)
606}
607
608#[derive(Debug, Clone, Copy, PartialEq, Eq)]
609struct HybridCandidateBudget {
610    lexical_candidates: usize,
611    semantic_candidates: usize,
612}
613
614#[inline]
615const fn hybrid_stage_multipliers(query_class: FsQueryClass) -> (usize, usize) {
616    match query_class {
617        // Identifier-heavy queries: prioritize lexical precision.
618        FsQueryClass::Identifier => (6, 2),
619        // Keyword queries: balanced lexical/semantic retrieval.
620        FsQueryClass::ShortKeyword => (4, 4),
621        // Natural language queries: prioritize semantic retrieval.
622        FsQueryClass::NaturalLanguage => (2, 8),
623        // Empty query should short-circuit before budgeting.
624        FsQueryClass::Empty => (0, 0),
625    }
626}
627
628#[inline]
629fn hybrid_candidate_budget(
630    query: &str,
631    requested_limit: usize,
632    effective_limit: usize,
633    offset: usize,
634    total_docs: usize,
635) -> HybridCandidateBudget {
636    let query_class = FsQueryClass::classify(query);
637    let (lex_mult, sem_mult) = hybrid_stage_multipliers(query_class);
638    let total_docs = total_docs.max(1);
639
640    // When no explicit limit is requested, keep "no limit" output semantics,
641    // but bound semantic fanout so hybrid doesn't try to score the entire corpus.
642    if requested_limit == 0 {
643        let planning_window = HYBRID_NO_LIMIT_PLANNING_WINDOW.max(offset.saturating_add(1));
644        // Cap the lexical fanout — without a ceiling a "no limit" hybrid
645        // query on a ~500k-row corpus asks Tantivy to materialize a
646        // `Vec<SearchHit>` the size of the entire index, which is the
647        // unboundedness fixed by `no_limit_result_cap()`.
648        let lexical = effective_limit.min(total_docs).min(no_limit_result_cap());
649        // Semantic fan-out can be wide in principle, but must never
650        // exceed the lexical cap — the pipeline fuses lexical+semantic
651        // candidates and returning more semantic candidates than
652        // lexical is both wasteful (semantic is the expensive tier)
653        // and breaks the pre-cap invariant that `semantic ≤ lexical`.
654        // On tiny boxes where `no_limit_result_cap()` hits the floor,
655        // this pulls semantic down with it.
656        let semantic = fs_candidate_count(planning_window, 0, sem_mult)
657            .max(planning_window)
658            .min(HYBRID_NO_LIMIT_SEMANTIC_CAP.max(offset.saturating_add(planning_window)))
659            .min(total_docs)
660            .min(lexical);
661        return HybridCandidateBudget {
662            lexical_candidates: lexical,
663            semantic_candidates: semantic,
664        };
665    }
666
667    let lexical = fs_candidate_count(requested_limit, offset, lex_mult.max(1))
668        .max(requested_limit.saturating_add(offset))
669        .min(total_docs);
670    let semantic = fs_candidate_count(requested_limit, offset, sem_mult.max(1))
671        .max(requested_limit.saturating_add(offset))
672        .min(total_docs);
673
674    HybridCandidateBudget {
675        lexical_candidates: lexical,
676        semantic_candidates: semantic,
677    }
678}
679
680// ============================================================================
681// Query Explanation types (--explain flag support)
682// ============================================================================
683
684/// Classification of query type for explanation purposes
685#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
686#[serde(rename_all = "snake_case")]
687pub enum QueryType {
688    /// Single term without operators
689    Simple,
690    /// Quoted phrase ("exact match")
691    Phrase,
692    /// Contains AND/OR/NOT operators
693    Boolean,
694    /// Contains wildcards (* prefix/suffix)
695    Wildcard,
696    /// Has time/agent/workspace filters
697    Filtered,
698    /// Empty query
699    Empty,
700}
701
702/// How the index will execute this query
703#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
704#[serde(rename_all = "snake_case")]
705pub enum IndexStrategy {
706    /// Fast path: edge n-gram prefix matching
707    EdgeNgram,
708    /// Regex scan for leading wildcards (*foo)
709    RegexScan,
710    /// Combined boolean query execution
711    BooleanCombination,
712    /// Range scan for time filters
713    RangeScan,
714    /// All documents (empty query)
715    FullScan,
716}
717
718/// Rough complexity indicator for query execution
719#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
720#[serde(rename_all = "snake_case")]
721pub enum QueryCost {
722    /// Very fast (under 10ms typical)
723    Low,
724    /// Moderate (10-100ms typical)
725    Medium,
726    /// Expensive (100ms+ typical, may scan many documents)
727    High,
728}
729
730/// Sub-component of a parsed term
731#[derive(Debug, Clone, serde::Serialize)]
732pub struct ParsedSubTerm {
733    pub text: String,
734    pub pattern: String,
735}
736
737/// Parsed term from the query
738#[derive(Debug, Clone, serde::Serialize)]
739pub struct ParsedTerm {
740    /// Original term text
741    pub text: String,
742    /// Whether this is negated (NOT/-)
743    pub negated: bool,
744    /// Sub-terms if split (implicit AND)
745    pub subterms: Vec<ParsedSubTerm>,
746}
747
748/// Parsed structure of the query
749#[derive(Debug, Clone, Default, serde::Serialize)]
750pub struct ParsedQuery {
751    /// Individual terms extracted
752    pub terms: Vec<ParsedTerm>,
753    /// Phrases (quoted strings)
754    pub phrases: Vec<String>,
755    /// Boolean operators used
756    pub operators: Vec<String>,
757    /// Whether implicit AND is used between terms
758    pub implicit_and: bool,
759}
760
761/// Comprehensive query explanation for debugging and understanding search behavior
762#[derive(Debug, Clone, serde::Serialize)]
763pub struct QueryExplanation {
764    /// Exact input string
765    pub original_query: String,
766    /// Sanitized query after normalization
767    pub sanitized_query: String,
768    /// Structured breakdown of query components
769    pub parsed: ParsedQuery,
770    /// High-level classification
771    pub query_type: QueryType,
772    /// How the index will execute this query
773    pub index_strategy: IndexStrategy,
774    /// Whether wildcard fallback was/will be applied
775    pub wildcard_applied: bool,
776    /// Rough complexity indicator
777    pub estimated_cost: QueryCost,
778    /// Active filters summary
779    pub filters_summary: FiltersSummary,
780    /// Any issues or suggestions
781    pub warnings: Vec<String>,
782}
783
784/// Summary of active filters for explanation
785#[derive(Debug, Clone, Default, serde::Serialize)]
786pub struct FiltersSummary {
787    /// Number of agent filters
788    pub agent_count: usize,
789    /// Number of workspace filters
790    pub workspace_count: usize,
791    /// Whether time range is applied
792    pub has_time_filter: bool,
793    /// Human-readable filter description
794    pub description: Option<String>,
795}
796
797impl QueryExplanation {
798    /// Build explanation from query string and filters
799    pub fn analyze(query: &str, filters: &SearchFilters) -> Self {
800        let sanitized = nfc_sanitize_query(query);
801        // Parse original query to preserve quotes for phrases
802        let tokens = fs_cass_parse_boolean_query(query);
803
804        // Extract terms, phrases, and operators
805        let mut parsed = ParsedQuery::default();
806        let mut has_explicit_operator = false;
807        let mut next_negated = false;
808
809        for token in &tokens {
810            match token {
811                FsCassQueryToken::Term(t) => {
812                    let parts: Vec<String> = nfc_sanitize_query(t)
813                        .split_whitespace()
814                        .map(|s| s.to_string())
815                        .collect();
816                    if parts.is_empty() {
817                        next_negated = false;
818                        continue;
819                    }
820                    let mut subterms = Vec::new();
821                    for part in parts {
822                        let pattern = FsCassWildcardPattern::parse(&part);
823                        let pattern_str = match &pattern {
824                            FsCassWildcardPattern::Exact(_) => "exact",
825                            FsCassWildcardPattern::Prefix(_) => "prefix (*)",
826                            FsCassWildcardPattern::Suffix(_) => "suffix (*)",
827                            FsCassWildcardPattern::Substring(_) => "substring (*)",
828                            FsCassWildcardPattern::Complex(_) => "complex (*)",
829                        };
830                        subterms.push(ParsedSubTerm {
831                            text: part,
832                            pattern: pattern_str.to_string(),
833                        });
834                    }
835                    parsed.terms.push(ParsedTerm {
836                        text: t.clone(),
837                        negated: next_negated,
838                        subterms,
839                    });
840                    next_negated = false;
841                }
842                FsCassQueryToken::Phrase(p) => {
843                    let parts: Vec<String> = nfc_sanitize_query(p)
844                        .split_whitespace()
845                        .map(|s| s.trim_matches('*').to_lowercase())
846                        .filter(|s| !s.is_empty())
847                        .collect();
848                    if !parts.is_empty() {
849                        parsed.phrases.push(parts.join(" "));
850                    }
851                    next_negated = false;
852                }
853                FsCassQueryToken::And => {
854                    parsed.operators.push("AND".to_string());
855                    has_explicit_operator = true;
856                }
857                FsCassQueryToken::Or => {
858                    parsed.operators.push("OR".to_string());
859                    has_explicit_operator = true;
860                }
861                FsCassQueryToken::Not => {
862                    parsed.operators.push("NOT".to_string());
863                    has_explicit_operator = true;
864                    next_negated = true;
865                }
866            }
867        }
868
869        // Implicit AND between terms if no explicit operators
870        parsed.implicit_and = !has_explicit_operator && parsed.terms.len() > 1;
871
872        // Determine query type
873        let query_type = Self::classify_query(&parsed, filters, &sanitized);
874
875        // Determine index strategy
876        let index_strategy = Self::determine_strategy(&parsed, &sanitized);
877
878        // Estimate cost
879        let estimated_cost = Self::estimate_cost(&parsed, &index_strategy, filters);
880
881        // Build filters summary
882        let filters_summary = Self::summarize_filters(filters);
883
884        // Generate warnings
885        let warnings = Self::generate_warnings(&parsed, &sanitized, filters);
886
887        Self {
888            original_query: query.to_string(),
889            sanitized_query: sanitized,
890            parsed,
891            query_type,
892            index_strategy,
893            wildcard_applied: false, // Set later by search_with_fallback
894            estimated_cost,
895            filters_summary,
896            warnings,
897        }
898    }
899
900    fn classify_query(parsed: &ParsedQuery, filters: &SearchFilters, sanitized: &str) -> QueryType {
901        if sanitized.trim().is_empty() {
902            return QueryType::Empty;
903        }
904
905        // Check for filters first (they modify everything)
906        let has_filters = !filters.agents.is_empty()
907            || !filters.workspaces.is_empty()
908            || filters.created_from.is_some()
909            || filters.created_to.is_some()
910            || !filters.source_filter.is_all();
911
912        if has_filters {
913            return QueryType::Filtered;
914        }
915
916        // Check for boolean operators
917        if !parsed.operators.is_empty() {
918            return QueryType::Boolean;
919        }
920
921        // Check for phrases
922        if !parsed.phrases.is_empty() {
923            return QueryType::Phrase;
924        }
925
926        // Check for wildcards
927        let has_wildcards = parsed
928            .terms
929            .iter()
930            .flat_map(|t| &t.subterms)
931            .any(|t| t.pattern != "exact");
932        if has_wildcards {
933            return QueryType::Wildcard;
934        }
935
936        QueryType::Simple
937    }
938
939    fn determine_strategy(parsed: &ParsedQuery, sanitized: &str) -> IndexStrategy {
940        if sanitized.trim().is_empty() {
941            return IndexStrategy::FullScan;
942        }
943
944        // Check for leading wildcards (requires regex)
945        let has_leading_wildcard = parsed
946            .terms
947            .iter()
948            .flat_map(|t| &t.subterms)
949            .any(|t| t.pattern == "suffix (*)" || t.pattern == "substring (*)");
950
951        if has_leading_wildcard {
952            return IndexStrategy::RegexScan;
953        }
954
955        // Boolean queries use combination strategy
956        // Also if any single term is split into multiple subterms (e.g. "foo.bar" -> "foo", "bar")
957        let has_compound_terms = parsed.terms.iter().any(|t| t.subterms.len() > 1);
958
959        if !parsed.operators.is_empty()
960            || parsed.terms.len() > 1
961            || !parsed.phrases.is_empty()
962            || has_compound_terms
963        {
964            return IndexStrategy::BooleanCombination;
965        }
966
967        // Single term uses edge n-gram
968        IndexStrategy::EdgeNgram
969    }
970
971    fn estimate_cost(
972        parsed: &ParsedQuery,
973        strategy: &IndexStrategy,
974        filters: &SearchFilters,
975    ) -> QueryCost {
976        // Regex scans are always expensive
977        if matches!(strategy, IndexStrategy::RegexScan) {
978            return QueryCost::High;
979        }
980
981        // Full scans are expensive
982        if matches!(strategy, IndexStrategy::FullScan) {
983            return QueryCost::High;
984        }
985
986        // Time range filters add cost
987        let has_time_filter = filters.created_from.is_some() || filters.created_to.is_some();
988
989        // Count complexity factors
990        let term_count: usize = parsed.terms.iter().map(|t| t.subterms.len()).sum();
991        let operator_count = parsed.operators.len();
992        let phrase_count = parsed.phrases.len();
993
994        let complexity = term_count + operator_count * 2 + phrase_count * 2;
995
996        if complexity > 6 || has_time_filter {
997            QueryCost::High
998        } else if complexity > 2 {
999            QueryCost::Medium
1000        } else {
1001            QueryCost::Low
1002        }
1003    }
1004
1005    fn summarize_filters(filters: &SearchFilters) -> FiltersSummary {
1006        let agent_count = filters.agents.len();
1007        let workspace_count = filters.workspaces.len();
1008        let has_time_filter = filters.created_from.is_some() || filters.created_to.is_some();
1009
1010        let mut parts = Vec::new();
1011        if agent_count > 0 {
1012            parts.push(format!(
1013                "{} agent{}",
1014                agent_count,
1015                if agent_count > 1 { "s" } else { "" }
1016            ));
1017        }
1018        if workspace_count > 0 {
1019            parts.push(format!(
1020                "{} workspace{}",
1021                workspace_count,
1022                if workspace_count > 1 { "s" } else { "" }
1023            ));
1024        }
1025        if has_time_filter {
1026            parts.push("time range".to_string());
1027        }
1028
1029        let description = if parts.is_empty() {
1030            None
1031        } else {
1032            Some(format!("Filtering by: {}", parts.join(", ")))
1033        };
1034
1035        FiltersSummary {
1036            agent_count,
1037            workspace_count,
1038            has_time_filter,
1039            description,
1040        }
1041    }
1042
1043    fn generate_warnings(
1044        parsed: &ParsedQuery,
1045        sanitized: &str,
1046        filters: &SearchFilters,
1047    ) -> Vec<String> {
1048        let mut warnings = Vec::new();
1049
1050        // Warn about leading wildcards
1051        let has_leading_wildcard = parsed
1052            .terms
1053            .iter()
1054            .flat_map(|t| &t.subterms)
1055            .any(|t| t.pattern == "suffix (*)" || t.pattern == "substring (*)");
1056        if has_leading_wildcard {
1057            warnings.push(
1058                "Leading wildcards (*foo) require regex scan and may be slow on large indexes"
1059                    .to_string(),
1060            );
1061        }
1062
1063        // Warn about very short terms
1064        for term in &parsed.terms {
1065            for sub in &term.subterms {
1066                if sub.text.trim_matches('*').len() < 2 {
1067                    warnings.push(format!(
1068                        "Very short term '{}' may match many documents",
1069                        sub.text
1070                    ));
1071                }
1072            }
1073        }
1074
1075        // Warn about empty query
1076        if sanitized.trim().is_empty() {
1077            warnings.push("Empty query will return all documents (expensive)".to_string());
1078        }
1079
1080        // Warn about complex boolean queries
1081        if parsed.operators.len() > 3 {
1082            warnings.push("Complex boolean query may have unexpected precedence".to_string());
1083        }
1084
1085        // Warn about narrow filters that might miss results
1086        if let Some(agent) = filters.agents.iter().next()
1087            && filters.agents.len() == 1
1088            && filters.workspaces.is_empty()
1089        {
1090            warnings.push(format!(
1091                "Searching only in agent '{}' - results from other agents will be excluded",
1092                agent
1093            ));
1094        }
1095
1096        warnings
1097    }
1098
1099    /// Update `wildcard_applied` flag (called after `search_with_fallback`)
1100    pub fn with_wildcard_fallback(mut self, applied: bool) -> Self {
1101        self.wildcard_applied = applied;
1102        if applied
1103            && !self
1104                .warnings
1105                .iter()
1106                .any(|w| w.contains("wildcard fallback"))
1107        {
1108            self.warnings.push(
1109                "Wildcard fallback was applied automatically due to sparse exact matches"
1110                    .to_string(),
1111            );
1112        }
1113        self
1114    }
1115}
1116
1117/// Indicates how a search result matched the query.
1118/// Used for ranking: exact matches rank higher than wildcard matches.
1119#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
1120#[serde(rename_all = "snake_case")]
1121pub enum MatchType {
1122    /// No wildcards - matched via exact term or edge n-gram prefix
1123    #[default]
1124    Exact,
1125    /// Matched via trailing wildcard (foo*)
1126    Prefix,
1127    /// Matched via leading wildcard (*foo) - uses regex
1128    Suffix,
1129    /// Matched via both wildcards (*foo*) - uses regex
1130    Substring,
1131    /// Matched via complex wildcard (e.g. f*o) - uses regex
1132    Wildcard,
1133    /// Matched via automatic wildcard fallback when exact search was sparse
1134    ImplicitWildcard,
1135}
1136
1137impl MatchType {
1138    /// Returns a quality factor for ranking (1.0 = best, lower = less precise match)
1139    pub fn quality_factor(self) -> f32 {
1140        match self {
1141            MatchType::Exact => 1.0,
1142            MatchType::Prefix => 0.9,
1143            MatchType::Suffix => 0.8,
1144            MatchType::Substring => 0.7,
1145            MatchType::Wildcard => 0.65,
1146            MatchType::ImplicitWildcard => 0.6,
1147        }
1148    }
1149}
1150
1151/// Type of suggestion for did-you-mean
1152#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
1153#[serde(rename_all = "snake_case")]
1154pub enum SuggestionKind {
1155    /// Typo correction (Levenshtein distance)
1156    SpellingFix,
1157    /// Try with wildcard prefix/suffix
1158    WildcardQuery,
1159    /// Remove restrictive filter
1160    RemoveFilter,
1161    /// Try different agent
1162    AlternateAgent,
1163    /// Broaden date range
1164    BroaderDateRange,
1165}
1166
1167/// A "did-you-mean" suggestion when search returns zero hits.
1168#[derive(Debug, Clone, serde::Serialize)]
1169pub struct QuerySuggestion {
1170    /// What kind of suggestion this is
1171    pub kind: SuggestionKind,
1172    /// Human-readable description (e.g., "Did you mean: 'codex'?")
1173    pub message: String,
1174    /// The suggested query string (if query change)
1175    pub suggested_query: Option<String>,
1176    /// Suggested filters to apply (replaces current filters if Some)
1177    pub suggested_filters: Option<SearchFilters>,
1178    /// Shortcut key (1, 2, or 3) for quick apply in TUI
1179    pub shortcut: Option<u8>,
1180}
1181
1182impl QuerySuggestion {
1183    fn spelling(_query: &str, corrected: &str) -> Self {
1184        Self {
1185            kind: SuggestionKind::SpellingFix,
1186            message: format!("Did you mean: \"{corrected}\"?"),
1187            suggested_query: Some(corrected.to_string()),
1188            suggested_filters: None,
1189            shortcut: None,
1190        }
1191    }
1192
1193    fn wildcard(query: &str) -> Self {
1194        let wildcard_query = format!("*{}*", query.trim_matches('*'));
1195        Self {
1196            kind: SuggestionKind::WildcardQuery,
1197            message: format!("Try broader search: \"{wildcard_query}\""),
1198            suggested_query: Some(wildcard_query),
1199            suggested_filters: None,
1200            shortcut: None,
1201        }
1202    }
1203
1204    fn remove_agent_filter(current_agent: &str, current_filters: &SearchFilters) -> Self {
1205        // Clone current filters and only clear the agent filter, preserving
1206        // workspace and date range filters
1207        let mut filters = current_filters.clone();
1208        filters.agents.clear();
1209        Self {
1210            kind: SuggestionKind::RemoveFilter,
1211            message: format!("Remove agent filter (currently: {current_agent})"),
1212            suggested_query: None,
1213            suggested_filters: Some(filters),
1214            shortcut: None,
1215        }
1216    }
1217
1218    fn try_agent(agent_slug: &str) -> Self {
1219        let mut filters = SearchFilters::default();
1220        filters.agents.insert(agent_slug.to_string());
1221        Self {
1222            kind: SuggestionKind::AlternateAgent,
1223            message: format!("Try searching in: {agent_slug}"),
1224            suggested_query: None,
1225            suggested_filters: Some(filters),
1226            shortcut: None,
1227        }
1228    }
1229
1230    fn with_shortcut(mut self, key: u8) -> Self {
1231        self.shortcut = Some(key);
1232        self
1233    }
1234}
1235
1236#[derive(Debug, Clone, Copy)]
1237pub struct FieldMask {
1238    flags: u8,
1239    preview_content_chars: Option<usize>,
1240}
1241
1242impl FieldMask {
1243    const CONTENT: u8 = 1 << 0;
1244    const SNIPPET: u8 = 1 << 1;
1245    const TITLE: u8 = 1 << 2;
1246    const CACHE: u8 = 1 << 3;
1247
1248    pub const FULL: Self = Self {
1249        flags: Self::CONTENT | Self::SNIPPET | Self::TITLE | Self::CACHE,
1250        preview_content_chars: None,
1251    };
1252
1253    pub fn new(
1254        wants_content: bool,
1255        wants_snippet: bool,
1256        wants_title: bool,
1257        allows_cache: bool,
1258    ) -> Self {
1259        let mut flags = 0;
1260        if wants_content {
1261            flags |= Self::CONTENT;
1262        }
1263        if wants_snippet {
1264            flags |= Self::SNIPPET;
1265        }
1266        if wants_title {
1267            flags |= Self::TITLE;
1268        }
1269        if allows_cache {
1270            flags |= Self::CACHE;
1271        }
1272        Self {
1273            flags,
1274            preview_content_chars: None,
1275        }
1276    }
1277
1278    pub fn with_preview_content_limit(mut self, max_chars: Option<usize>) -> Self {
1279        self.preview_content_chars = max_chars;
1280        if max_chars.is_some() {
1281            self.flags &= !Self::CACHE;
1282        }
1283        self
1284    }
1285
1286    pub fn needs_content(self) -> bool {
1287        self.flags & Self::CONTENT != 0
1288    }
1289
1290    pub fn wants_snippet(self) -> bool {
1291        self.flags & Self::SNIPPET != 0
1292    }
1293
1294    pub fn wants_title(self) -> bool {
1295        self.flags & Self::TITLE != 0
1296    }
1297
1298    pub fn allows_cache(self) -> bool {
1299        self.flags & Self::CACHE != 0
1300    }
1301
1302    pub fn preview_content_limit(self) -> Option<usize> {
1303        self.preview_content_chars
1304    }
1305}
1306
1307#[derive(Debug, Clone, serde::Serialize)]
1308pub struct SearchHit {
1309    pub title: String,
1310    pub snippet: String,
1311    pub content: String,
1312    #[serde(skip_serializing)]
1313    pub content_hash: u64,
1314    #[serde(skip_serializing)]
1315    pub conversation_id: Option<i64>,
1316    pub score: f32,
1317    pub source_path: String,
1318    pub agent: String,
1319    pub workspace: String,
1320    /// Original workspace path before rewriting (P6.2)
1321    #[serde(skip_serializing_if = "Option::is_none")]
1322    pub workspace_original: Option<String>,
1323    pub created_at: Option<i64>,
1324    /// Line number in the source file where the matched message starts (1-indexed)
1325    pub line_number: Option<usize>,
1326    /// How this result matched the query (exact, prefix wildcard, etc.)
1327    #[serde(default)]
1328    pub match_type: MatchType,
1329    // Provenance fields (P3.3)
1330    /// Source identifier (e.g., "local", "work-laptop")
1331    #[serde(default = "default_source_id")]
1332    pub source_id: String,
1333    /// Origin kind ("local" or "ssh")
1334    #[serde(default = "default_source_id")]
1335    pub origin_kind: String,
1336    /// Origin host label for remote sources
1337    #[serde(skip_serializing_if = "Option::is_none")]
1338    pub origin_host: Option<String>,
1339}
1340
1341static LAZY_FIELDS_ENABLED: Lazy<bool> = Lazy::new(|| {
1342    dotenvy::var("CASS_LAZY_FIELDS")
1343        .ok()
1344        .map(|v| !(v == "0" || v.eq_ignore_ascii_case("false")))
1345        .unwrap_or(true)
1346});
1347
1348fn default_source_id() -> String {
1349    "local".to_string()
1350}
1351
1352fn effective_field_mask(field_mask: FieldMask) -> FieldMask {
1353    if *LAZY_FIELDS_ENABLED {
1354        field_mask
1355    } else {
1356        FieldMask::FULL
1357    }
1358}
1359
1360struct CassLexicalSearchResult {
1361    hits: Vec<FsLexicalDocHit>,
1362    total_count: Option<usize>,
1363}
1364
1365fn execute_query_with_bounded_exact_count(
1366    searcher: &Searcher,
1367    query: &dyn Query,
1368    limit: usize,
1369    offset: usize,
1370) -> Result<CassLexicalSearchResult> {
1371    let top_docs = searcher.search(
1372        query,
1373        &TopDocs::with_limit(limit)
1374            .and_offset(offset)
1375            .order_by_score(),
1376    )?;
1377    let page_saturated = top_docs.len() == limit;
1378    let index_doc_count = usize::try_from(searcher.num_docs()).unwrap_or(usize::MAX);
1379    let total_count = if page_saturated {
1380        if should_collect_exact_total_count(index_doc_count, exact_total_count_max_docs()) {
1381            Some(searcher.search(query, &Count)?)
1382        } else {
1383            tracing::debug!(
1384                index_doc_count,
1385                exact_count_max_docs = exact_total_count_max_docs(),
1386                limit,
1387                offset,
1388                "skipping exact Tantivy count on large saturated result page"
1389            );
1390            None
1391        }
1392    } else if offset > 0 && top_docs.is_empty() {
1393        None
1394    } else {
1395        Some(offset.saturating_add(top_docs.len()))
1396    };
1397    let hits = top_docs
1398        .into_iter()
1399        .enumerate()
1400        .map(|(rank, (bm25_score, doc_address))| FsLexicalDocHit {
1401            bm25_score,
1402            rank,
1403            doc_address,
1404        })
1405        .collect();
1406
1407    Ok(CassLexicalSearchResult { hits, total_count })
1408}
1409
1410/// Result of a search operation with metadata about how matches were found
1411#[derive(Debug, Clone)]
1412pub struct SearchResult {
1413    /// The search results
1414    pub hits: Vec<SearchHit>,
1415    /// Whether wildcard fallback was used (query had no/few exact matches)
1416    pub wildcard_fallback: bool,
1417    /// Cache metrics snapshot for observability/debug
1418    pub cache_stats: CacheStats,
1419    /// Did-you-mean suggestions when hits are empty or sparse
1420    pub suggestions: Vec<QuerySuggestion>,
1421    /// ANN search statistics (present when --approximate was used)
1422    pub ann_stats: Option<crate::search::ann_index::AnnSearchStats>,
1423    /// True total matching documents from the search engine when that is cheap
1424    /// and available. Large saturated lexical pages intentionally leave this as
1425    /// `None`; robot output then reports `total_matches` as a lower bound
1426    /// instead of forcing an expensive exact recount.
1427    pub total_count: Option<usize>,
1428}
1429
1430#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1431pub enum ProgressivePhaseKind {
1432    Initial,
1433    Refined,
1434}
1435
1436// Phase events intentionally carry a complete SearchResult so consumers can
1437// react without reloading auxiliary state or keeping cross-event caches.
1438#[allow(clippy::large_enum_variant)]
1439#[derive(Debug, Clone)]
1440pub enum ProgressiveSearchEvent {
1441    Phase {
1442        kind: ProgressivePhaseKind,
1443        result: SearchResult,
1444        elapsed_ms: u128,
1445    },
1446    RefinementFailed {
1447        latency_ms: u128,
1448        error: String,
1449    },
1450}
1451
1452#[derive(Debug, Clone)]
1453pub(crate) struct ProgressiveSearchRequest<'a> {
1454    pub(crate) cx: &'a FsCx,
1455    pub(crate) query: &'a str,
1456    pub(crate) filters: SearchFilters,
1457    pub(crate) limit: usize,
1458    pub(crate) sparse_threshold: usize,
1459    pub(crate) field_mask: FieldMask,
1460    pub(crate) mode: SearchMode,
1461}
1462
1463#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1464struct SearchHitKey {
1465    source_id: String,
1466    source_path: String,
1467    conversation_id: Option<i64>,
1468    title: String,
1469    line_number: Option<usize>,
1470    created_at: Option<i64>,
1471    content_hash: u64,
1472}
1473
1474fn normalized_search_source_id_sql_expr(
1475    source_id_column: &str,
1476    origin_kind_column: &str,
1477    origin_host_column: &str,
1478) -> String {
1479    format!(
1480        "CASE \
1481            WHEN TRIM(COALESCE({source_id_column}, '')) != '' THEN \
1482                CASE \
1483                    WHEN LOWER(TRIM(COALESCE({source_id_column}, ''))) = '{local}' THEN '{local}' \
1484                    ELSE TRIM(COALESCE({source_id_column}, '')) \
1485                END \
1486            WHEN LOWER(TRIM(COALESCE({origin_kind_column}, ''))) IN ('ssh', 'remote') THEN \
1487                CASE \
1488                    WHEN TRIM(COALESCE({origin_host_column}, '')) = '' THEN 'remote' \
1489                    ELSE TRIM(COALESCE({origin_host_column}, '')) \
1490                END \
1491            WHEN LOWER(TRIM(COALESCE({origin_kind_column}, ''))) = '{local}' THEN '{local}' \
1492            WHEN TRIM(COALESCE({origin_host_column}, '')) != '' THEN TRIM(COALESCE({origin_host_column}, '')) \
1493            ELSE '{local}' \
1494         END",
1495        local = crate::sources::provenance::LOCAL_SOURCE_ID,
1496    )
1497}
1498
1499fn normalize_search_source_filter_value(source_id: &str) -> String {
1500    let trimmed = source_id.trim();
1501    if trimmed.eq_ignore_ascii_case(crate::sources::provenance::LOCAL_SOURCE_ID) {
1502        crate::sources::provenance::LOCAL_SOURCE_ID.to_string()
1503    } else {
1504        trimmed.to_string()
1505    }
1506}
1507
1508fn normalized_search_hit_source_id_parts(
1509    source_id: &str,
1510    origin_kind: &str,
1511    origin_host: Option<&str>,
1512) -> String {
1513    let trimmed_source_id = source_id.trim();
1514    if !trimmed_source_id.is_empty() {
1515        if trimmed_source_id.eq_ignore_ascii_case(crate::sources::provenance::LOCAL_SOURCE_ID) {
1516            return crate::sources::provenance::LOCAL_SOURCE_ID.to_string();
1517        }
1518        return trimmed_source_id.to_string();
1519    }
1520
1521    let trimmed_origin_host = origin_host.map(str::trim).filter(|value| !value.is_empty());
1522    let trimmed_origin_kind = origin_kind.trim();
1523    if trimmed_origin_kind.eq_ignore_ascii_case("ssh")
1524        || trimmed_origin_kind.eq_ignore_ascii_case("remote")
1525    {
1526        return trimmed_origin_host.unwrap_or("remote").to_string();
1527    }
1528    if let Some(origin_host) = trimmed_origin_host {
1529        return origin_host.to_string();
1530    }
1531
1532    crate::sources::provenance::LOCAL_SOURCE_ID.to_string()
1533}
1534
1535fn normalized_search_hit_origin_kind(source_id: &str, origin_kind: Option<&str>) -> String {
1536    if let Some(kind) = origin_kind.map(str::trim).filter(|value| !value.is_empty()) {
1537        if kind.eq_ignore_ascii_case("local") {
1538            return crate::sources::provenance::LOCAL_SOURCE_ID.to_string();
1539        }
1540        if kind.eq_ignore_ascii_case("ssh") || kind.eq_ignore_ascii_case("remote") {
1541            return "remote".to_string();
1542        }
1543        return kind.to_ascii_lowercase();
1544    }
1545
1546    if source_id == crate::sources::provenance::LOCAL_SOURCE_ID {
1547        crate::sources::provenance::LOCAL_SOURCE_ID.to_string()
1548    } else {
1549        "remote".to_string()
1550    }
1551}
1552
1553fn normalized_search_hit_source_id(hit: &SearchHit) -> String {
1554    normalized_search_hit_source_id_parts(
1555        hit.source_id.as_str(),
1556        hit.origin_kind.as_str(),
1557        hit.origin_host.as_deref(),
1558    )
1559}
1560
1561impl SearchHitKey {
1562    fn from_hit(hit: &SearchHit) -> Self {
1563        Self {
1564            source_id: normalized_search_hit_source_id(hit),
1565            source_path: hit.source_path.clone(),
1566            conversation_id: hit.conversation_id,
1567            title: if hit.conversation_id.is_some() {
1568                String::new()
1569            } else {
1570                hit.title.trim().to_string()
1571            },
1572            line_number: hit.line_number,
1573            created_at: hit.created_at,
1574            content_hash: hit.content_hash,
1575        }
1576    }
1577}
1578
1579impl Ord for SearchHitKey {
1580    fn cmp(&self, other: &Self) -> CmpOrdering {
1581        self.source_id
1582            .cmp(&other.source_id)
1583            .then_with(|| self.source_path.cmp(&other.source_path))
1584            .then_with(|| self.conversation_id.cmp(&other.conversation_id))
1585            .then_with(|| self.title.cmp(&other.title))
1586            .then_with(|| self.line_number.cmp(&other.line_number))
1587            .then_with(|| self.created_at.cmp(&other.created_at))
1588            .then_with(|| self.content_hash.cmp(&other.content_hash))
1589    }
1590}
1591
1592impl PartialOrd for SearchHitKey {
1593    fn partial_cmp(&self, other: &Self) -> Option<CmpOrdering> {
1594        Some(self.cmp(other))
1595    }
1596}
1597
1598const FEDERATED_RRF_K: f32 = 60.0;
1599
1600#[derive(Debug)]
1601struct FederatedRankedHit {
1602    hit: SearchHit,
1603    shard_index: usize,
1604    shard_rank: usize,
1605    fused_score: f32,
1606}
1607
1608fn federated_rrf_score(shard_rank: usize) -> f32 {
1609    1.0 / (FEDERATED_RRF_K + shard_rank as f32 + 1.0)
1610}
1611
1612fn merge_federated_ranked_hits(mut ranked_hits: Vec<FederatedRankedHit>) -> Vec<SearchHit> {
1613    ranked_hits.sort_by(|a, b| {
1614        b.fused_score
1615            .total_cmp(&a.fused_score)
1616            .then_with(|| a.shard_rank.cmp(&b.shard_rank))
1617            .then_with(|| SearchHitKey::from_hit(&a.hit).cmp(&SearchHitKey::from_hit(&b.hit)))
1618            .then_with(|| a.shard_index.cmp(&b.shard_index))
1619    });
1620    ranked_hits
1621        .into_iter()
1622        .map(|mut ranked| {
1623            ranked.hit.score = ranked.fused_score;
1624            ranked.hit
1625        })
1626        .collect()
1627}
1628
1629#[cfg(test)]
1630#[allow(dead_code)]
1631#[derive(Debug, Default, Clone)]
1632struct HybridScore {
1633    rrf: f32,
1634    lexical_rank: Option<usize>,
1635    semantic_rank: Option<usize>,
1636    lexical_score: Option<f32>,
1637    semantic_score: Option<f32>,
1638}
1639
1640#[cfg(test)]
1641#[allow(dead_code)]
1642#[derive(Debug, Clone)]
1643struct FusedHit {
1644    key: SearchHitKey,
1645    score: HybridScore,
1646    hit: SearchHit,
1647}
1648
1649/// Whitespace-invariant content hash used for search-hit dedup.
1650///
1651/// Uses xxhash3-64 (via `xxhash-rust`) for ~4-10x throughput over the prior
1652/// hand-rolled FNV-1a byte loop on the 1-2 KB tool-output bodies that
1653/// dominate the corpus. The hash value is in-memory only (dedup keys), never
1654/// persisted, so switching algorithms requires no migration. The canonical
1655/// byte stream fed to the hasher is: each whitespace-separated token
1656/// followed by a single 0x20 space between tokens — identical tokenization
1657/// rules as the former FNV implementation, so dedup semantics are preserved.
1658pub(crate) fn stable_content_hash(content: &str) -> u64 {
1659    use xxhash_rust::xxh3::Xxh3;
1660    let mut hasher = Xxh3::new();
1661    let mut first = true;
1662    for token in content.split_whitespace() {
1663        if !first {
1664            hasher.update(b" ");
1665        }
1666        hasher.update(token.as_bytes());
1667        first = false;
1668    }
1669    hasher.digest()
1670}
1671
1672fn stable_hit_hash(
1673    content: &str,
1674    source_path: &str,
1675    line_number: Option<usize>,
1676    created_at: Option<i64>,
1677) -> u64 {
1678    use xxhash_rust::xxh3::Xxh3;
1679    let mut hasher = Xxh3::new();
1680    // Seed with the whitespace-normalized content hash for empty-body
1681    // stability (matches the former FNV_OFFSET fallback).
1682    if !content.is_empty() {
1683        hasher.update(&stable_content_hash(content).to_le_bytes());
1684    }
1685    hasher.update(b"|");
1686    hasher.update(source_path.as_bytes());
1687    hasher.update(b"|");
1688    if let Some(line) = line_number {
1689        let mut buf = itoa::Buffer::new();
1690        hasher.update(buf.format(line).as_bytes());
1691    }
1692    hasher.update(b"|");
1693    if let Some(ts) = created_at {
1694        let mut buf = itoa::Buffer::new();
1695        hasher.update(buf.format(ts).as_bytes());
1696    }
1697    hasher.digest()
1698}
1699
1700fn search_hit_key_doc_id(key: &SearchHitKey) -> String {
1701    // Unit Separator (0x1F) is extremely unlikely in filesystem paths/ids.
1702    // Bead num7z: build the stable dedup key directly into a pre-sized
1703    // String, branching on each Option instead of allocating throwaway
1704    // per-field Strings via `.map(|v| v.to_string())`. Output must stay
1705    // byte-identical to the prior `format!`-based implementation: empty
1706    // string for `None` optional fields, the integer's `Display` rendering
1707    // otherwise, all joined by 0x1F.
1708    use std::fmt::Write as _;
1709    const SEP: char = '\u{1f}';
1710    // 20 bytes covers the decimal rendering of any i64/usize/u64.
1711    let capacity = key.source_id.len()
1712        + key.source_path.len()
1713        + key.title.len()
1714        + 6 // six separators
1715        + 3 * 20 // three possibly-empty i64/usize fields
1716        + 20; // content_hash u64
1717    let mut out = String::with_capacity(capacity);
1718    out.push_str(&key.source_id);
1719    out.push(SEP);
1720    out.push_str(&key.source_path);
1721    out.push(SEP);
1722    if let Some(v) = key.conversation_id {
1723        let _ = write!(out, "{v}");
1724    }
1725    out.push(SEP);
1726    out.push_str(&key.title);
1727    out.push(SEP);
1728    if let Some(v) = key.line_number {
1729        let _ = write!(out, "{v}");
1730    }
1731    out.push(SEP);
1732    if let Some(v) = key.created_at {
1733        let _ = write!(out, "{v}");
1734    }
1735    out.push(SEP);
1736    let _ = write!(out, "{}", key.content_hash);
1737    out
1738}
1739
1740fn search_hit_doc_id(hit: &SearchHit) -> String {
1741    search_hit_key_doc_id(&SearchHitKey::from_hit(hit))
1742}
1743
1744/// Comparator for FusedHit: descending RRF score, prefer dual-source, then key for determinism.
1745#[cfg(test)]
1746fn cmp_fused_hit_desc(a: &FusedHit, b: &FusedHit) -> CmpOrdering {
1747    b.score
1748        .rrf
1749        .total_cmp(&a.score.rrf)
1750        .then_with(|| {
1751            let a_both = a.score.lexical_rank.is_some() && a.score.semantic_rank.is_some();
1752            let b_both = b.score.lexical_rank.is_some() && b.score.semantic_rank.is_some();
1753            match (b_both, a_both) {
1754                (true, false) => CmpOrdering::Greater,
1755                (false, true) => CmpOrdering::Less,
1756                _ => CmpOrdering::Equal,
1757            }
1758        })
1759        .then_with(|| a.key.cmp(&b.key))
1760}
1761
1762/// Threshold below which full sort is faster than quickselect + partial sort.
1763#[cfg(test)]
1764#[allow(dead_code)]
1765const QUICKSELECT_THRESHOLD: usize = 64;
1766
1767/// Partition fused hits to get top-k in O(N + k log k) instead of O(N log N).
1768///
1769/// For k << N, this is significantly faster than sorting all N elements.
1770/// Uses `select_nth_unstable_by` for O(N) average-case partitioning,
1771/// then sorts only the top-k elements.
1772///
1773/// Note: Currently only used for tests. Production code uses full sort for
1774/// content deduplication which requires seeing all elements.
1775#[cfg(test)]
1776#[allow(dead_code)]
1777fn top_k_fused(mut hits: Vec<FusedHit>, k: usize) -> Vec<FusedHit> {
1778    let n = hits.len();
1779
1780    // Edge cases: nothing to do or k >= n
1781    if n == 0 || k == 0 {
1782        return Vec::new();
1783    }
1784    if k >= n {
1785        hits.sort_by(cmp_fused_hit_desc);
1786        return hits;
1787    }
1788
1789    // For small N, full sort has less overhead than quickselect
1790    if n < QUICKSELECT_THRESHOLD {
1791        hits.sort_by(cmp_fused_hit_desc);
1792        hits.truncate(k);
1793        return hits;
1794    }
1795
1796    // Partition: move top-k elements to the front (unordered) in O(N)
1797    hits.select_nth_unstable_by(k - 1, cmp_fused_hit_desc);
1798
1799    // Truncate to just the top-k elements
1800    hits.truncate(k);
1801
1802    // Sort just the top-k in O(k log k)
1803    hits.sort_by(cmp_fused_hit_desc);
1804
1805    hits
1806}
1807
1808/// Fuse lexical + semantic hits using Reciprocal Rank Fusion (RRF).
1809/// Applies deterministic tie-breaking and returns the requested page slice.
1810pub fn rrf_fuse_hits(
1811    lexical: &[SearchHit],
1812    semantic: &[SearchHit],
1813    query: &str,
1814    limit: usize,
1815    offset: usize,
1816) -> Vec<SearchHit> {
1817    if limit == 0 {
1818        return Vec::new();
1819    }
1820    let total_candidates = lexical.len().saturating_add(semantic.len());
1821    if total_candidates == 0 {
1822        return Vec::new();
1823    }
1824
1825    let mut lexical_scored = Vec::with_capacity(lexical.len());
1826    let mut semantic_scored = Vec::with_capacity(semantic.len());
1827    let mut hit_by_doc_id: HashMap<String, SearchHit> = HashMap::with_capacity(total_candidates);
1828
1829    for hit in lexical {
1830        let doc_id = search_hit_doc_id(hit);
1831        // Prefer lexical hit details (snippets highlight query terms).
1832        hit_by_doc_id.insert(doc_id.clone(), hit.clone());
1833        lexical_scored.push(FsScoredResult {
1834            doc_id,
1835            score: hit.score,
1836            source: FsScoreSource::Lexical,
1837            index: None,
1838            fast_score: None,
1839            quality_score: None,
1840            lexical_score: Some(hit.score),
1841            rerank_score: None,
1842            explanation: None,
1843            metadata: None,
1844        });
1845    }
1846
1847    for (idx, hit) in semantic.iter().enumerate() {
1848        let doc_id = search_hit_doc_id(hit);
1849        hit_by_doc_id
1850            .entry(doc_id.clone())
1851            .or_insert_with(|| hit.clone());
1852        semantic_scored.push(FsVectorHit {
1853            index: u32::try_from(idx).unwrap_or(u32::MAX),
1854            score: hit.score,
1855            doc_id,
1856        });
1857    }
1858
1859    // Ask frankensearch for full fused ordering so we can preserve cass's
1860    // content-level deduplication/pagination semantics afterward.
1861    let fused = fs_rrf_fuse(
1862        &lexical_scored,
1863        &semantic_scored,
1864        total_candidates,
1865        0,
1866        &FsRrfConfig::default(),
1867    );
1868
1869    // Dedup by (source_id, source_path, conversation_id-or-title, line_number,
1870    // created_at, content_hash) while preserving RRF order. When a real
1871    // conversation_id is present, it is the authoritative session key and title
1872    // drift must not split the same conversation.
1873    #[derive(Clone, Copy)]
1874    struct CompatSlot {
1875        index: usize,
1876        conversation_id: Option<i64>,
1877        ambiguous: bool,
1878    }
1879
1880    let mut source_ids: HashMap<String, u32> = HashMap::new();
1881    let mut path_ids: HashMap<String, u32> = HashMap::new();
1882    let mut title_ids: HashMap<String, u32> = HashMap::new();
1883    let mut next_source_id: u32 = 0;
1884    let mut next_path_id: u32 = 0;
1885    let mut next_title_id: u32 = 0;
1886    type CompatExactKey = (
1887        u32,
1888        u32,
1889        Option<i64>,
1890        Option<u32>,
1891        Option<usize>,
1892        Option<i64>,
1893        u64,
1894    );
1895    type CompatFallbackKey = (u32, u32, u32, Option<usize>, Option<i64>, u64);
1896
1897    let mut exact_seen: HashMap<CompatExactKey, usize> = HashMap::with_capacity(fused.len());
1898    let mut fallback_seen: HashMap<CompatFallbackKey, CompatSlot> =
1899        HashMap::with_capacity(fused.len());
1900    let mut unique_hits: Vec<SearchHit> = Vec::with_capacity(fused.len());
1901
1902    let update_slot = |slot: &mut CompatSlot, conversation_id: Option<i64>| {
1903        if slot.ambiguous {
1904            return;
1905        }
1906        match (slot.conversation_id, conversation_id) {
1907            (Some(existing), Some(current)) if existing != current => slot.ambiguous = true,
1908            (None, Some(current)) => slot.conversation_id = Some(current),
1909            _ => {}
1910        }
1911    };
1912
1913    for fused_hit in fused {
1914        let mut hit = match hit_by_doc_id.remove(&fused_hit.doc_id) {
1915            Some(hit) => hit,
1916            None => continue,
1917        };
1918        if hit_is_noise(&hit, query) {
1919            continue;
1920        }
1921
1922        let normalized_source_id = normalized_search_hit_source_id(&hit);
1923        let source_key = if let Some(id) = source_ids.get(normalized_source_id.as_str()) {
1924            *id
1925        } else {
1926            let id = next_source_id;
1927            next_source_id = next_source_id.saturating_add(1);
1928            source_ids.insert(normalized_source_id, id);
1929            id
1930        };
1931        let path_key = if let Some(id) = path_ids.get(hit.source_path.as_str()) {
1932            *id
1933        } else {
1934            let id = next_path_id;
1935            next_path_id = next_path_id.saturating_add(1);
1936            path_ids.insert(hit.source_path.clone(), id);
1937            id
1938        };
1939        let normalized_title = hit.title.trim();
1940        let fallback_title_key = if let Some(id) = title_ids.get(normalized_title) {
1941            *id
1942        } else {
1943            let id = next_title_id;
1944            next_title_id = next_title_id.saturating_add(1);
1945            title_ids.insert(normalized_title.to_string(), id);
1946            id
1947        };
1948        let exact_title_key = if hit.conversation_id.is_some() {
1949            None
1950        } else {
1951            Some(fallback_title_key)
1952        };
1953        let exact_key = (
1954            source_key,
1955            path_key,
1956            hit.conversation_id,
1957            exact_title_key,
1958            hit.line_number,
1959            hit.created_at,
1960            hit.content_hash,
1961        );
1962        let fallback_key = (
1963            source_key,
1964            path_key,
1965            fallback_title_key,
1966            hit.line_number,
1967            hit.created_at,
1968            hit.content_hash,
1969        );
1970
1971        let merged_idx = exact_seen.get(&exact_key).copied().or_else(|| {
1972            fallback_seen.get(&fallback_key).and_then(|slot| {
1973                if slot.ambiguous {
1974                    return None;
1975                }
1976                match (slot.conversation_id, hit.conversation_id) {
1977                    (Some(existing), Some(current)) if existing != current => None,
1978                    _ => Some(slot.index),
1979                }
1980            })
1981        });
1982
1983        if let Some(existing_idx) = merged_idx {
1984            exact_seen.insert(exact_key, existing_idx);
1985            let slot = fallback_seen.entry(fallback_key).or_insert(CompatSlot {
1986                index: existing_idx,
1987                conversation_id: hit.conversation_id,
1988                ambiguous: false,
1989            });
1990            update_slot(slot, hit.conversation_id);
1991            if unique_hits[existing_idx].conversation_id.is_none() && hit.conversation_id.is_some()
1992            {
1993                unique_hits[existing_idx].conversation_id = hit.conversation_id;
1994            }
1995            unique_hits[existing_idx].score += fused_hit.rrf_score as f32;
1996            continue;
1997        }
1998
1999        hit.score = fused_hit.rrf_score as f32;
2000        let index = unique_hits.len();
2001        unique_hits.push(hit);
2002        exact_seen.insert(exact_key, index);
2003        match fallback_seen.get_mut(&fallback_key) {
2004            Some(slot) => update_slot(slot, unique_hits[index].conversation_id),
2005            None => {
2006                fallback_seen.insert(
2007                    fallback_key,
2008                    CompatSlot {
2009                        index,
2010                        conversation_id: unique_hits[index].conversation_id,
2011                        ambiguous: false,
2012                    },
2013                );
2014            }
2015        }
2016    }
2017
2018    unique_hits.sort_by(|a, b| {
2019        b.score
2020            .total_cmp(&a.score)
2021            .then_with(|| SearchHitKey::from_hit(a).cmp(&SearchHitKey::from_hit(b)))
2022    });
2023
2024    let start = offset.min(unique_hits.len());
2025    unique_hits.into_iter().skip(start).take(limit).collect()
2026}
2027
2028struct QueryCache {
2029    embedder_id: String,
2030    embeddings: LruCache<String, Vec<f32>>,
2031}
2032
2033impl QueryCache {
2034    fn new(embedder_id: &str, capacity: NonZeroUsize) -> Self {
2035        Self {
2036            embedder_id: embedder_id.to_string(),
2037            embeddings: LruCache::new(capacity),
2038        }
2039    }
2040
2041    fn align_embedder(&mut self, embedder: &dyn Embedder) {
2042        if self.embedder_id != embedder.id() {
2043            self.embedder_id = embedder.id().to_string();
2044            self.embeddings.clear();
2045        }
2046    }
2047
2048    fn get_cached(&mut self, embedder: &dyn Embedder, canonical: &str) -> Option<Vec<f32>> {
2049        self.align_embedder(embedder);
2050        self.embeddings.get(canonical).cloned()
2051    }
2052
2053    fn store(&mut self, embedder: &dyn Embedder, canonical: &str, embedding: Vec<f32>) {
2054        self.align_embedder(embedder);
2055        self.embeddings.put(canonical.to_string(), embedding);
2056    }
2057}
2058
2059/// Returns `Some(&filter)` when the filter has at least one active constraint,
2060/// `None` when unrestricted (skip filtering for performance).
2061fn semantic_filter_as_search_filter(filter: &SemanticFilter) -> Option<&dyn FsSearchFilter> {
2062    let unrestricted = filter.agents.is_none()
2063        && filter.workspaces.is_none()
2064        && filter.sources.is_none()
2065        && filter.roles.is_none()
2066        && filter.created_from.is_none()
2067        && filter.created_to.is_none();
2068    if unrestricted { None } else { Some(filter) }
2069}
2070
2071fn open_fs_semantic_ann_index(fs_index: &FsVectorIndex, ann_path: &Path) -> Result<FsHnswIndex> {
2072    if !ann_path.is_file() {
2073        bail!(
2074            "approximate search unavailable: HNSW index not found at {}",
2075            ann_path.display()
2076        );
2077    }
2078
2079    let ann = FsHnswIndex::load(ann_path, fs_index)
2080        .map_err(|err| anyhow!("open HNSW index failed: {err}"))?;
2081    let matches = ann
2082        .matches_vector_index(fs_index)
2083        .map_err(|err| anyhow!("validate HNSW index failed: {err}"))?;
2084    if !matches {
2085        bail!(
2086            "approximate search unavailable: HNSW index at {} is stale for current semantic index (run 'cass index --semantic --build-hnsw')",
2087            ann_path.display()
2088        );
2089    }
2090
2091    Ok(ann)
2092}
2093
2094struct SemanticSearchState {
2095    context_token: Arc<()>,
2096    embedder: Arc<dyn Embedder>,
2097    fs_semantic_index: Arc<FsVectorIndex>,
2098    fs_semantic_indexes: Arc<Vec<Arc<FsVectorIndex>>>,
2099    fs_ann_index: Option<Arc<FsHnswIndex>>,
2100    ann_path: Option<PathBuf>,
2101    fs_in_memory_two_tier_index: Option<Arc<FsInMemoryTwoTierIndex>>,
2102    in_memory_two_tier_unavailable: InMemoryTwoTierUnavailable,
2103    progressive_context: Option<Arc<ProgressiveTwoTierContext>>,
2104    progressive_context_unavailable: bool,
2105    filter_maps: SemanticFilterMaps,
2106    roles: Option<HashSet<u8>>,
2107    query_cache: QueryCache,
2108}
2109
2110#[derive(Debug, Clone, Copy, Default)]
2111struct InMemoryTwoTierUnavailable {
2112    fast_only: bool,
2113    quality: bool,
2114}
2115
2116impl InMemoryTwoTierUnavailable {
2117    fn is_known_unavailable(self, tier_mode: SemanticTierMode) -> bool {
2118        match tier_mode {
2119            SemanticTierMode::Single => false,
2120            SemanticTierMode::FastOnly => self.fast_only,
2121            SemanticTierMode::Progressive | SemanticTierMode::QualityOnly => self.quality,
2122        }
2123    }
2124
2125    fn mark_unavailable(&mut self, tier_mode: SemanticTierMode) {
2126        match tier_mode {
2127            SemanticTierMode::Single => {}
2128            SemanticTierMode::FastOnly => {
2129                self.fast_only = true;
2130            }
2131            SemanticTierMode::Progressive | SemanticTierMode::QualityOnly => {
2132                self.quality = true;
2133            }
2134        }
2135    }
2136}
2137
2138struct ProgressiveTwoTierContext {
2139    context_token: Arc<()>,
2140    index: Arc<FsTwoTierIndex>,
2141    fast_embedder: Arc<dyn frankensearch::Embedder>,
2142    quality_embedder: Option<Arc<dyn frankensearch::Embedder>>,
2143}
2144
2145#[derive(Clone)]
2146struct SemanticCandidateContext {
2147    fs_semantic_index: Arc<FsVectorIndex>,
2148    fs_semantic_indexes: Arc<Vec<Arc<FsVectorIndex>>>,
2149    filter_maps: SemanticFilterMaps,
2150    roles: Option<HashSet<u8>>,
2151}
2152
2153struct SemanticCandidateSearchRequest<'a> {
2154    fetch_limit: usize,
2155    approximate: bool,
2156    tier_mode: SemanticTierMode,
2157    in_memory_two_tier_index: Option<&'a Arc<FsInMemoryTwoTierIndex>>,
2158    ann_index: Option<&'a Arc<FsHnswIndex>>,
2159}
2160
2161#[derive(Debug, Clone, Copy, Default)]
2162struct SemanticCandidateRetryState {
2163    has_more_candidates: bool,
2164    exact_window_may_omit_competitor: bool,
2165}
2166
2167struct SemanticQueryEmbedding {
2168    context_token: Arc<()>,
2169    vector: Vec<f32>,
2170}
2171
2172struct SharedCassSyncEmbedder {
2173    inner: Arc<dyn Embedder>,
2174    cache: Mutex<LruCache<String, Vec<f32>>>,
2175}
2176
2177impl SharedCassSyncEmbedder {
2178    fn new(inner: Arc<dyn Embedder>) -> Self {
2179        let cache_capacity =
2180            NonZeroUsize::new(PROGRESSIVE_EMBEDDING_CACHE_CAPACITY).expect("cache capacity > 0");
2181        Self {
2182            inner,
2183            cache: Mutex::new(LruCache::new(cache_capacity)),
2184        }
2185    }
2186}
2187
2188impl Embedder for SharedCassSyncEmbedder {
2189    fn embed_sync(&self, text: &str) -> crate::search::embedder::EmbedderResult<Vec<f32>> {
2190        if let Ok(mut cache) = self.cache.lock()
2191            && let Some(embedding) = cache.get(text).cloned()
2192        {
2193            return Ok(embedding);
2194        }
2195
2196        let embedding = self.inner.embed_sync(text)?;
2197        if let Ok(mut cache) = self.cache.lock() {
2198            cache.put(text.to_owned(), embedding.clone());
2199        }
2200        Ok(embedding)
2201    }
2202
2203    fn embed_batch_sync(
2204        &self,
2205        texts: &[&str],
2206    ) -> crate::search::embedder::EmbedderResult<Vec<Vec<f32>>> {
2207        self.inner.embed_batch_sync(texts)
2208    }
2209
2210    fn dimension(&self) -> usize {
2211        self.inner.dimension()
2212    }
2213
2214    fn id(&self) -> &str {
2215        self.inner.id()
2216    }
2217
2218    fn model_name(&self) -> &str {
2219        self.inner.model_name()
2220    }
2221
2222    fn is_ready(&self) -> bool {
2223        self.inner.is_ready()
2224    }
2225
2226    fn is_semantic(&self) -> bool {
2227        self.inner.is_semantic()
2228    }
2229
2230    fn category(&self) -> frankensearch::ModelCategory {
2231        self.inner.category()
2232    }
2233
2234    fn tier(&self) -> frankensearch::ModelTier {
2235        self.inner.tier()
2236    }
2237
2238    fn supports_mrl(&self) -> bool {
2239        self.inner.supports_mrl()
2240    }
2241}
2242
2243fn build_in_memory_two_tier_index(
2244    ann_path: Option<PathBuf>,
2245    embedder_id: &str,
2246    tier_mode: SemanticTierMode,
2247) -> Option<Arc<FsInMemoryTwoTierIndex>> {
2248    let index_dir = ann_path
2249        .as_ref()
2250        .and_then(|path| path.parent().map(Path::to_path_buf));
2251    let Some(index_dir) = index_dir else {
2252        tracing::debug!("two-tier semantic unavailable: ann/index directory path missing");
2253        return None;
2254    };
2255
2256    match FsInMemoryTwoTierIndex::from_dir(&index_dir) {
2257        Ok(index) => return Some(Arc::new(index)),
2258        Err(err) => {
2259            tracing::debug!(
2260                dir = %index_dir.display(),
2261                error = %err,
2262                "two-tier semantic index load failed; considering fallback"
2263            );
2264        }
2265    }
2266
2267    if !matches!(tier_mode, SemanticTierMode::FastOnly) {
2268        return None;
2269    }
2270
2271    let fallback_fast = index_dir.join(format!("index-{embedder_id}.fsvi"));
2272    if !fallback_fast.is_file() {
2273        return None;
2274    }
2275
2276    match FsInMemoryVectorIndex::from_fsvi(&fallback_fast) {
2277        Ok(fast) => Some(Arc::new(FsInMemoryTwoTierIndex::new(fast, None))),
2278        Err(err) => {
2279            tracing::debug!(
2280                path = %fallback_fast.display(),
2281                error = %err,
2282                "fast-only semantic fallback index load failed"
2283            );
2284            None
2285        }
2286    }
2287}
2288
2289fn two_tier_index_supports_mode(
2290    index: &FsInMemoryTwoTierIndex,
2291    tier_mode: SemanticTierMode,
2292) -> bool {
2293    !matches!(
2294        tier_mode,
2295        SemanticTierMode::Progressive | SemanticTierMode::QualityOnly
2296    ) || index.has_quality_index()
2297}
2298
2299#[derive(Debug, Clone)]
2300struct ResolvedSemanticDocId {
2301    message_id: u64,
2302    doc_id: String,
2303}
2304
2305type ProgressiveLookupKey = (String, String, Option<i64>, String, i64, Option<i64>, u64);
2306type ProgressiveExactQueryKey = (i64, i64);
2307type ProgressiveFallbackQueryKey = (String, String, i64);
2308type ResolvedSemanticLookupRow = Option<(ProgressiveLookupKey, ResolvedSemanticDocId)>;
2309
2310#[derive(Debug, Clone)]
2311struct ProgressiveLexicalHit {
2312    title: String,
2313    snippet: String,
2314    content: String,
2315    content_hash: u64,
2316    conversation_id: Option<i64>,
2317    source_path: String,
2318    agent: String,
2319    workspace: String,
2320    workspace_original: Option<String>,
2321    created_at: Option<i64>,
2322    match_type: MatchType,
2323    line_number: Option<usize>,
2324    source_id: String,
2325    origin_kind: String,
2326    origin_host: Option<String>,
2327}
2328
2329impl ProgressiveLexicalHit {
2330    fn from_search_hit(hit: &SearchHit, field_mask: FieldMask) -> Self {
2331        Self {
2332            title: if field_mask.wants_title() {
2333                hit.title.clone()
2334            } else {
2335                String::new()
2336            },
2337            snippet: if field_mask.wants_snippet() {
2338                hit.snippet.clone()
2339            } else {
2340                String::new()
2341            },
2342            content: if field_mask.needs_content() {
2343                hit.content.clone()
2344            } else {
2345                String::new()
2346            },
2347            content_hash: hit.content_hash,
2348            conversation_id: hit.conversation_id,
2349            source_path: hit.source_path.clone(),
2350            agent: hit.agent.clone(),
2351            workspace: hit.workspace.clone(),
2352            workspace_original: hit.workspace_original.clone(),
2353            created_at: hit.created_at,
2354            match_type: hit.match_type,
2355            line_number: hit.line_number,
2356            source_id: hit.source_id.clone(),
2357            origin_kind: hit.origin_kind.clone(),
2358            origin_host: hit.origin_host.clone(),
2359        }
2360    }
2361
2362    fn to_search_hit(&self, score: f32) -> SearchHit {
2363        SearchHit {
2364            title: self.title.clone(),
2365            snippet: self.snippet.clone(),
2366            content: self.content.clone(),
2367            content_hash: self.content_hash,
2368            conversation_id: self.conversation_id,
2369            score,
2370            source_path: self.source_path.clone(),
2371            agent: self.agent.clone(),
2372            workspace: self.workspace.clone(),
2373            workspace_original: self.workspace_original.clone(),
2374            created_at: self.created_at,
2375            line_number: self.line_number,
2376            match_type: self.match_type,
2377            source_id: self.source_id.clone(),
2378            origin_kind: self.origin_kind.clone(),
2379            origin_host: self.origin_host.clone(),
2380        }
2381    }
2382}
2383
2384#[derive(Debug, Default)]
2385struct ProgressiveLexicalCache {
2386    hits_by_message: HashMap<u64, ProgressiveLexicalHit>,
2387    wildcard_fallback: bool,
2388    suggestions: Vec<QuerySuggestion>,
2389}
2390
2391#[derive(Clone, Copy)]
2392struct ProgressivePhaseContext<'a> {
2393    query: &'a str,
2394    filters: &'a SearchFilters,
2395    field_mask: FieldMask,
2396    lexical_cache: Option<&'a ProgressiveLexicalCache>,
2397    limit: usize,
2398    fetch_limit: usize,
2399}
2400
2401type ProgressiveLexicalSnapshot = Arc<ProgressiveLexicalCache>;
2402
2403struct CassProgressiveLexicalAdapter {
2404    client: Arc<SearchClient>,
2405    filters: SearchFilters,
2406    field_mask: FieldMask,
2407    sparse_threshold: usize,
2408    shared: Arc<Mutex<ProgressiveLexicalSnapshot>>,
2409}
2410
2411impl CassProgressiveLexicalAdapter {
2412    fn new(
2413        client: Arc<SearchClient>,
2414        filters: SearchFilters,
2415        field_mask: FieldMask,
2416        sparse_threshold: usize,
2417        shared: Arc<Mutex<ProgressiveLexicalSnapshot>>,
2418    ) -> Self {
2419        Self {
2420            client,
2421            filters,
2422            field_mask,
2423            sparse_threshold,
2424            shared,
2425        }
2426    }
2427}
2428
2429impl FsLexicalSearch for CassProgressiveLexicalAdapter {
2430    fn search<'a>(
2431        &'a self,
2432        cx: &'a FsCx,
2433        query: &'a str,
2434        limit: usize,
2435    ) -> FsSearchFuture<'a, Vec<FsScoredResult>> {
2436        Box::pin(async move {
2437            if cx.is_cancel_requested() {
2438                return Err(FsSearchError::Cancelled {
2439                    phase: "lexical".to_string(),
2440                    reason: "cancel requested".to_string(),
2441                });
2442            }
2443
2444            let result = self
2445                .client
2446                .search_with_fallback(
2447                    query,
2448                    self.filters.clone(),
2449                    limit,
2450                    0,
2451                    self.sparse_threshold,
2452                    self.field_mask,
2453                )
2454                .map_err(|err| FsSearchError::SubsystemError {
2455                    subsystem: "cass_lexical_adapter",
2456                    source: Box::new(std::io::Error::other(err.to_string())),
2457                })?;
2458
2459            let resolved = self
2460                .client
2461                .resolve_semantic_doc_ids_for_hits(&result.hits)
2462                .map_err(|err| FsSearchError::SubsystemError {
2463                    subsystem: "cass_lexical_adapter",
2464                    source: Box::new(std::io::Error::other(err.to_string())),
2465                })?;
2466
2467            let mut scored = Vec::with_capacity(result.hits.len());
2468            let mut hits_by_message = HashMap::with_capacity(result.hits.len());
2469
2470            for (hit, resolved_doc) in result.hits.iter().zip(resolved) {
2471                let Some(resolved_doc) = resolved_doc else {
2472                    continue;
2473                };
2474                hits_by_message
2475                    .entry(resolved_doc.message_id)
2476                    .or_insert_with(|| {
2477                        ProgressiveLexicalHit::from_search_hit(hit, self.field_mask)
2478                    });
2479                scored.push(FsScoredResult {
2480                    doc_id: resolved_doc.doc_id,
2481                    score: hit.score,
2482                    source: FsScoreSource::Lexical,
2483                    index: None,
2484                    fast_score: None,
2485                    quality_score: None,
2486                    lexical_score: Some(hit.score),
2487                    rerank_score: None,
2488                    explanation: None,
2489                    metadata: None,
2490                });
2491            }
2492
2493            if let Ok(mut guard) = self.shared.lock() {
2494                *guard = Arc::new(ProgressiveLexicalCache {
2495                    hits_by_message,
2496                    wildcard_fallback: result.wildcard_fallback,
2497                    suggestions: result.suggestions,
2498                });
2499            }
2500
2501            Ok(scored)
2502        })
2503    }
2504
2505    fn index_document<'a>(
2506        &'a self,
2507        _cx: &'a FsCx,
2508        _doc: &'a frankensearch::IndexableDocument,
2509    ) -> FsSearchFuture<'a, ()> {
2510        Box::pin(async move {
2511            Err(FsSearchError::SubsystemError {
2512                subsystem: "cass_lexical_adapter",
2513                source: Box::new(std::io::Error::other("cass lexical adapter is read-only")),
2514            })
2515        })
2516    }
2517
2518    fn commit<'a>(&'a self, _cx: &'a FsCx) -> FsSearchFuture<'a, ()> {
2519        Box::pin(async move { Ok(()) })
2520    }
2521
2522    fn doc_count(&self) -> usize {
2523        self.client.total_docs()
2524    }
2525}
2526
2527pub struct SearchClient {
2528    reader: Option<(IndexReader, FsCassFields)>,
2529    sqlite: Mutex<Option<SendConnection>>,
2530    sqlite_path: Option<PathBuf>,
2531    prefix_cache: Mutex<CacheShards>,
2532    reload_on_search: bool,
2533    last_reload: Mutex<Option<Instant>>,
2534    last_generation: Mutex<Option<u64>>,
2535    reload_epoch: Arc<AtomicU64>,
2536    warm_tx: Option<mpsc::Sender<WarmJob>>,
2537    _warm_handle: Option<std::thread::JoinHandle<()>>,
2538    metrics: Metrics,
2539    cache_namespace: String,
2540    semantic: Mutex<Option<SemanticSearchState>>,
2541    /// Exact total from the most recent Tantivy query when collecting it was
2542    /// cheap enough. Large saturated pages leave this as `None` so robot output
2543    /// can truthfully report lower-bound count precision without blocking the
2544    /// top-N result path.
2545    last_tantivy_total_count: Mutex<Option<usize>>,
2546}
2547
2548#[derive(Debug, Clone, Copy)]
2549pub struct SearchClientOptions {
2550    pub enable_reload: bool,
2551    pub enable_warm: bool,
2552}
2553
2554impl Default for SearchClientOptions {
2555    fn default() -> Self {
2556        Self {
2557            enable_reload: true,
2558            enable_warm: true,
2559        }
2560    }
2561}
2562
2563impl Drop for SearchClient {
2564    fn drop(&mut self) {
2565        FEDERATED_SEARCH_READERS
2566            .write()
2567            .remove(&self.cache_namespace);
2568    }
2569}
2570
2571#[derive(Debug, Clone, PartialEq, Eq)]
2572pub struct CacheStats {
2573    pub cache_hits: u64,
2574    pub cache_miss: u64,
2575    pub cache_shortfall: u64,
2576    pub reloads: u64,
2577    pub reload_ms_total: u128,
2578    pub total_cap: usize,
2579    pub total_cost: usize,
2580    /// Total evictions since client creation
2581    pub eviction_count: u64,
2582    /// Approximate bytes used by cache (rough estimate)
2583    pub approx_bytes: usize,
2584    /// Effective byte cap for cached hits (0 = disabled by explicit operator override)
2585    pub byte_cap: usize,
2586    /// Active eviction/admission policy for prefix result cache
2587    pub eviction_policy: &'static str,
2588    /// Number of S3-FIFO ghost entries retained for adaptive admission
2589    pub ghost_entries: usize,
2590    /// Number of cache insertions rejected by adaptive admission
2591    pub admission_rejects: u64,
2592    /// Number of adaptive query prewarm jobs scheduled from hot prefix-cache state.
2593    pub prewarm_scheduled: u64,
2594    /// Number of adaptive query prewarm jobs skipped because cache pressure was high.
2595    pub prewarm_skipped_pressure: u64,
2596    /// Last observed Tantivy reader generation signature for cursor continuity metadata.
2597    pub reader_generation: Option<u64>,
2598}
2599
2600impl Default for CacheStats {
2601    fn default() -> Self {
2602        Self {
2603            cache_hits: 0,
2604            cache_miss: 0,
2605            cache_shortfall: 0,
2606            reloads: 0,
2607            reload_ms_total: 0,
2608            total_cap: 0,
2609            total_cost: 0,
2610            eviction_count: 0,
2611            approx_bytes: 0,
2612            byte_cap: 0,
2613            eviction_policy: "unknown",
2614            ghost_entries: 0,
2615            admission_rejects: 0,
2616            prewarm_scheduled: 0,
2617            prewarm_skipped_pressure: 0,
2618            reader_generation: None,
2619        }
2620    }
2621}
2622
2623// Cache tuning: read from env to allow runtime override without recompiling.
2624// CASS_CACHE_SHARD_CAP controls per-shard entries; default 256.
2625static CACHE_SHARD_CAP: Lazy<usize> = Lazy::new(|| {
2626    dotenvy::var("CASS_CACHE_SHARD_CAP")
2627        .ok()
2628        .and_then(|v| v.parse::<usize>().ok())
2629        .filter(|v| *v > 0)
2630        .unwrap_or(256)
2631});
2632
2633// Total cache cost across all shards; approximate "~2k entries" default.
2634static CACHE_TOTAL_CAP: Lazy<usize> = Lazy::new(|| {
2635    dotenvy::var("CASS_CACHE_TOTAL_CAP")
2636        .ok()
2637        .and_then(|v| v.parse::<usize>().ok())
2638        .filter(|v| *v > 0)
2639        .unwrap_or(2048)
2640});
2641
2642static CACHE_DEBUG_ENABLED: Lazy<bool> = Lazy::new(|| {
2643    dotenvy::var("CASS_DEBUG_CACHE_METRICS")
2644        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
2645        .unwrap_or(false)
2646});
2647
2648// Byte-based cap for cache memory. Unset defaults to a memory-proportional cap;
2649// explicit CASS_CACHE_BYTE_CAP=0 disables the byte guard.
2650static CACHE_BYTE_CAP: Lazy<usize> = Lazy::new(|| match dotenvy::var("CASS_CACHE_BYTE_CAP") {
2651    Ok(value) => cache_byte_cap_from_env_value(Some(&value), available_memory_bytes()),
2652    Err(_) => default_cache_byte_cap(),
2653});
2654
2655static CACHE_EVICTION_POLICY: Lazy<CacheEvictionPolicy> = Lazy::new(|| {
2656    cache_eviction_policy_from_env_value(dotenvy::var("CASS_CACHE_EVICTION_POLICY").ok().as_deref())
2657});
2658
2659const DEFAULT_CACHE_BYTE_CAP_FALLBACK: usize = 64 * 1024 * 1024;
2660const DEFAULT_CACHE_BYTE_CAP_MEMORY_FRACTION_DENOMINATOR: u64 = 128;
2661const DEFAULT_CACHE_BYTE_CAP_CEILING: u64 = 2 * 1024 * 1024 * 1024;
2662const S3_FIFO_GHOST_CAP_MULTIPLIER: usize = 2;
2663const S3_FIFO_LARGE_ENTRY_FRACTION_DENOMINATOR: usize = 4;
2664const PREWARM_ENTRY_PRESSURE_NUMERATOR: usize = 9;
2665const PREWARM_ENTRY_PRESSURE_DENOMINATOR: usize = 10;
2666const PREWARM_BYTE_PRESSURE_NUMERATOR: usize = 4;
2667const PREWARM_BYTE_PRESSURE_DENOMINATOR: usize = 5;
2668
2669const CACHE_KEY_VERSION: &str = "1";
2670
2671// Warm debounce (ms) for background reload/warm jobs; default 120ms.
2672static WARM_DEBOUNCE_MS: Lazy<u64> = Lazy::new(|| {
2673    dotenvy::var("CASS_WARM_DEBOUNCE_MS")
2674        .ok()
2675        .and_then(|v| v.parse::<u64>().ok())
2676        .filter(|v| *v > 0)
2677        .unwrap_or(120)
2678});
2679
2680fn default_cache_byte_cap() -> usize {
2681    default_cache_byte_cap_for_available(available_memory_bytes())
2682}
2683
2684fn cache_byte_cap_from_env_value(value: Option<&str>, available_bytes: Option<u64>) -> usize {
2685    let Some(raw) = value else {
2686        return default_cache_byte_cap_for_available(available_bytes);
2687    };
2688    raw.parse::<usize>()
2689        .unwrap_or_else(|_| default_cache_byte_cap_for_available(available_bytes))
2690}
2691
2692fn default_cache_byte_cap_for_available(available_bytes: Option<u64>) -> usize {
2693    let Some(available_bytes) = available_bytes else {
2694        return DEFAULT_CACHE_BYTE_CAP_FALLBACK;
2695    };
2696    let ceiling = usize::try_from(DEFAULT_CACHE_BYTE_CAP_CEILING).unwrap_or(usize::MAX);
2697    let budget = available_bytes / DEFAULT_CACHE_BYTE_CAP_MEMORY_FRACTION_DENOMINATOR;
2698    let budget = budget.min(DEFAULT_CACHE_BYTE_CAP_CEILING);
2699    let budget = usize::try_from(budget).unwrap_or(ceiling);
2700    budget.clamp(DEFAULT_CACHE_BYTE_CAP_FALLBACK, ceiling)
2701}
2702
2703#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2704enum CacheEvictionPolicy {
2705    Lru,
2706    S3Fifo,
2707}
2708
2709impl CacheEvictionPolicy {
2710    fn label(self) -> &'static str {
2711        match self {
2712            CacheEvictionPolicy::Lru => "lru",
2713            CacheEvictionPolicy::S3Fifo => "s3-fifo",
2714        }
2715    }
2716}
2717
2718fn cache_eviction_policy_from_env_value(value: Option<&str>) -> CacheEvictionPolicy {
2719    match value.map(str::trim).filter(|value| !value.is_empty()) {
2720        Some(value) if value.eq_ignore_ascii_case("s3-fifo") => CacheEvictionPolicy::S3Fifo,
2721        Some(value) if value.eq_ignore_ascii_case("s3fifo") => CacheEvictionPolicy::S3Fifo,
2722        Some(value) if value.eq_ignore_ascii_case("s3_fifo") => CacheEvictionPolicy::S3Fifo,
2723        _ => CacheEvictionPolicy::Lru,
2724    }
2725}
2726
2727#[derive(Clone)]
2728struct CachedHit {
2729    hit: SearchHit,
2730    lc_content: String,
2731    lc_title: Option<String>,
2732    bloom64: u64,
2733}
2734
2735impl CachedHit {
2736    /// Approximate byte size of this cached hit (rough estimate for memory guardrails).
2737    /// Includes `SearchHit` strings + lowercase copies + bloom filter.
2738    fn approx_bytes(&self) -> usize {
2739        // Base struct overhead
2740        let base = std::mem::size_of::<Self>();
2741        // SearchHit string fields (title, snippet, content, source_path, agent, workspace)
2742        let hit_strings = self.hit.title.len()
2743            + self.hit.snippet.len()
2744            + self.hit.content.len()
2745            + self.hit.source_path.len()
2746            + self.hit.agent.len()
2747            + self.hit.workspace.len()
2748            + self
2749                .hit
2750                .workspace_original
2751                .as_ref()
2752                .map_or(0, std::string::String::len)
2753            + self.hit.source_id.len()
2754            + self.hit.origin_kind.len()
2755            + self
2756                .hit
2757                .origin_host
2758                .as_ref()
2759                .map_or(0, std::string::String::len);
2760        // Lowercase cache copies
2761        let lc_strings =
2762            self.lc_content.len() + self.lc_title.as_ref().map_or(0, std::string::String::len);
2763        base + hit_strings + lc_strings
2764    }
2765}
2766
2767struct CacheShards {
2768    // Optimization 2.3: Use Arc<str> for cache keys to reduce memory via interning
2769    shards: HashMap<Arc<str>, LruCache<Arc<str>, Vec<CachedHit>>>,
2770    total_cap: usize,
2771    total_cost: usize,
2772    /// Running count of evictions (for diagnostics)
2773    eviction_count: u64,
2774    /// Approximate bytes used by all cached hits
2775    total_bytes: usize,
2776    /// Byte cap (0 = disabled)
2777    byte_cap: usize,
2778    /// Active cache admission/eviction policy.
2779    policy: CacheEvictionPolicy,
2780    /// Ghost queue used by S3-FIFO-style adaptive admission.
2781    ghost_keys: VecDeque<Arc<str>>,
2782    ghost_set: HashSet<Arc<str>>,
2783    admission_rejects: u64,
2784}
2785
2786impl CacheShards {
2787    fn new(total_cap: usize, byte_cap: usize) -> Self {
2788        Self::new_with_policy(total_cap, byte_cap, *CACHE_EVICTION_POLICY)
2789    }
2790
2791    fn new_with_policy(total_cap: usize, byte_cap: usize, policy: CacheEvictionPolicy) -> Self {
2792        Self {
2793            shards: HashMap::new(),
2794            total_cap: total_cap.max(1),
2795            total_cost: 0,
2796            eviction_count: 0,
2797            total_bytes: 0,
2798            byte_cap,
2799            policy,
2800            ghost_keys: VecDeque::new(),
2801            ghost_set: HashSet::new(),
2802            admission_rejects: 0,
2803        }
2804    }
2805
2806    fn shard_mut(&mut self, name: &str) -> &mut LruCache<Arc<str>, Vec<CachedHit>> {
2807        // Use interned shard names to reduce memory for repeated lookups
2808        let interned_name = intern_cache_key(name);
2809        self.shards
2810            .entry(interned_name)
2811            .or_insert_with(|| LruCache::new(NonZeroUsize::new(*CACHE_SHARD_CAP).unwrap()))
2812    }
2813
2814    fn shard_opt(&self, name: &str) -> Option<&LruCache<Arc<str>, Vec<CachedHit>>> {
2815        // HashMap<Arc<str>, _> can be queried with &str via Borrow trait
2816        self.shards.get(name)
2817    }
2818
2819    fn put(&mut self, shard_name: &str, key: Arc<str>, value: Vec<CachedHit>) {
2820        let new_cost = value.len();
2821        let new_bytes: usize = value.iter().map(CachedHit::approx_bytes).sum();
2822        let replacing = self
2823            .shard_opt(shard_name)
2824            .is_some_and(|shard| shard.contains(&key));
2825
2826        if !replacing && !self.should_admit(&key, new_cost, new_bytes) {
2827            self.admission_rejects += 1;
2828            self.record_ghost(key);
2829            return;
2830        }
2831
2832        self.remove_ghost(&key);
2833
2834        let shard = self.shard_mut(shard_name);
2835        let old_val = shard.put(key, value);
2836        let (old_cost, old_bytes) = old_val.as_ref().map_or((0, 0), |v| {
2837            (v.len(), v.iter().map(CachedHit::approx_bytes).sum())
2838        });
2839
2840        self.total_cost = self
2841            .total_cost
2842            .saturating_add(new_cost)
2843            .saturating_sub(old_cost);
2844        self.total_bytes = self
2845            .total_bytes
2846            .saturating_add(new_bytes)
2847            .saturating_sub(old_bytes);
2848        self.evict_until_within_cap();
2849    }
2850
2851    fn evict_until_within_cap(&mut self) {
2852        // Evict if over entry cap OR over byte cap (when byte_cap > 0)
2853        while self.total_cost > self.total_cap
2854            || (self.byte_cap > 0 && self.total_bytes > self.byte_cap)
2855        {
2856            // Under byte pressure, target the byte-heaviest shard. Otherwise,
2857            // target the shard with the most cached items. This avoids
2858            // evicting many small useful entries before a single oversized
2859            // result set is finally removed.
2860            let byte_pressure = self.byte_cap > 0 && self.total_bytes > self.byte_cap;
2861            let mut largest_shard_key = None;
2862            let mut max_score = 0usize;
2863            for (k, v) in self.shards.iter() {
2864                let score = if byte_pressure {
2865                    shard_cached_bytes(v)
2866                } else {
2867                    v.len()
2868                };
2869                if score > max_score {
2870                    max_score = score;
2871                    largest_shard_key = Some(k.clone());
2872                }
2873            }
2874
2875            if let Some(key) = largest_shard_key {
2876                if let Some(shard) = self.shards.get_mut(&key)
2877                    && let Some((evicted_key, v)) = shard.pop_lru()
2878                {
2879                    let evicted_bytes: usize = v.iter().map(CachedHit::approx_bytes).sum();
2880                    self.total_cost = self.total_cost.saturating_sub(v.len());
2881                    self.total_bytes = self.total_bytes.saturating_sub(evicted_bytes);
2882                    self.eviction_count += 1;
2883                    self.record_ghost(evicted_key);
2884                }
2885            } else {
2886                break; // All shards are empty
2887            }
2888        }
2889    }
2890
2891    fn should_admit(&self, key: &Arc<str>, cost: usize, bytes: usize) -> bool {
2892        if self.policy == CacheEvictionPolicy::Lru || self.ghost_set.contains(key) {
2893            return true;
2894        }
2895        !self.is_s3_fifo_large_candidate(cost, bytes)
2896    }
2897
2898    fn is_s3_fifo_large_candidate(&self, cost: usize, bytes: usize) -> bool {
2899        let entry_heavy = cost
2900            > self
2901                .total_cap
2902                .div_ceil(S3_FIFO_LARGE_ENTRY_FRACTION_DENOMINATOR);
2903        let byte_heavy = self.byte_cap > 0
2904            && bytes
2905                > self
2906                    .byte_cap
2907                    .div_ceil(S3_FIFO_LARGE_ENTRY_FRACTION_DENOMINATOR);
2908        entry_heavy || byte_heavy
2909    }
2910
2911    fn record_ghost(&mut self, key: Arc<str>) {
2912        if self.policy != CacheEvictionPolicy::S3Fifo {
2913            return;
2914        }
2915        if self.ghost_set.insert(key.clone()) {
2916            self.ghost_keys.push_back(key);
2917        }
2918        let cap = self
2919            .total_cap
2920            .saturating_mul(S3_FIFO_GHOST_CAP_MULTIPLIER)
2921            .max(1);
2922        while self.ghost_set.len() > cap {
2923            if let Some(old) = self.ghost_keys.pop_front() {
2924                self.ghost_set.remove(&old);
2925            } else {
2926                break;
2927            }
2928        }
2929    }
2930
2931    fn remove_ghost(&mut self, key: &Arc<str>) {
2932        self.ghost_set.remove(key);
2933        self.ghost_keys.retain(|candidate| candidate != key);
2934    }
2935
2936    fn clear(&mut self) {
2937        self.shards.clear();
2938        self.total_cost = 0;
2939        self.total_bytes = 0;
2940        self.ghost_keys.clear();
2941        self.ghost_set.clear();
2942        // Note: eviction_count preserved for lifetime stats
2943    }
2944
2945    fn total_cost(&self) -> usize {
2946        self.total_cost
2947    }
2948
2949    fn total_cap(&self) -> usize {
2950        self.total_cap
2951    }
2952
2953    fn eviction_count(&self) -> u64 {
2954        self.eviction_count
2955    }
2956
2957    fn total_bytes(&self) -> usize {
2958        self.total_bytes
2959    }
2960
2961    fn byte_cap(&self) -> usize {
2962        self.byte_cap
2963    }
2964
2965    fn policy_label(&self) -> &'static str {
2966        self.policy.label()
2967    }
2968
2969    fn ghost_entries(&self) -> usize {
2970        self.ghost_set.len()
2971    }
2972
2973    fn admission_rejects(&self) -> u64 {
2974        self.admission_rejects
2975    }
2976
2977    fn prewarm_pressure(&self) -> bool {
2978        let entry_pressure = self
2979            .total_cost
2980            .saturating_mul(PREWARM_ENTRY_PRESSURE_DENOMINATOR)
2981            >= self
2982                .total_cap
2983                .saturating_mul(PREWARM_ENTRY_PRESSURE_NUMERATOR);
2984        let byte_pressure = self.byte_cap > 0
2985            && self
2986                .total_bytes
2987                .saturating_mul(PREWARM_BYTE_PRESSURE_DENOMINATOR)
2988                >= self
2989                    .byte_cap
2990                    .saturating_mul(PREWARM_BYTE_PRESSURE_NUMERATOR);
2991        entry_pressure || byte_pressure
2992    }
2993}
2994
2995fn shard_cached_bytes(shard: &LruCache<Arc<str>, Vec<CachedHit>>) -> usize {
2996    shard
2997        .iter()
2998        .map(|(_key, hits)| hits.iter().map(CachedHit::approx_bytes).sum::<usize>())
2999        .sum()
3000}
3001
3002#[derive(Clone)]
3003struct WarmJob {
3004    query: String,
3005    filters_fingerprint: String,
3006    shard_name: String,
3007}
3008
3009#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3010enum AdaptivePrewarmDecision {
3011    Schedule,
3012    SkipCold,
3013    SkipPressure,
3014}
3015
3016#[derive(Clone)]
3017struct SearcherCacheEntry {
3018    epoch: u64,
3019    reader_key: usize,
3020    searcher: Searcher,
3021}
3022
3023thread_local! {
3024    static THREAD_SEARCHER: RefCell<Option<SearcherCacheEntry>> = const { RefCell::new(None) };
3025}
3026
3027#[derive(Clone)]
3028struct FederatedIndexReader {
3029    reader: IndexReader,
3030    fields: FsCassFields,
3031}
3032
3033static FEDERATED_SEARCH_READERS: Lazy<RwLock<HashMap<String, Arc<Vec<FederatedIndexReader>>>>> =
3034    Lazy::new(|| RwLock::new(HashMap::new()));
3035static SEARCH_CLIENT_INSTANCE_COUNTER: AtomicU64 = AtomicU64::new(1);
3036
3037/// Calculate Levenshtein edit distance between two strings.
3038/// Used for typo detection in did-you-mean suggestions.
3039fn levenshtein_distance(a: &str, b: &str) -> usize {
3040    let a_chars: Vec<char> = a.chars().collect();
3041    let b_chars: Vec<char> = b.chars().collect();
3042    let a_len = a_chars.len();
3043    let b_len = b_chars.len();
3044
3045    if a_len == 0 {
3046        return b_len;
3047    }
3048    if b_len == 0 {
3049        return a_len;
3050    }
3051
3052    // Use two rows for space efficiency
3053    let mut prev_row: Vec<usize> = (0..=b_len).collect();
3054    let mut curr_row: Vec<usize> = vec![0; b_len + 1];
3055
3056    for (i, a_char) in a_chars.iter().enumerate() {
3057        curr_row[0] = i + 1;
3058        for (j, b_char) in b_chars.iter().enumerate() {
3059            let cost = usize::from(a_char != b_char);
3060            curr_row[j + 1] = (prev_row[j + 1] + 1) // deletion
3061                .min(curr_row[j] + 1) // insertion
3062                .min(prev_row[j] + cost); // substitution
3063        }
3064        std::mem::swap(&mut prev_row, &mut curr_row);
3065    }
3066
3067    prev_row[b_len]
3068}
3069
3070/// Normalize a term into FTS5-porter-aligned parts.
3071/// Splits punctuation into separate fragments while preserving a trailing `*`
3072/// on the final fragment so fallback queries match how SQLite tokenizes indexed
3073/// text in `fts_messages`.
3074fn normalize_term_parts(raw: &str) -> Vec<String> {
3075    let mut parts = Vec::new();
3076    for token in nfc_sanitize_query(raw).split_whitespace() {
3077        let mut current = String::new();
3078        let mut chars = token.chars().peekable();
3079        while let Some(ch) = chars.next() {
3080            let trailing_wildcard = ch == '*' && chars.peek().is_none() && !current.is_empty();
3081            if ch.is_alphanumeric() || ch == '_' || trailing_wildcard {
3082                current.push(ch);
3083                continue;
3084            }
3085
3086            if !current.is_empty() {
3087                parts.push(std::mem::take(&mut current));
3088            }
3089        }
3090
3091        if !current.is_empty() {
3092            parts.push(current);
3093        }
3094    }
3095    parts
3096}
3097
3098/// Normalize phrase text into tokenizer-aligned terms (lowercased, no wildcards).
3099fn normalize_phrase_terms(raw: &str) -> Vec<String> {
3100    normalize_term_parts(raw)
3101        .into_iter()
3102        .map(|s| s.trim_matches('*').to_lowercase())
3103        .filter(|s| !s.is_empty())
3104        .collect()
3105}
3106
3107fn render_fts5_term_part(part: &str) -> Option<String> {
3108    let pattern = FsCassWildcardPattern::parse(part);
3109    if matches!(
3110        pattern,
3111        FsCassWildcardPattern::Suffix(_)
3112            | FsCassWildcardPattern::Substring(_)
3113            | FsCassWildcardPattern::Complex(_)
3114    ) {
3115        return None;
3116    }
3117
3118    Some(part.to_string())
3119}
3120
3121/// Determine the dominant match type from a query string.
3122/// Returns the "loosest" pattern used (Substring > Suffix > Prefix > Exact).
3123fn dominant_match_type(query: &str) -> MatchType {
3124    let mut worst = MatchType::Exact;
3125    for term in query.split_whitespace() {
3126        let pattern = FsCassWildcardPattern::parse(term);
3127        let mt = match pattern {
3128            FsCassWildcardPattern::Exact(_) => MatchType::Exact,
3129            FsCassWildcardPattern::Prefix(_) => MatchType::Prefix,
3130            FsCassWildcardPattern::Suffix(_) => MatchType::Suffix,
3131            FsCassWildcardPattern::Substring(_) => MatchType::Substring,
3132            FsCassWildcardPattern::Complex(_) => MatchType::Wildcard,
3133        };
3134        // Lower quality factor = "looser" match = dominant
3135        if mt.quality_factor() < worst.quality_factor() {
3136            worst = mt;
3137        }
3138    }
3139    worst
3140}
3141
3142/// Check if content is primarily a tool invocation (noise that shouldn't appear in search results).
3143/// Tool invocations like "[Tool: Bash - Check status]" are not informative search results.
3144pub(crate) fn is_tool_invocation_noise(content: &str) -> bool {
3145    let trimmed = content.trim();
3146
3147    // Direct tool invocations that are just "[Tool: X - description]" or "[Tool: X] args"
3148    if trimmed.starts_with("[Tool:") {
3149        // Find closing bracket
3150        if let Some(close_idx) = trimmed.find(']') {
3151            // Check for content after closing bracket (Pi-Agent style: "[Tool: name] args")
3152            let after = &trimmed[close_idx + 1..];
3153            if !after.trim().is_empty() {
3154                return false; // Has args/content after -> Keep
3155            }
3156
3157            // No content after bracket. Check for description inside.
3158            // Format: "[Tool: Name - Desc]" (useful) vs "[Tool: Name]" (previously noise, now kept)
3159            // We now keep "[Tool: Name]" because users might search for "Tool: Bash" to find usage.
3160            // Only "[Tool:]" or "[Tool: ]" (empty name) is considered noise.
3161            let inner = &trimmed[6..close_idx]; // Skip "[Tool:"
3162            return inner.trim().is_empty();
3163        }
3164        // No closing bracket? Malformed, treat as noise
3165        return true;
3166    }
3167
3168    // Also filter very short content that's just tool names or markers
3169    if trimmed.len() < 20 {
3170        let lower = trimmed.to_lowercase();
3171        if lower.starts_with("[tool") || lower.starts_with("tool:") {
3172            return true;
3173        }
3174    }
3175
3176    false
3177}
3178
3179fn hit_content_for_noise_check(hit: &SearchHit) -> &str {
3180    if hit.content.is_empty() {
3181        &hit.snippet
3182    } else {
3183        &hit.content
3184    }
3185}
3186
3187fn hit_is_noise(hit: &SearchHit, query: &str) -> bool {
3188    let content_to_check = hit_content_for_noise_check(hit);
3189    // When both `content` and `snippet` are empty, it usually means the caller
3190    // explicitly asked for a projection (`--fields minimal` / `summary`) that
3191    // excludes both fields — NOT that the underlying row was empty. Treating
3192    // the hit as noise in that case silently drops every real match and makes
3193    // `cass search --fields minimal` return zero results even when matches
3194    // exist (reality-check bead q6xf9). The noise classifier cannot make a
3195    // correctness-preserving decision without text to inspect, so default to
3196    // "not noise" in that case and let the hit through; downstream projection
3197    // will apply the requested field subset.
3198    if content_to_check.is_empty() {
3199        return false;
3200    }
3201    is_search_noise_text(content_to_check, query) || is_tool_invocation_noise(content_to_check)
3202}
3203
3204fn snippet_from_content(content: &str) -> String {
3205    let trimmed = content.trim();
3206    let mut chars = trimmed.chars();
3207    let preview: String = chars.by_ref().take(200).collect();
3208    if chars.next().is_some() {
3209        format!("{preview}...")
3210    } else {
3211        preview
3212    }
3213}
3214
3215/// Deduplicate search hits by message-level provenance and content, keeping
3216/// only the highest-scored hit for each unique matched message.
3217///
3218/// This respects source boundaries (P2.3): the same content from different sources
3219/// appears as separate results, since they represent distinct conversations.
3220///
3221/// Also filters out tool invocation noise that isn't useful for search results.
3222#[cfg(test)]
3223pub(crate) fn deduplicate_hits(hits: Vec<SearchHit>) -> Vec<SearchHit> {
3224    deduplicate_hits_with_query(hits, "")
3225}
3226
3227pub(crate) fn deduplicate_hits_with_query(hits: Vec<SearchHit>, query: &str) -> Vec<SearchHit> {
3228    // Key: (source_numeric_id, source_path_numeric_id, conversation_id-or-title,
3229    //       line_number, created_at, content_hash) -> index in deduped.
3230    // Include message-level identity so repeated identical content in the same
3231    // session remains visible as distinct hits when it came from different messages.
3232    // When conversation_id exists, it is authoritative and title drift must not
3233    // split or merge hits incorrectly.
3234    let mut source_ids: HashMap<String, u32> = HashMap::new();
3235    let mut path_ids: HashMap<String, u32> = HashMap::new();
3236    let mut title_ids: HashMap<String, u32> = HashMap::new();
3237    let mut next_source_id: u32 = 0;
3238    let mut next_path_id: u32 = 0;
3239    let mut next_title_id: u32 = 0;
3240    type DedupKey = (
3241        u32,
3242        u32,
3243        Option<i64>,
3244        Option<u32>,
3245        Option<usize>,
3246        Option<i64>,
3247        u64,
3248    );
3249
3250    let mut seen: HashMap<DedupKey, usize> = HashMap::new();
3251    let mut deduped: Vec<SearchHit> = Vec::new();
3252
3253    for hit in hits {
3254        if hit_is_noise(&hit, query) {
3255            continue;
3256        }
3257
3258        // Include normalized source identity AND source_path in the key so different
3259        // sessions keep their results while local provenance drift still coalesces.
3260        let normalized_source_id = normalized_search_hit_source_id(&hit);
3261        let source_key = if let Some(id) = source_ids.get(normalized_source_id.as_str()) {
3262            *id
3263        } else {
3264            let id = next_source_id;
3265            next_source_id = next_source_id.saturating_add(1);
3266            source_ids.insert(normalized_source_id, id);
3267            id
3268        };
3269        let path_key = if let Some(id) = path_ids.get(hit.source_path.as_str()) {
3270            *id
3271        } else {
3272            let id = next_path_id;
3273            next_path_id = next_path_id.saturating_add(1);
3274            path_ids.insert(hit.source_path.clone(), id);
3275            id
3276        };
3277        let title_key = if hit.conversation_id.is_some() {
3278            None
3279        } else {
3280            let normalized_title = hit.title.trim();
3281            Some(if let Some(id) = title_ids.get(normalized_title) {
3282                *id
3283            } else {
3284                let id = next_title_id;
3285                next_title_id = next_title_id.saturating_add(1);
3286                title_ids.insert(normalized_title.to_string(), id);
3287                id
3288            })
3289        };
3290        let key = (
3291            source_key,
3292            path_key,
3293            hit.conversation_id,
3294            title_key,
3295            hit.line_number,
3296            hit.created_at,
3297            hit.content_hash,
3298        );
3299
3300        if let Some(&existing_idx) = seen.get(&key) {
3301            // If existing hit has lower score, replace it
3302            if deduped[existing_idx].score < hit.score {
3303                deduped[existing_idx] = hit;
3304            }
3305            // Otherwise keep existing (higher score)
3306        } else {
3307            seen.insert(key, deduped.len());
3308            deduped.push(hit);
3309        }
3310    }
3311
3312    deduped
3313}
3314
3315fn should_try_wildcard_fallback(
3316    returned_hits: usize,
3317    limit: usize,
3318    offset: usize,
3319    sparse_threshold: usize,
3320) -> bool {
3321    if offset != 0 {
3322        return false;
3323    }
3324
3325    let effective_sparse_threshold = if limit == 0 {
3326        sparse_threshold
3327    } else {
3328        sparse_threshold.min(limit)
3329    };
3330
3331    returned_hits < effective_sparse_threshold
3332}
3333
3334fn should_skip_automatic_wildcard_fallback_for_long_zero_hit_query(
3335    query: &str,
3336    returned_hits: usize,
3337) -> bool {
3338    if returned_hits != 0 {
3339        return false;
3340    }
3341
3342    for token in normalize_phrase_terms(query) {
3343        if token.chars().count() > AUTOMATIC_WILDCARD_FALLBACK_MAX_TOKEN_CHARS {
3344            return true;
3345        }
3346    }
3347
3348    false
3349}
3350
3351fn snippet_from_preview_without_full_content(
3352    field_mask: FieldMask,
3353    stored_preview: &str,
3354    query: &str,
3355) -> Option<String> {
3356    if field_mask.needs_content() || !field_mask.wants_snippet() || stored_preview.is_empty() {
3357        return None;
3358    }
3359
3360    cached_prefix_snippet(stored_preview, query, 160)
3361}
3362
3363fn stored_preview_is_complete_content(stored_preview: &str) -> bool {
3364    // The preview builder appends U+2026 only when truncating. A real message
3365    // ending with that character becomes a conservative false negative here.
3366    !stored_preview.is_empty() && !stored_preview.ends_with('…')
3367}
3368
3369impl SearchClient {
3370    pub fn open(index_path: &Path, db_path: Option<&Path>) -> Result<Option<Self>> {
3371        Self::open_with_options(index_path, db_path, SearchClientOptions::default())
3372    }
3373
3374    pub fn open_with_options(
3375        index_path: &Path,
3376        db_path: Option<&Path>,
3377        options: SearchClientOptions,
3378    ) -> Result<Option<Self>> {
3379        let tantivy = fs_cass_open_search_reader(index_path, ReloadPolicy::Manual).ok();
3380        let client_id = SEARCH_CLIENT_INSTANCE_COUNTER.fetch_add(1, Ordering::Relaxed);
3381        let cache_namespace = format!(
3382            "v{}|schema:{}|client:{}|index:{}",
3383            CACHE_KEY_VERSION,
3384            FS_CASS_SCHEMA_HASH,
3385            client_id,
3386            index_path.display()
3387        );
3388        let federated_readers = if tantivy.is_none() {
3389            crate::search::tantivy::open_federated_search_readers(index_path, ReloadPolicy::Manual)
3390                .ok()
3391                .flatten()
3392                .filter(|readers| !readers.is_empty())
3393                .map(|readers| {
3394                    Arc::new(
3395                        readers
3396                            .into_iter()
3397                            .map(|(reader, fields)| FederatedIndexReader { reader, fields })
3398                            .collect::<Vec<_>>(),
3399                    )
3400                })
3401        } else {
3402            None
3403        };
3404
3405        let sqlite_path = db_path.map(Path::to_path_buf).filter(|path| path.exists());
3406
3407        if tantivy.is_none() && federated_readers.is_none() && sqlite_path.is_some() {
3408            tracing::warn!(
3409                index_path = %index_path.display(),
3410                "Tantivy search index not found or incompatible. \
3411                 Search results will be degraded. \
3412                 Run `cass index --full` to rebuild the index."
3413            );
3414        }
3415
3416        if tantivy.is_none() && federated_readers.is_none() && sqlite_path.is_none() {
3417            return Ok(None);
3418        }
3419
3420        let reload_epoch = Arc::new(AtomicU64::new(0));
3421        let metrics = Metrics::default();
3422
3423        let warm_pair = if options.enable_warm
3424            && let Some((reader, fields)) = &tantivy
3425        {
3426            maybe_spawn_warm_worker(
3427                reader.clone(),
3428                *fields,
3429                reload_epoch.clone(),
3430                metrics.clone(),
3431            )
3432        } else {
3433            None
3434        };
3435
3436        if let Some(readers) = &federated_readers {
3437            FEDERATED_SEARCH_READERS
3438                .write()
3439                .insert(cache_namespace.clone(), Arc::clone(readers));
3440        } else {
3441            FEDERATED_SEARCH_READERS.write().remove(&cache_namespace);
3442        }
3443
3444        Ok(Some(Self {
3445            reader: tantivy,
3446            sqlite: Mutex::new(None),
3447            sqlite_path,
3448            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
3449            reload_on_search: options.enable_reload,
3450            last_reload: Mutex::new(None),
3451            last_generation: Mutex::new(None),
3452            reload_epoch,
3453            warm_tx: warm_pair.as_ref().map(|(tx, _)| tx.clone()),
3454            _warm_handle: warm_pair.map(|(_, h)| h),
3455            metrics,
3456            cache_namespace,
3457            semantic: Mutex::new(None),
3458            last_tantivy_total_count: Mutex::new(None),
3459        }))
3460    }
3461
3462    fn sqlite_guard(&self) -> Result<std::sync::MutexGuard<'_, Option<SendConnection>>> {
3463        let mut guard = self
3464            .sqlite
3465            .lock()
3466            .map_err(|_| anyhow!("sqlite lock poisoned"))?;
3467
3468        if guard.is_none()
3469            && let Some(path) = &self.sqlite_path
3470        {
3471            match open_search_hydration_sqlite(path, std::time::Duration::from_secs(1)) {
3472                Ok(conn) => {
3473                    *guard = Some(SendConnection(conn));
3474                }
3475                Err(err) => {
3476                    tracing::debug!(
3477                        error = %err,
3478                        path = %path.display(),
3479                        "readonly sqlite open failed for search client"
3480                    );
3481                }
3482            }
3483        }
3484
3485        Ok(guard)
3486    }
3487
3488    pub fn search(
3489        &self,
3490        query: &str,
3491        filters: SearchFilters,
3492        limit: usize,
3493        offset: usize,
3494        field_mask: FieldMask,
3495    ) -> Result<Vec<SearchHit>> {
3496        // NFC-normalize early so every downstream consumer (Tantivy query
3497        // builder, sanitizer, FTS5 fallback) sees consistent Unicode form
3498        // matching the NFC-indexed content.
3499        use unicode_normalization::UnicodeNormalization;
3500        let query: String = query.nfc().collect();
3501        let query: &str = &query;
3502        let sanitized = nfc_sanitize_query(query);
3503        let field_mask = effective_field_mask(field_mask);
3504        let limit = if limit == 0 {
3505            self.total_docs().min(no_limit_result_cap()).max(1)
3506        } else {
3507            limit
3508        };
3509        let can_use_cache =
3510            field_mask.allows_cache() && (field_mask.needs_content() || field_mask.wants_snippet());
3511
3512        // Invalidate prefix cache if the index has been updated since last search.
3513        // This must happen BEFORE the cache check below to avoid serving stale results.
3514        if let Some((reader, _)) = &self.reader {
3515            self.maybe_reload_reader(reader)?;
3516            let searcher = self.searcher_for_thread(reader);
3517            self.track_generation(searcher.generation().generation_id());
3518        } else if let Some(readers) = self.federated_readers()
3519            && let Some(signature) = self.maybe_reload_federated_readers(readers.as_ref())?
3520        {
3521            self.track_generation(signature);
3522        }
3523
3524        // Fast path: reuse cached prefix when user is typing forward (offset 0 only).
3525        // Only use cache for simple queries (no wildcards, no boolean operators) because
3526        // the cache matching logic enforces strict prefix AND semantics which is incorrect
3527        // for suffixes, substrings, OR, NOT, or phrases.
3528        if can_use_cache
3529            && offset == 0
3530            && !query.contains('*')
3531            && !fs_cass_has_boolean_operators(query)
3532        {
3533            self.maybe_schedule_adaptive_query_prewarm(&sanitized, &filters);
3534            if let Some(cached) = self.cached_prefix_hits(&sanitized, &filters) {
3535                // Opt 2.4: Pre-compute lowercase query terms once, reuse for all hits
3536                let query_terms = QueryTermsLower::from_query(&sanitized);
3537                let mut filtered: Vec<SearchHit> = cached
3538                    .into_iter()
3539                    .filter(|h| hit_matches_query_cached_precomputed(h, &query_terms))
3540                    .map(|c| c.hit.clone())
3541                    .collect();
3542                if filtered.len() >= limit {
3543                    filtered.truncate(limit);
3544                    self.metrics.inc_cache_hits();
3545                    self.maybe_log_cache_metrics("hit");
3546                    if let Ok(mut tc) = self.last_tantivy_total_count.lock() {
3547                        *tc = None;
3548                    }
3549                    return Ok(filtered);
3550                }
3551                // Cache had entries but not enough to satisfy limit - shortfall, not miss
3552                self.metrics.inc_cache_shortfall();
3553                self.maybe_log_cache_metrics("shortfall");
3554            } else {
3555                // No cached prefix at all - this is the actual miss
3556                self.metrics.inc_cache_miss();
3557                self.maybe_log_cache_metrics("miss");
3558            }
3559        }
3560
3561        // Adaptive fetch sizing: start at 2x target to reduce common-case work,
3562        // retry at 3x only when deduplication causes shortfall.
3563        // We always fetch from 0 to preserve global deduplication correctness.
3564        let target_hits = offset.saturating_add(limit);
3565        let initial_fetch_limit = if target_hits <= 16 {
3566            target_hits.saturating_mul(2)
3567        } else {
3568            // Larger pages benefit from a lower first-pass over-fetch.
3569            // Retry logic below preserves correctness on duplicate-heavy corpora.
3570            target_hits.saturating_mul(3).div_ceil(2)
3571        };
3572        let session_path_filter_active = !filters.session_paths.is_empty();
3573        let fallback_fetch_limit = if session_path_filter_active {
3574            self.total_docs()
3575                .min(no_limit_result_cap())
3576                .max(target_hits.saturating_mul(3))
3577                .max(1)
3578        } else {
3579            target_hits.saturating_mul(3)
3580        };
3581
3582        // Tantivy is the primary high-performance engine.
3583        if let Some((reader, fields)) = &self.reader {
3584            tracing::info!(
3585                backend = "tantivy",
3586                query = sanitized,
3587                limit = initial_fetch_limit,
3588                offset = 0,
3589                "search_start"
3590            );
3591            let (hits, tantivy_total_count) = self.search_tantivy(
3592                reader,
3593                fields,
3594                query,
3595                &sanitized,
3596                filters.clone(),
3597                initial_fetch_limit,
3598                0, // Always fetch from 0 for global dedup
3599                field_mask,
3600            )?;
3601            if let Ok(mut tc) = self.last_tantivy_total_count.lock() {
3602                *tc = tantivy_total_count;
3603            }
3604            if !hits.is_empty() {
3605                let initial_hit_count = hits.len();
3606                let page_hits = |raw_hits: Vec<SearchHit>| {
3607                    self.postprocess_hits_page(raw_hits, &sanitized, &filters, limit, offset)
3608                };
3609
3610                let (mut deduped_len, mut paged_hits) = page_hits(hits);
3611
3612                let needs_retry = deduped_len < target_hits
3613                    && initial_hit_count == initial_fetch_limit
3614                    && initial_fetch_limit < fallback_fetch_limit;
3615
3616                if needs_retry {
3617                    tracing::debug!(
3618                        query = sanitized,
3619                        target_hits,
3620                        deduped_len,
3621                        initial_fetch_limit,
3622                        fallback_fetch_limit,
3623                        session_path_filter_active,
3624                        "retrying lexical fetch due to dedup or session-path shortfall"
3625                    );
3626                    let (retry_hits, retry_total_count) = self.search_tantivy(
3627                        reader,
3628                        fields,
3629                        query,
3630                        &sanitized,
3631                        filters.clone(),
3632                        fallback_fetch_limit,
3633                        0,
3634                        field_mask,
3635                    )?;
3636                    if let Ok(mut tc) = self.last_tantivy_total_count.lock() {
3637                        *tc = retry_total_count;
3638                    }
3639                    if !retry_hits.is_empty() {
3640                        (deduped_len, paged_hits) = page_hits(retry_hits);
3641                    }
3642                }
3643
3644                tracing::trace!(
3645                    query = sanitized,
3646                    target_hits,
3647                    deduped_len,
3648                    returned = paged_hits.len(),
3649                    "lexical fetch complete"
3650                );
3651
3652                if can_use_cache && offset == 0 {
3653                    self.put_cache(&sanitized, &filters, &paged_hits);
3654                }
3655                return Ok(paged_hits);
3656            }
3657            tracing::debug!(
3658                query = sanitized,
3659                "tantivy returned zero hits; skipping sqlite fallback because tantivy is authoritative when available"
3660            );
3661            return Ok(Vec::new());
3662        } else if let Some(readers) = self.federated_readers() {
3663            tracing::info!(
3664                backend = "tantivy-federated",
3665                query = sanitized,
3666                limit = initial_fetch_limit,
3667                offset = 0,
3668                shards = readers.len(),
3669                "search_start"
3670            );
3671            let (hits, tantivy_total_count) = self.search_tantivy_federated(
3672                readers.as_ref(),
3673                query,
3674                &sanitized,
3675                filters.clone(),
3676                initial_fetch_limit,
3677                field_mask,
3678            )?;
3679            if let Ok(mut tc) = self.last_tantivy_total_count.lock() {
3680                *tc = tantivy_total_count;
3681            }
3682            if !hits.is_empty() {
3683                let initial_hit_count = hits.len();
3684                let page_hits = |raw_hits: Vec<SearchHit>| {
3685                    self.postprocess_hits_page(raw_hits, &sanitized, &filters, limit, offset)
3686                };
3687
3688                let (mut deduped_len, mut paged_hits) = page_hits(hits);
3689                let expected_federated_capacity = initial_fetch_limit.saturating_mul(readers.len());
3690                let federated_initial_capacity_reached = if session_path_filter_active {
3691                    initial_hit_count >= initial_fetch_limit.min(expected_federated_capacity)
3692                } else {
3693                    initial_hit_count == expected_federated_capacity
3694                };
3695                let needs_retry = deduped_len < target_hits
3696                    && federated_initial_capacity_reached
3697                    && initial_fetch_limit < fallback_fetch_limit;
3698
3699                if needs_retry {
3700                    tracing::debug!(
3701                        query = sanitized,
3702                        target_hits,
3703                        deduped_len,
3704                        initial_fetch_limit,
3705                        fallback_fetch_limit,
3706                        shards = readers.len(),
3707                        session_path_filter_active,
3708                        "retrying federated lexical fetch due to dedup or session-path shortfall"
3709                    );
3710                    let (retry_hits, retry_total_count) = self.search_tantivy_federated(
3711                        readers.as_ref(),
3712                        query,
3713                        &sanitized,
3714                        filters.clone(),
3715                        fallback_fetch_limit,
3716                        field_mask,
3717                    )?;
3718                    if let Ok(mut tc) = self.last_tantivy_total_count.lock() {
3719                        *tc = retry_total_count;
3720                    }
3721                    if !retry_hits.is_empty() {
3722                        (deduped_len, paged_hits) = page_hits(retry_hits);
3723                    }
3724                }
3725
3726                tracing::trace!(
3727                    query = sanitized,
3728                    target_hits,
3729                    deduped_len,
3730                    returned = paged_hits.len(),
3731                    shards = readers.len(),
3732                    "federated lexical fetch complete"
3733                );
3734
3735                if can_use_cache && offset == 0 {
3736                    self.put_cache(&sanitized, &filters, &paged_hits);
3737                }
3738                return Ok(paged_hits);
3739            }
3740            tracing::debug!(
3741                query = sanitized,
3742                shards = readers.len(),
3743                "federated tantivy returned zero hits; skipping sqlite fallback because tantivy is authoritative when available"
3744            );
3745            return Ok(Vec::new());
3746        }
3747
3748        // Skip SQLite fallback when the query contains leading/internal wildcards that
3749        // FTS5 cannot parse (e.g., "*handler" or "f*o").
3750        // We ALLOW trailing wildcards ("foo*") as FTS5 supports prefix matching.
3751        let unsupported_wildcards = sanitized.split_whitespace().any(|t| {
3752            let core = t.trim_end_matches('*');
3753            core.contains('*') // Any star remaining after trimming end is unsupported (leading or internal)
3754        });
3755
3756        if unsupported_wildcards {
3757            return Ok(Vec::new());
3758        }
3759
3760        let has_sqlite_backend = {
3761            let sqlite_guard = self
3762                .sqlite
3763                .lock()
3764                .map_err(|_| anyhow!("sqlite lock poisoned"))?;
3765            sqlite_guard.is_some() || self.sqlite_path.is_some()
3766        };
3767
3768        if has_sqlite_backend {
3769            tracing::info!(
3770                backend = "sqlite-fts5",
3771                query = sanitized,
3772                limit = fallback_fetch_limit,
3773                offset = 0,
3774                "search_start"
3775            );
3776            let hits = self.search_sqlite_fts5(
3777                self.sqlite_path
3778                    .as_deref()
3779                    .unwrap_or_else(|| Path::new(":memory:")),
3780                query,
3781                filters.clone(),
3782                fallback_fetch_limit,
3783                0, // Always fetch from 0 for global dedup
3784                field_mask,
3785            )?;
3786            let (_, paged_hits) =
3787                self.postprocess_hits_page(hits, &sanitized, &filters, limit, offset);
3788
3789            if can_use_cache && offset == 0 {
3790                self.put_cache(&sanitized, &filters, &paged_hits);
3791            }
3792            return Ok(paged_hits);
3793        }
3794
3795        tracing::info!(backend = "none", query = query, "search_start");
3796        Ok(Vec::new())
3797    }
3798
3799    pub fn set_semantic_context(
3800        &self,
3801        embedder: Arc<dyn Embedder>,
3802        fs_semantic_index: VectorIndex,
3803        filter_maps: SemanticFilterMaps,
3804        roles: Option<HashSet<u8>>,
3805        ann_path: Option<PathBuf>,
3806    ) -> Result<()> {
3807        self.set_semantic_indexes_context(
3808            embedder,
3809            vec![fs_semantic_index],
3810            filter_maps,
3811            roles,
3812            ann_path,
3813        )
3814    }
3815
3816    pub fn set_semantic_indexes_context(
3817        &self,
3818        embedder: Arc<dyn Embedder>,
3819        fs_semantic_indexes: Vec<VectorIndex>,
3820        filter_maps: SemanticFilterMaps,
3821        roles: Option<HashSet<u8>>,
3822        ann_path: Option<PathBuf>,
3823    ) -> Result<()> {
3824        if fs_semantic_indexes.is_empty() {
3825            bail!("semantic context requires at least one vector index");
3826        }
3827
3828        let fs_semantic_indexes = fs_semantic_indexes
3829            .into_iter()
3830            .map(|index| {
3831                let embedder_id = index.embedder_id().to_string();
3832                let dimension = index.dimension();
3833                if embedder_id != embedder.id() {
3834                    bail!(
3835                        "embedder mismatch: index uses {}, embedder is {}",
3836                        embedder_id,
3837                        embedder.id()
3838                    );
3839                }
3840                if dimension != embedder.dimension() {
3841                    bail!(
3842                        "embedder dimension mismatch: index uses {}, embedder is {}",
3843                        dimension,
3844                        embedder.dimension()
3845                    );
3846                }
3847                Ok(Arc::new(index))
3848            })
3849            .collect::<Result<Vec<_>>>()?;
3850        let fs_semantic_index = Arc::clone(&fs_semantic_indexes[0]);
3851        let shard_count = fs_semantic_indexes.len();
3852        let ann_path = if shard_count == 1 { ann_path } else { None };
3853        let embedder_id = fs_semantic_index.embedder_id().to_string();
3854        let dimension = fs_semantic_index.dimension();
3855        let fs_semantic_indexes = Arc::new(fs_semantic_indexes);
3856
3857        let capacity = NonZeroUsize::new(100).ok_or_else(|| anyhow!("invalid cache size"))?;
3858        let context_token = Arc::new(());
3859        let mut state_guard = self
3860            .semantic
3861            .lock()
3862            .map_err(|_| anyhow!("semantic lock poisoned"))?;
3863        *state_guard = Some(SemanticSearchState {
3864            context_token,
3865            embedder,
3866            fs_semantic_index,
3867            fs_semantic_indexes,
3868            fs_ann_index: None,
3869            ann_path,
3870            fs_in_memory_two_tier_index: None,
3871            in_memory_two_tier_unavailable: InMemoryTwoTierUnavailable::default(),
3872            progressive_context: None,
3873            progressive_context_unavailable: false,
3874            filter_maps,
3875            roles,
3876            query_cache: QueryCache::new(embedder_id.as_str(), capacity),
3877        });
3878        if shard_count > 1 {
3879            tracing::info!(
3880                shard_count,
3881                dimension,
3882                embedder = embedder_id,
3883                "semantic search context loaded sharded vector generation"
3884            );
3885        }
3886        Ok(())
3887    }
3888
3889    pub fn clear_semantic_context(&self) -> Result<()> {
3890        let mut guard = self
3891            .semantic
3892            .lock()
3893            .map_err(|_| anyhow!("semantic lock poisoned"))?;
3894        *guard = None;
3895        Ok(())
3896    }
3897
3898    fn semantic_context_matches(&self, context_token: &Arc<()>) -> Result<bool> {
3899        let guard = self
3900            .semantic
3901            .lock()
3902            .map_err(|_| anyhow!("semantic lock poisoned"))?;
3903        Ok(guard
3904            .as_ref()
3905            .is_some_and(|state| Arc::ptr_eq(&state.context_token, context_token)))
3906    }
3907
3908    fn semantic_query_embedding(&self, canonical: &str) -> Result<SemanticQueryEmbedding> {
3909        loop {
3910            let (embedder, context_token) = {
3911                let mut guard = self
3912                    .semantic
3913                    .lock()
3914                    .map_err(|_| anyhow!("semantic lock poisoned"))?;
3915                let state = guard.as_mut().ok_or_else(|| {
3916                    anyhow!("semantic search unavailable (no embedder or vector index)")
3917                })?;
3918                if let Some(hit) = state
3919                    .query_cache
3920                    .get_cached(state.embedder.as_ref(), canonical)
3921                {
3922                    return Ok(SemanticQueryEmbedding {
3923                        context_token: Arc::clone(&state.context_token),
3924                        vector: hit,
3925                    });
3926                }
3927                (
3928                    Arc::clone(&state.embedder),
3929                    Arc::clone(&state.context_token),
3930                )
3931            };
3932
3933            let embedding = embedder
3934                .embed_sync(canonical)
3935                .map_err(|e| anyhow!("embedding failed: {e}"))?;
3936
3937            let mut guard = self
3938                .semantic
3939                .lock()
3940                .map_err(|_| anyhow!("semantic lock poisoned"))?;
3941            let state = guard.as_mut().ok_or_else(|| {
3942                anyhow!("semantic search unavailable (no embedder or vector index)")
3943            })?;
3944            if !Arc::ptr_eq(&state.context_token, &context_token) {
3945                continue;
3946            }
3947            if let Some(hit) = state
3948                .query_cache
3949                .get_cached(state.embedder.as_ref(), canonical)
3950            {
3951                return Ok(SemanticQueryEmbedding {
3952                    context_token,
3953                    vector: hit,
3954                });
3955            }
3956            state
3957                .query_cache
3958                .store(state.embedder.as_ref(), canonical, embedding.clone());
3959            return Ok(SemanticQueryEmbedding {
3960                context_token,
3961                vector: embedding,
3962            });
3963        }
3964    }
3965
3966    fn in_memory_two_tier_index(
3967        &self,
3968        tier_mode: SemanticTierMode,
3969    ) -> Result<Option<Arc<FsInMemoryTwoTierIndex>>> {
3970        loop {
3971            let (ann_path, embedder_id, context_token) = {
3972                let mut guard = self
3973                    .semantic
3974                    .lock()
3975                    .map_err(|_| anyhow!("semantic lock poisoned"))?;
3976                let state = guard.as_mut().ok_or_else(|| {
3977                    anyhow!("semantic search unavailable (no embedder or vector index)")
3978                })?;
3979                if let Some(index) = state.fs_in_memory_two_tier_index.as_ref()
3980                    && two_tier_index_supports_mode(index.as_ref(), tier_mode)
3981                {
3982                    return Ok(Some(Arc::clone(index)));
3983                }
3984                if state
3985                    .in_memory_two_tier_unavailable
3986                    .is_known_unavailable(tier_mode)
3987                {
3988                    return Ok(None);
3989                }
3990                (
3991                    state.ann_path.clone(),
3992                    state.embedder.id().to_string(),
3993                    Arc::clone(&state.context_token),
3994                )
3995            };
3996
3997            let index = build_in_memory_two_tier_index(ann_path.clone(), &embedder_id, tier_mode);
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_in_memory_two_tier_index.as_ref()
4007                && two_tier_index_supports_mode(existing.as_ref(), tier_mode)
4008            {
4009                return Ok(Some(Arc::clone(existing)));
4010            }
4011            if !Arc::ptr_eq(&state.context_token, &context_token) {
4012                continue;
4013            }
4014            let Some(index) = index else {
4015                state
4016                    .in_memory_two_tier_unavailable
4017                    .mark_unavailable(tier_mode);
4018                return Ok(None);
4019            };
4020            if !two_tier_index_supports_mode(index.as_ref(), tier_mode) {
4021                state
4022                    .in_memory_two_tier_unavailable
4023                    .mark_unavailable(tier_mode);
4024                return Ok(None);
4025            }
4026            state.fs_in_memory_two_tier_index = Some(Arc::clone(&index));
4027            if index.has_quality_index() {
4028                state.in_memory_two_tier_unavailable = InMemoryTwoTierUnavailable::default();
4029            } else {
4030                state.in_memory_two_tier_unavailable.fast_only = false;
4031            }
4032            return Ok(Some(index));
4033        }
4034    }
4035
4036    fn ann_index(&self) -> Result<Arc<FsHnswIndex>> {
4037        loop {
4038            let (ann_path, fs_semantic_index) = {
4039                let mut guard = self
4040                    .semantic
4041                    .lock()
4042                    .map_err(|_| anyhow!("semantic lock poisoned"))?;
4043                let state = guard.as_mut().ok_or_else(|| {
4044                    anyhow!("semantic search unavailable (no embedder or vector index)")
4045                })?;
4046                if let Some(index) = state.fs_ann_index.as_ref() {
4047                    return Ok(Arc::clone(index));
4048                }
4049                let ann_path = state.ann_path.clone().ok_or_else(|| {
4050                    anyhow!(
4051                        "approximate search unavailable: HNSW index missing (run 'cass index --semantic --build-hnsw')"
4052                    )
4053                })?;
4054                (ann_path, Arc::clone(&state.fs_semantic_index))
4055            };
4056
4057            let ann = Arc::new(open_fs_semantic_ann_index(
4058                fs_semantic_index.as_ref(),
4059                &ann_path,
4060            )?);
4061
4062            let mut guard = self
4063                .semantic
4064                .lock()
4065                .map_err(|_| anyhow!("semantic lock poisoned"))?;
4066            let state = guard.as_mut().ok_or_else(|| {
4067                anyhow!("semantic search unavailable (no embedder or vector index)")
4068            })?;
4069            if let Some(existing) = state.fs_ann_index.as_ref() {
4070                return Ok(Arc::clone(existing));
4071            }
4072            if state.ann_path.as_ref() != Some(&ann_path)
4073                || !Arc::ptr_eq(&state.fs_semantic_index, &fs_semantic_index)
4074            {
4075                continue;
4076            }
4077            state.fs_ann_index = Some(Arc::clone(&ann));
4078            return Ok(ann);
4079        }
4080    }
4081
4082    fn collapse_semantic_results(
4083        best_by_message: HashMap<u64, VectorSearchResult>,
4084        fetch_limit: usize,
4085    ) -> Vec<VectorSearchResult> {
4086        let mut collapsed: Vec<VectorSearchResult> = best_by_message.into_values().collect();
4087        collapsed.sort_by(|a, b| {
4088            b.score
4089                .total_cmp(&a.score)
4090                .then_with(|| a.message_id.cmp(&b.message_id))
4091        });
4092        if collapsed.len() > fetch_limit {
4093            collapsed.truncate(fetch_limit);
4094        }
4095        collapsed
4096    }
4097
4098    fn semantic_exact_candidate_limit(fetch_limit: usize, record_count: usize) -> usize {
4099        fetch_limit
4100            .saturating_mul(SEMANTIC_EXACT_CHUNK_OVERFETCH_MULTIPLIER)
4101            .max(fetch_limit)
4102            .min(record_count)
4103    }
4104
4105    fn semantic_window_may_omit_competitor(
4106        collapsed: &[VectorSearchResult],
4107        fetch_limit: usize,
4108        max_omitted_score: Option<f32>,
4109    ) -> bool {
4110        if fetch_limit == 0 {
4111            return false;
4112        }
4113        let Some(max_omitted_score) = max_omitted_score else {
4114            return false;
4115        };
4116        if collapsed.len() < fetch_limit {
4117            return true;
4118        }
4119        let Some(last_in_requested_window) = collapsed.get(fetch_limit - 1) else {
4120            return true;
4121        };
4122        !last_in_requested_window
4123            .score
4124            .total_cmp(&max_omitted_score)
4125            .is_gt()
4126    }
4127
4128    fn record_fs_semantic_hit(
4129        best_by_message: &mut HashMap<u64, VectorSearchResult>,
4130        hit: &FsVectorHit,
4131    ) {
4132        let Some(parsed) = parse_semantic_doc_id(&hit.doc_id) else {
4133            return;
4134        };
4135        best_by_message
4136            .entry(parsed.message_id)
4137            .and_modify(|entry| {
4138                if hit.score > entry.score {
4139                    entry.score = hit.score;
4140                    entry.chunk_idx = parsed.chunk_idx;
4141                }
4142            })
4143            .or_insert(VectorSearchResult {
4144                message_id: parsed.message_id,
4145                chunk_idx: parsed.chunk_idx,
4146                score: hit.score,
4147            });
4148    }
4149
4150    fn search_exact_semantic_indexes(
4151        context: &SemanticCandidateContext,
4152        embedding: &[f32],
4153        fetch_limit: usize,
4154        fs_filter: Option<&dyn FsSearchFilter>,
4155    ) -> Result<(Vec<VectorSearchResult>, SemanticCandidateRetryState)> {
4156        if context.fs_semantic_indexes.len() == 1 {
4157            let record_count = context.fs_semantic_index.record_count();
4158            let candidate_limit = Self::semantic_exact_candidate_limit(fetch_limit, record_count);
4159            let fs_hits = context
4160                .fs_semantic_index
4161                .search_top_k(embedding, candidate_limit, fs_filter)
4162                .map_err(|err| anyhow!("frankensearch semantic search failed: {err}"))?;
4163            let mut best_by_message = HashMap::with_capacity(fs_hits.len());
4164            for hit in &fs_hits {
4165                Self::record_fs_semantic_hit(&mut best_by_message, hit);
4166            }
4167            let collapsed = Self::collapse_semantic_results(best_by_message, candidate_limit);
4168            let has_more_candidates =
4169                fs_hits.len() >= candidate_limit && candidate_limit < record_count;
4170            let max_omitted_score = if has_more_candidates {
4171                fs_hits.last().map(|hit| hit.score)
4172            } else {
4173                None
4174            };
4175            let exact_window_may_omit_competitor = Self::semantic_window_may_omit_competitor(
4176                &collapsed,
4177                fetch_limit,
4178                max_omitted_score,
4179            );
4180            return Ok((
4181                collapsed,
4182                SemanticCandidateRetryState {
4183                    has_more_candidates,
4184                    exact_window_may_omit_competitor,
4185                },
4186            ));
4187        }
4188
4189        let mut best_by_message = HashMap::new();
4190        let mut raw_hits = 0usize;
4191        let mut max_omitted_score: Option<f32> = None;
4192        let mut has_more_candidates = false;
4193        for index in context.fs_semantic_indexes.iter() {
4194            let shard_record_count = index.record_count();
4195            // Search chunks, then collapse by message. A message can have many
4196            // high-scoring chunks, so per-shard top-k chunks alone is not a
4197            // proof of per-message top-k. Use a bounded overfetch window and
4198            // retry only when the omitted-score bound can still beat the last
4199            // collapsed message in the requested window.
4200            let shard_limit = Self::semantic_exact_candidate_limit(fetch_limit, shard_record_count);
4201            if shard_limit == 0 {
4202                continue;
4203            }
4204            let fs_hits = index
4205                .search_top_k(embedding, shard_limit, fs_filter)
4206                .map_err(|err| anyhow!("frankensearch sharded semantic search failed: {err}"))?;
4207            if fs_hits.len() >= shard_limit
4208                && shard_limit < shard_record_count
4209                && let Some(last_hit) = fs_hits.last()
4210            {
4211                has_more_candidates = true;
4212                max_omitted_score = Some(
4213                    max_omitted_score
4214                        .map(|current| current.max(last_hit.score))
4215                        .unwrap_or(last_hit.score),
4216                );
4217            }
4218            raw_hits = raw_hits.saturating_add(fs_hits.len());
4219            best_by_message.reserve(fs_hits.len());
4220            for hit in &fs_hits {
4221                Self::record_fs_semantic_hit(&mut best_by_message, hit);
4222            }
4223        }
4224        let candidate_return_limit = Self::semantic_exact_candidate_limit(fetch_limit, raw_hits);
4225        let collapsed = Self::collapse_semantic_results(best_by_message, candidate_return_limit);
4226        let exact_window_may_omit_competitor =
4227            Self::semantic_window_may_omit_competitor(&collapsed, fetch_limit, max_omitted_score);
4228        tracing::debug!(
4229            shard_count = context.fs_semantic_indexes.len(),
4230            raw_hits,
4231            returned = collapsed.len(),
4232            "semantic sharded exact merge complete"
4233        );
4234        Ok((
4235            collapsed,
4236            SemanticCandidateRetryState {
4237                has_more_candidates,
4238                exact_window_may_omit_competitor,
4239            },
4240        ))
4241    }
4242
4243    fn search_semantic_candidates(
4244        &self,
4245        context: &SemanticCandidateContext,
4246        embedding: &[f32],
4247        filters: &SearchFilters,
4248        request: SemanticCandidateSearchRequest<'_>,
4249    ) -> Result<(
4250        Vec<VectorSearchResult>,
4251        SemanticCandidateRetryState,
4252        Option<crate::search::ann_index::AnnSearchStats>,
4253    )> {
4254        let mut semantic_filter =
4255            SemanticFilter::from_search_filters(filters, &context.filter_maps)?;
4256        if let Some(roles) = context.roles.clone() {
4257            semantic_filter = semantic_filter.with_roles(Some(roles));
4258        }
4259
4260        if request.tier_mode.wants_two_tier() && !request.approximate {
4261            let fs_filter = semantic_filter_as_search_filter(&semantic_filter);
4262            if let Some(two_tier_index) = request.in_memory_two_tier_index {
4263                let config = request.tier_mode.to_frankensearch_config();
4264                let searcher = FsSyncTwoTierSearcher::new(Arc::clone(two_tier_index), config);
4265                let (tier_hits, metrics) = searcher
4266                    .search_collect_with_filter(embedding, request.fetch_limit, fs_filter)
4267                    .map_err(|err| {
4268                        anyhow!("frankensearch two-tier semantic search failed: {err}")
4269                    })?;
4270
4271                tracing::debug!(
4272                    tier_mode = ?request.tier_mode,
4273                    phase1_ms = metrics.phase1_total_ms,
4274                    phase2_ms = metrics.phase2_total_ms,
4275                    skip_reason = ?metrics.skip_reason,
4276                    returned = tier_hits.len(),
4277                    "semantic two-tier search executed"
4278                );
4279
4280                let mut best_by_message: HashMap<u64, VectorSearchResult> =
4281                    HashMap::with_capacity(tier_hits.len());
4282                for hit in tier_hits.iter() {
4283                    let Some(parsed) = parse_semantic_doc_id(&hit.doc_id) else {
4284                        continue;
4285                    };
4286                    best_by_message
4287                        .entry(parsed.message_id)
4288                        .and_modify(|entry| {
4289                            if hit.score > entry.score {
4290                                entry.score = hit.score;
4291                                entry.chunk_idx = parsed.chunk_idx;
4292                            }
4293                        })
4294                        .or_insert(VectorSearchResult {
4295                            message_id: parsed.message_id,
4296                            chunk_idx: parsed.chunk_idx,
4297                            score: hit.score,
4298                        });
4299                }
4300
4301                return Ok((
4302                    Self::collapse_semantic_results(best_by_message, request.fetch_limit),
4303                    SemanticCandidateRetryState {
4304                        has_more_candidates: tier_hits.len() >= request.fetch_limit,
4305                        exact_window_may_omit_competitor: false,
4306                    },
4307                    None,
4308                ));
4309            }
4310
4311            tracing::debug!(
4312                tier_mode = ?request.tier_mode,
4313                "two-tier semantic unavailable; falling back to exact single-tier search"
4314            );
4315
4316            let fs_filter = semantic_filter_as_search_filter(&semantic_filter);
4317            let (results, truncated) = Self::search_exact_semantic_indexes(
4318                context,
4319                embedding,
4320                request.fetch_limit,
4321                fs_filter,
4322            )?;
4323            return Ok((results, truncated, None));
4324        }
4325
4326        if request.approximate {
4327            if request.tier_mode.wants_two_tier() {
4328                tracing::debug!(
4329                    tier_mode = ?request.tier_mode,
4330                    "approximate search requested; bypassing two-tier mode"
4331                );
4332            }
4333
4334            let ann = request
4335                .ann_index
4336                .ok_or_else(|| anyhow!("HNSW index failed to initialize"))?;
4337            let candidate = request
4338                .fetch_limit
4339                .saturating_mul(ANN_CANDIDATE_MULTIPLIER)
4340                .max(request.fetch_limit);
4341            let ef = FS_HNSW_DEFAULT_EF_SEARCH.max(candidate);
4342            let (ann_results, search_stats) =
4343                ann.knn_search_with_stats(embedding, candidate, ef)
4344                    .map_err(|err| anyhow!("frankensearch approximate search failed: {err}"))?;
4345            let ann_stats = Some(crate::search::ann_index::AnnSearchStats {
4346                index_size: search_stats.index_size,
4347                dimension: search_stats.dimension,
4348                ef_search: search_stats.ef_search,
4349                k_requested: search_stats.k_requested,
4350                k_returned: search_stats.k_returned,
4351                search_time_us: search_stats.search_time_us,
4352                estimated_recall: search_stats.estimated_recall as f32,
4353                is_approximate: search_stats.is_approximate,
4354            });
4355
4356            let fs_filter = semantic_filter_as_search_filter(&semantic_filter);
4357
4358            let mut best_by_message: HashMap<u64, VectorSearchResult> =
4359                HashMap::with_capacity(ann_results.len());
4360            for hit in ann_results.iter() {
4361                if let Some(filter) = fs_filter
4362                    && !filter.matches(&hit.doc_id, None)
4363                {
4364                    continue;
4365                }
4366                let Some(parsed) = parse_semantic_doc_id(&hit.doc_id) else {
4367                    continue;
4368                };
4369                best_by_message
4370                    .entry(parsed.message_id)
4371                    .and_modify(|entry| {
4372                        if hit.score > entry.score {
4373                            entry.score = hit.score;
4374                            entry.chunk_idx = parsed.chunk_idx;
4375                        }
4376                    })
4377                    .or_insert(VectorSearchResult {
4378                        message_id: parsed.message_id,
4379                        chunk_idx: parsed.chunk_idx,
4380                        score: hit.score,
4381                    });
4382            }
4383
4384            return Ok((
4385                Self::collapse_semantic_results(best_by_message, request.fetch_limit),
4386                SemanticCandidateRetryState {
4387                    has_more_candidates: ann_results.len() >= candidate,
4388                    exact_window_may_omit_competitor: false,
4389                },
4390                ann_stats,
4391            ));
4392        }
4393
4394        let fs_filter = semantic_filter_as_search_filter(&semantic_filter);
4395        let (results, truncated) = Self::search_exact_semantic_indexes(
4396            context,
4397            embedding,
4398            request.fetch_limit,
4399            fs_filter,
4400        )?;
4401        Ok((results, truncated, None))
4402    }
4403
4404    pub fn can_progressively_refine(&self) -> bool {
4405        self.progressive_context()
4406            .map(|context| {
4407                context.as_ref().is_some_and(|ctx| {
4408                    ctx.quality_embedder.is_some() && ctx.index.has_quality_index()
4409                })
4410            })
4411            .unwrap_or(false)
4412    }
4413
4414    fn progressive_context(&self) -> Result<Option<Arc<ProgressiveTwoTierContext>>> {
4415        loop {
4416            let (ann_path, embedder, context_token) = {
4417                let mut guard = self
4418                    .semantic
4419                    .lock()
4420                    .map_err(|_| anyhow!("semantic lock poisoned"))?;
4421                let state = guard.as_mut().ok_or_else(|| {
4422                    anyhow!("semantic search unavailable (no embedder or vector index)")
4423                })?;
4424                if let Some(context) = state.progressive_context.as_ref() {
4425                    return Ok(Some(Arc::clone(context)));
4426                }
4427                if state.progressive_context_unavailable {
4428                    return Ok(None);
4429                }
4430                (
4431                    state.ann_path.clone(),
4432                    Arc::clone(&state.embedder),
4433                    Arc::clone(&state.context_token),
4434                )
4435            };
4436
4437            let context = match self.build_progressive_context(
4438                ann_path.clone(),
4439                embedder,
4440                Arc::clone(&context_token),
4441            ) {
4442                Ok(context) => context,
4443                Err(err) => {
4444                    let mut guard = self
4445                        .semantic
4446                        .lock()
4447                        .map_err(|_| anyhow!("semantic lock poisoned"))?;
4448                    let state = guard.as_mut().ok_or_else(|| {
4449                        anyhow!("semantic search unavailable (no embedder or vector index)")
4450                    })?;
4451                    if let Some(existing) = state.progressive_context.as_ref() {
4452                        return Ok(Some(Arc::clone(existing)));
4453                    }
4454                    if !Arc::ptr_eq(&state.context_token, &context_token) {
4455                        continue;
4456                    }
4457                    return Err(err);
4458                }
4459            };
4460
4461            let Some(context) = context else {
4462                let mut guard = self
4463                    .semantic
4464                    .lock()
4465                    .map_err(|_| anyhow!("semantic lock poisoned"))?;
4466                let state = guard.as_mut().ok_or_else(|| {
4467                    anyhow!("semantic search unavailable (no embedder or vector index)")
4468                })?;
4469                if let Some(existing) = state.progressive_context.as_ref() {
4470                    return Ok(Some(Arc::clone(existing)));
4471                }
4472                if !Arc::ptr_eq(&state.context_token, &context_token) {
4473                    continue;
4474                }
4475                state.progressive_context_unavailable = true;
4476                return Ok(None);
4477            };
4478
4479            let mut guard = self
4480                .semantic
4481                .lock()
4482                .map_err(|_| anyhow!("semantic lock poisoned"))?;
4483            let state = guard.as_mut().ok_or_else(|| {
4484                anyhow!("semantic search unavailable (no embedder or vector index)")
4485            })?;
4486            if let Some(existing) = state.progressive_context.as_ref() {
4487                return Ok(Some(Arc::clone(existing)));
4488            }
4489            if !Arc::ptr_eq(&state.context_token, &context_token) {
4490                continue;
4491            }
4492            state.progressive_context_unavailable = false;
4493            state.progressive_context = Some(Arc::clone(&context));
4494            return Ok(Some(context));
4495        }
4496    }
4497
4498    fn build_progressive_context(
4499        &self,
4500        ann_path: Option<PathBuf>,
4501        embedder: Arc<dyn Embedder>,
4502        context_token: Arc<()>,
4503    ) -> Result<Option<Arc<ProgressiveTwoTierContext>>> {
4504        let Some(index_dir) = ann_path
4505            .as_ref()
4506            .and_then(|path| path.parent().map(Path::to_path_buf))
4507        else {
4508            return Ok(None);
4509        };
4510
4511        let fast_path = {
4512            let explicit = index_dir.join("vector.fast.idx");
4513            if explicit.is_file() {
4514                explicit
4515            } else {
4516                let fallback = index_dir.join("vector.idx");
4517                if fallback.is_file() {
4518                    fallback
4519                } else {
4520                    return Ok(None);
4521                }
4522            }
4523        };
4524        let quality_path = index_dir.join("vector.quality.idx");
4525        if !quality_path.is_file() {
4526            return Ok(None);
4527        }
4528
4529        let fast_index = FsVectorIndex::open(&fast_path)
4530            .map_err(|err| anyhow!("open fast-tier index failed: {err}"))?;
4531        let quality_index = FsVectorIndex::open(&quality_path)
4532            .map_err(|err| anyhow!("open quality-tier index failed: {err}"))?;
4533        let index = Arc::new(
4534            FsTwoTierIndex::open(&index_dir, frankensearch_two_tier_config())
4535                .map_err(|err| anyhow!("open progressive two-tier index failed: {err}"))?,
4536        );
4537
4538        let fast_embedder = self.load_embedder_for_progressive_id(
4539            &embedder,
4540            fast_index.embedder_id(),
4541            fast_index.dimension(),
4542        )?;
4543        let fast_embedder: Arc<dyn frankensearch::Embedder> = Arc::new(FsSyncEmbedderAdapter(
4544            SharedCassSyncEmbedder::new(fast_embedder),
4545        ));
4546        let quality_embedder = Some(self.load_embedder_for_progressive_id(
4547            &embedder,
4548            quality_index.embedder_id(),
4549            quality_index.dimension(),
4550        )?);
4551        let quality_embedder = quality_embedder.map(|embedder| {
4552            Arc::new(FsSyncEmbedderAdapter(SharedCassSyncEmbedder::new(embedder)))
4553                as Arc<dyn frankensearch::Embedder>
4554        });
4555
4556        Ok(Some(Arc::new(ProgressiveTwoTierContext {
4557            context_token,
4558            index,
4559            fast_embedder,
4560            quality_embedder,
4561        })))
4562    }
4563
4564    fn load_embedder_for_progressive_id(
4565        &self,
4566        current_embedder: &Arc<dyn Embedder>,
4567        embedder_id: &str,
4568        dimension: usize,
4569    ) -> Result<Arc<dyn Embedder>> {
4570        if current_embedder.id() == embedder_id {
4571            return Ok(Arc::clone(current_embedder));
4572        }
4573
4574        if let Some(dim) = embedder_id.strip_prefix("fnv1a-")
4575            && let Ok(parsed) = dim.parse::<usize>()
4576        {
4577            return Ok(Arc::new(crate::search::hash_embedder::HashEmbedder::new(
4578                parsed.max(dimension),
4579            )));
4580        }
4581
4582        if let Some(embedder_name) =
4583            crate::search::fastembed_embedder::FastEmbedder::canonical_name(embedder_id)
4584        {
4585            let data_dir = self
4586                .sqlite_path
4587                .as_ref()
4588                .and_then(|path| path.parent())
4589                .ok_or_else(|| anyhow!("cannot resolve data dir for progressive embedder load"))?;
4590            let embedder = crate::search::fastembed_embedder::FastEmbedder::load_by_name(
4591                data_dir,
4592                embedder_name,
4593            )
4594            .with_context(|| format!("loading FastEmbed model for {embedder_name}"))?;
4595            if embedder.dimension() != dimension {
4596                bail!(
4597                    "progressive embedder dimension mismatch: {} index expects {}, model has {}",
4598                    embedder_id,
4599                    dimension,
4600                    embedder.dimension()
4601                );
4602            }
4603            return Ok(Arc::new(embedder));
4604        }
4605
4606        bail!("unsupported progressive embedder id: {embedder_id}");
4607    }
4608
4609    fn resolve_semantic_doc_ids_for_hits(
4610        &self,
4611        hits: &[SearchHit],
4612    ) -> Result<Vec<Option<ResolvedSemanticDocId>>> {
4613        if hits.is_empty() {
4614            return Ok(Vec::new());
4615        }
4616
4617        let lookup_keys: Vec<Option<ProgressiveLookupKey>> = hits
4618            .iter()
4619            .map(|hit| {
4620                let idx = hit
4621                    .line_number
4622                    .and_then(|line| line.checked_sub(1))
4623                    .map(i64::try_from)
4624                    .transpose()
4625                    .ok()
4626                    .flatten()?;
4627                Some((
4628                    normalized_search_hit_source_id(hit),
4629                    hit.source_path.clone(),
4630                    hit.conversation_id,
4631                    hit.title.trim().to_string(),
4632                    idx,
4633                    hit.created_at,
4634                    hit.content_hash,
4635                ))
4636            })
4637            .collect();
4638
4639        let mut seen_exact = HashSet::new();
4640        let mut exact_query_keys = Vec::new();
4641        let mut seen_fallback = HashSet::new();
4642        let mut fallback_query_keys = Vec::new();
4643        for (source_id, source_path, conversation_id, _title, idx, _created_at, _content_hash) in
4644            lookup_keys.iter().flatten()
4645        {
4646            if let Some(conversation_id) = conversation_id {
4647                let query_key: ProgressiveExactQueryKey = (*conversation_id, *idx);
4648                if seen_exact.insert(query_key) {
4649                    exact_query_keys.push(query_key);
4650                }
4651            } else {
4652                let query_key: ProgressiveFallbackQueryKey =
4653                    (source_id.clone(), source_path.clone(), *idx);
4654                if seen_fallback.insert(query_key.clone()) {
4655                    fallback_query_keys.push(query_key);
4656                }
4657            }
4658        }
4659
4660        if exact_query_keys.is_empty() && fallback_query_keys.is_empty() {
4661            return Ok(vec![None; hits.len()]);
4662        }
4663
4664        let sqlite_guard = self.sqlite_guard()?;
4665        let conn = sqlite_guard
4666            .as_ref()
4667            .ok_or_else(|| anyhow!("progressive search requires database connection"))?;
4668
4669        let mut resolved_by_key = HashMap::new();
4670        let normalized_source_sql =
4671            normalized_search_source_id_sql_expr("c.source_id", "s.kind", "c.origin_host");
4672
4673        const CHUNK_SIZE: usize = 300;
4674        for chunk in exact_query_keys.chunks(CHUNK_SIZE) {
4675            let mut sql = String::from("SELECT c.id, ");
4676            sql.push_str(&normalized_source_sql);
4677            sql.push_str(
4678                ", c.source_path, m.idx, m.id, c.agent_id, c.workspace_id, m.role, m.created_at, m.content, c.title
4679                 FROM messages m
4680                 JOIN conversations c ON m.conversation_id = c.id
4681                 LEFT JOIN sources s ON c.source_id = s.id
4682                 WHERE ",
4683            );
4684            let mut params = Vec::with_capacity(chunk.len().saturating_mul(2));
4685            for (idx, (conversation_id, line_idx)) in chunk.iter().enumerate() {
4686                if idx > 0 {
4687                    sql.push_str(" OR ");
4688                }
4689                sql.push_str("(c.id = ? AND m.idx = ?)");
4690                params.push(ParamValue::from(*conversation_id));
4691                params.push(ParamValue::from(*line_idx));
4692            }
4693
4694            let chunk_rows: Vec<ResolvedSemanticLookupRow> =
4695                conn.query_map_collect(&sql, &params, |row: &frankensqlite::Row| {
4696                    let conversation_id: i64 = row.get_typed(0)?;
4697                    let source_id: String = row.get_typed(1)?;
4698                    let source_path: String = row.get_typed(2)?;
4699                    let idx: i64 = row.get_typed(3)?;
4700                    let message_id_raw: i64 = row.get_typed(4)?;
4701                    // agent_id is nullable for legacy V1 conversations; treat
4702                    // NULL the same as the negative-sentinel branch below (0).
4703                    let agent_id_raw: Option<i64> = row.get_typed(5)?;
4704                    let workspace_id_raw: Option<i64> = row.get_typed(6)?;
4705                    let role_raw: String = row.get_typed(7)?;
4706                    let created_at_ms: Option<i64> = row.get_typed(8)?;
4707                    let content: String = row.get_typed(9)?;
4708                    let title: Option<String> = row.get_typed(10)?;
4709
4710                    let canonical = canonicalize_for_embedding(&content);
4711                    if canonical.is_empty() {
4712                        return Ok(None);
4713                    }
4714
4715                    let message_id = u64::try_from(message_id_raw).map_err(|_| {
4716                        std::io::Error::other("message id out of range for progressive doc_id")
4717                    })?;
4718                    let agent_id = semantic_doc_component_id_from_db(agent_id_raw);
4719                    let workspace_id = semantic_doc_component_id_from_db(workspace_id_raw);
4720                    let role = role_code_from_str(&role_raw).unwrap_or(ROLE_USER);
4721                    let doc_id = SemanticDocId {
4722                        message_id,
4723                        chunk_idx: 0,
4724                        agent_id,
4725                        workspace_id,
4726                        source_id: crc32fast::hash(source_id.as_bytes()),
4727                        role,
4728                        created_at_ms: created_at_ms.unwrap_or(0),
4729                        content_hash: Some(content_hash(&canonical)),
4730                    }
4731                    .to_doc_id_string();
4732                    let line_number = usize::try_from(idx).ok().map(|line| line.saturating_add(1));
4733                    let lookup_key = (
4734                        source_id,
4735                        source_path.clone(),
4736                        Some(conversation_id),
4737                        title.unwrap_or_default().trim().to_string(),
4738                        idx,
4739                        created_at_ms,
4740                        stable_hit_hash(&content, &source_path, line_number, created_at_ms),
4741                    );
4742
4743                    Ok(Some((
4744                        lookup_key,
4745                        ResolvedSemanticDocId { message_id, doc_id },
4746                    )))
4747                })?;
4748
4749            for row in chunk_rows.into_iter().flatten() {
4750                resolved_by_key.insert(row.0, row.1);
4751            }
4752        }
4753
4754        for chunk in fallback_query_keys.chunks(CHUNK_SIZE) {
4755            let mut sql = String::from("SELECT ");
4756            sql.push_str(&normalized_source_sql);
4757            sql.push_str(
4758                ", c.source_path, m.idx, m.id, c.agent_id, c.workspace_id, m.role, m.created_at, m.content, c.title
4759                 FROM messages m
4760                 JOIN conversations c ON m.conversation_id = c.id
4761                 LEFT JOIN sources s ON c.source_id = s.id
4762                 WHERE ",
4763            );
4764            let mut params = Vec::with_capacity(chunk.len().saturating_mul(3));
4765            for (idx, (source_id, source_path, line_idx)) in chunk.iter().enumerate() {
4766                if idx > 0 {
4767                    sql.push_str(" OR ");
4768                }
4769                sql.push_str(&format!(
4770                    "({normalized_source_sql} = ? AND c.source_path = ? AND m.idx = ?)"
4771                ));
4772                params.push(ParamValue::from(normalize_search_source_filter_value(
4773                    source_id,
4774                )));
4775                params.push(ParamValue::from(source_path.clone()));
4776                params.push(ParamValue::from(*line_idx));
4777            }
4778
4779            let chunk_rows: Vec<ResolvedSemanticLookupRow> =
4780                conn.query_map_collect(&sql, &params, |row: &frankensqlite::Row| {
4781                    let source_id: String = row.get_typed(0)?;
4782                    let source_path: String = row.get_typed(1)?;
4783                    let idx: i64 = row.get_typed(2)?;
4784                    let message_id_raw: i64 = row.get_typed(3)?;
4785                    // agent_id is nullable for legacy V1 conversations; treat
4786                    // NULL the same as the negative-sentinel branch below (0).
4787                    let agent_id_raw: Option<i64> = row.get_typed(4)?;
4788                    let workspace_id_raw: Option<i64> = row.get_typed(5)?;
4789                    let role_raw: String = row.get_typed(6)?;
4790                    let created_at_ms: Option<i64> = row.get_typed(7)?;
4791                    let content: String = row.get_typed(8)?;
4792                    let title: Option<String> = row.get_typed(9)?;
4793
4794                    let canonical = canonicalize_for_embedding(&content);
4795                    if canonical.is_empty() {
4796                        return Ok(None);
4797                    }
4798
4799                    let message_id = u64::try_from(message_id_raw).map_err(|_| {
4800                        std::io::Error::other("message id out of range for progressive doc_id")
4801                    })?;
4802                    let agent_id = semantic_doc_component_id_from_db(agent_id_raw);
4803                    let workspace_id = semantic_doc_component_id_from_db(workspace_id_raw);
4804                    let role = role_code_from_str(&role_raw).unwrap_or(ROLE_USER);
4805                    let doc_id = SemanticDocId {
4806                        message_id,
4807                        chunk_idx: 0,
4808                        agent_id,
4809                        workspace_id,
4810                        source_id: crc32fast::hash(source_id.as_bytes()),
4811                        role,
4812                        created_at_ms: created_at_ms.unwrap_or(0),
4813                        content_hash: Some(content_hash(&canonical)),
4814                    }
4815                    .to_doc_id_string();
4816                    let line_number = usize::try_from(idx).ok().map(|line| line.saturating_add(1));
4817                    let lookup_key = (
4818                        source_id,
4819                        source_path.clone(),
4820                        None,
4821                        title.unwrap_or_default().trim().to_string(),
4822                        idx,
4823                        created_at_ms,
4824                        stable_hit_hash(&content, &source_path, line_number, created_at_ms),
4825                    );
4826
4827                    Ok(Some((
4828                        lookup_key,
4829                        ResolvedSemanticDocId { message_id, doc_id },
4830                    )))
4831                })?;
4832
4833            for row in chunk_rows.into_iter().flatten() {
4834                resolved_by_key.insert(row.0, row.1);
4835            }
4836        }
4837
4838        Ok(lookup_keys
4839            .into_iter()
4840            .map(|key| key.and_then(|lookup| resolved_by_key.get(&lookup).cloned()))
4841            .collect())
4842    }
4843
4844    fn load_message_text_by_id(&self, message_id: u64) -> Result<Option<String>> {
4845        let sqlite_guard = self.sqlite_guard()?;
4846        let conn = sqlite_guard
4847            .as_ref()
4848            .ok_or_else(|| anyhow!("progressive search requires database connection"))?;
4849        let rows: Vec<String> = conn.query_map_collect(
4850            "SELECT content FROM messages WHERE id = ?",
4851            &[ParamValue::from(i64::try_from(message_id)?)],
4852            |row: &frankensqlite::Row| row.get_typed(0),
4853        )?;
4854        Ok(rows.into_iter().next())
4855    }
4856
4857    fn collapse_progressive_scored_results(
4858        &self,
4859        results: &[FsScoredResult],
4860        fetch_limit: usize,
4861    ) -> Vec<VectorSearchResult> {
4862        let fetch = fetch_limit.max(1);
4863        let mut best_by_message: HashMap<u64, VectorSearchResult> =
4864            HashMap::with_capacity(results.len());
4865        for hit in results {
4866            let Some(parsed) = parse_semantic_doc_id(&hit.doc_id) else {
4867                continue;
4868            };
4869            best_by_message
4870                .entry(parsed.message_id)
4871                .and_modify(|entry| {
4872                    if hit.score > entry.score {
4873                        entry.score = hit.score;
4874                        entry.chunk_idx = parsed.chunk_idx;
4875                    }
4876                })
4877                .or_insert(VectorSearchResult {
4878                    message_id: parsed.message_id,
4879                    chunk_idx: parsed.chunk_idx,
4880                    score: hit.score,
4881                });
4882        }
4883        let mut collapsed: Vec<VectorSearchResult> = best_by_message.into_values().collect();
4884        collapsed.sort_by(|a, b| {
4885            b.score
4886                .total_cmp(&a.score)
4887                .then_with(|| a.message_id.cmp(&b.message_id))
4888        });
4889        if collapsed.len() > fetch {
4890            collapsed.truncate(fetch);
4891        }
4892        collapsed
4893    }
4894
4895    fn hydrate_semantic_hits_with_ids(
4896        &self,
4897        results: &[VectorSearchResult],
4898        field_mask: FieldMask,
4899    ) -> Result<Vec<(u64, SearchHit)>> {
4900        if results.is_empty() {
4901            return Ok(Vec::new());
4902        }
4903        let sqlite_guard = self.sqlite_guard()?;
4904        let conn = sqlite_guard
4905            .as_ref()
4906            .ok_or_else(|| anyhow!("semantic search requires database connection"))?;
4907
4908        #[derive(Debug)]
4909        struct MessageHydrationRow {
4910            message_id: u64,
4911            conversation_id: i64,
4912            full_content: String,
4913            msg_created_at: Option<i64>,
4914            idx: Option<i64>,
4915        }
4916
4917        #[derive(Debug)]
4918        struct ConversationHydrationRow {
4919            title: Option<String>,
4920            source_path: String,
4921            source_id: String,
4922            origin_host: Option<String>,
4923            agent: String,
4924            workspace: Option<String>,
4925            origin_kind: Option<String>,
4926            started_at: Option<i64>,
4927        }
4928
4929        let mut unique_message_ids = Vec::with_capacity(results.len());
4930        let mut seen_message_ids = HashSet::with_capacity(results.len());
4931        for result in results {
4932            if seen_message_ids.insert(result.message_id) {
4933                unique_message_ids.push(result.message_id);
4934            }
4935        }
4936
4937        let message_placeholder_capacity =
4938            unique_message_ids.len().saturating_mul(2).saturating_sub(1);
4939        let mut message_placeholders = String::with_capacity(message_placeholder_capacity);
4940        let mut message_params: Vec<ParamValue> = Vec::with_capacity(unique_message_ids.len());
4941        for (idx, message_id) in unique_message_ids.iter().enumerate() {
4942            if idx > 0 {
4943                message_placeholders.push(',');
4944            }
4945            message_placeholders.push('?');
4946            message_params.push(ParamValue::from(i64::try_from(*message_id)?));
4947        }
4948
4949        let message_sql = format!(
4950            "SELECT id, conversation_id, content, created_at, idx
4951             FROM messages
4952             WHERE id IN ({message_placeholders})"
4953        );
4954
4955        let message_rows: Vec<MessageHydrationRow> =
4956            conn.query_map_collect(&message_sql, &message_params, |row: &frankensqlite::Row| {
4957                let message_id: i64 = row.get_typed(0)?;
4958                Ok(MessageHydrationRow {
4959                    message_id: semantic_message_id_from_db(message_id)?,
4960                    conversation_id: row.get_typed(1)?,
4961                    full_content: row.get_typed(2)?,
4962                    msg_created_at: row.get_typed(3)?,
4963                    idx: row.get_typed(4)?,
4964                })
4965            })?;
4966        if message_rows.is_empty() {
4967            return Ok(Vec::new());
4968        }
4969
4970        let title_expr = if field_mask.wants_title() {
4971            "c.title"
4972        } else {
4973            "''"
4974        };
4975        let normalized_source_sql =
4976            normalized_search_source_id_sql_expr("c.source_id", "s.kind", "c.origin_host");
4977        let mut conversation_ids = Vec::with_capacity(message_rows.len());
4978        let mut seen_conversation_ids = HashSet::with_capacity(message_rows.len());
4979        for row in &message_rows {
4980            if seen_conversation_ids.insert(row.conversation_id) {
4981                conversation_ids.push(row.conversation_id);
4982            }
4983        }
4984        let conversation_placeholder_capacity =
4985            conversation_ids.len().saturating_mul(2).saturating_sub(1);
4986        let mut conversation_placeholders =
4987            String::with_capacity(conversation_placeholder_capacity);
4988        let mut conversation_params: Vec<ParamValue> = Vec::with_capacity(conversation_ids.len());
4989        for (idx, conversation_id) in conversation_ids.iter().enumerate() {
4990            if idx > 0 {
4991                conversation_placeholders.push(',');
4992            }
4993            conversation_placeholders.push('?');
4994            conversation_params.push(ParamValue::from(*conversation_id));
4995        }
4996        // LEFT JOIN + COALESCE on agents so search hits for conversations
4997        // with NULL agent_id (legacy V1 schema) still surface instead of
4998        // being silently dropped from results.  Consistent with the fts/
4999        // lexical rebuild paths (8a0c547c, e1c08e7c).
5000        let sql = format!(
5001            "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
5002             FROM conversations c
5003             LEFT JOIN agents a ON c.agent_id = a.id
5004             LEFT JOIN workspaces w ON c.workspace_id = w.id
5005             LEFT JOIN sources s ON c.source_id = s.id
5006             WHERE c.id IN ({conversation_placeholders})"
5007        );
5008
5009        let conversation_rows: Vec<(i64, ConversationHydrationRow)> =
5010            conn.query_map_collect(&sql, &conversation_params, |row: &frankensqlite::Row| {
5011                let conversation_id: i64 = row.get_typed(0)?;
5012                let title: Option<String> = if field_mask.wants_title() {
5013                    row.get_typed(1)?
5014                } else {
5015                    None
5016                };
5017                Ok((
5018                    conversation_id,
5019                    ConversationHydrationRow {
5020                        title,
5021                        source_path: row.get_typed(2)?,
5022                        source_id: row.get_typed(3)?,
5023                        origin_host: row.get_typed(4)?,
5024                        agent: row.get_typed(5)?,
5025                        workspace: row.get_typed(6)?,
5026                        origin_kind: row.get_typed(7)?,
5027                        started_at: row.get_typed(8)?,
5028                    },
5029                ))
5030            })?;
5031
5032        let conversations_by_id: HashMap<i64, ConversationHydrationRow> =
5033            conversation_rows.into_iter().collect();
5034
5035        let rows: Vec<(u64, SearchHit)> = message_rows
5036            .into_iter()
5037            .filter_map(|message| {
5038                let conversation = conversations_by_id.get(&message.conversation_id)?;
5039
5040                let created_at = message.msg_created_at.or(conversation.started_at);
5041                let line_number = message
5042                    .idx
5043                    .and_then(|i| usize::try_from(i).ok())
5044                    .map(|i| i.saturating_add(1));
5045                let snippet = if field_mask.wants_snippet() {
5046                    snippet_from_content(&message.full_content)
5047                } else {
5048                    String::new()
5049                };
5050                let content = if field_mask.needs_content() {
5051                    message.full_content.clone()
5052                } else {
5053                    String::new()
5054                };
5055                let content_hash = stable_hit_hash(
5056                    &message.full_content,
5057                    &conversation.source_path,
5058                    line_number,
5059                    created_at,
5060                );
5061                let source_id = normalized_search_hit_source_id_parts(
5062                    conversation.source_id.as_str(),
5063                    conversation.origin_kind.as_deref().unwrap_or_default(),
5064                    conversation.origin_host.as_deref(),
5065                );
5066                let origin_kind = normalized_search_hit_origin_kind(
5067                    &source_id,
5068                    conversation.origin_kind.as_deref(),
5069                );
5070
5071                let hit = SearchHit {
5072                    title: if field_mask.wants_title() {
5073                        conversation.title.clone().unwrap_or_default()
5074                    } else {
5075                        String::new()
5076                    },
5077                    snippet,
5078                    content,
5079                    content_hash,
5080                    conversation_id: Some(message.conversation_id),
5081                    score: 0.0,
5082                    source_path: conversation.source_path.clone(),
5083                    agent: conversation.agent.clone(),
5084                    workspace: conversation.workspace.clone().unwrap_or_default(),
5085                    workspace_original: None,
5086                    created_at,
5087                    line_number,
5088                    match_type: MatchType::Exact,
5089                    source_id,
5090                    origin_kind,
5091                    origin_host: conversation.origin_host.clone(),
5092                };
5093
5094                Some((message.message_id, hit))
5095            })
5096            .collect();
5097
5098        let mut hits_by_id = HashMap::new();
5099        for (id, hit) in rows {
5100            hits_by_id.insert(id, hit);
5101        }
5102
5103        let mut ordered = Vec::new();
5104        for result in results {
5105            if let Some(mut hit) = hits_by_id.remove(&result.message_id) {
5106                hit.score = result.score;
5107                ordered.push((result.message_id, hit));
5108            }
5109        }
5110
5111        Ok(ordered)
5112    }
5113
5114    fn overlay_progressive_lexical_hit(
5115        &self,
5116        hit: &mut SearchHit,
5117        lexical: &ProgressiveLexicalHit,
5118        field_mask: FieldMask,
5119    ) {
5120        if field_mask.wants_title() && !lexical.title.is_empty() {
5121            hit.title = lexical.title.clone();
5122        }
5123        if field_mask.wants_snippet() && !lexical.snippet.is_empty() {
5124            hit.snippet = lexical.snippet.clone();
5125        }
5126        if field_mask.needs_content() && !lexical.content.is_empty() {
5127            hit.content = lexical.content.clone();
5128        }
5129        hit.match_type = lexical.match_type;
5130        hit.line_number = lexical.line_number.or(hit.line_number);
5131    }
5132
5133    fn progressive_phase_to_result(
5134        &self,
5135        results: &[FsScoredResult],
5136        ctx: ProgressivePhaseContext<'_>,
5137    ) -> Result<SearchResult> {
5138        let collapsed = self.collapse_progressive_scored_results(results, ctx.fetch_limit);
5139        let missing: Vec<VectorSearchResult> = collapsed
5140            .iter()
5141            .filter(|result| {
5142                ctx.lexical_cache
5143                    .and_then(|cache| cache.hits_by_message.get(&result.message_id))
5144                    .is_none()
5145            })
5146            .map(|result| VectorSearchResult {
5147                message_id: result.message_id,
5148                chunk_idx: result.chunk_idx,
5149                score: result.score,
5150            })
5151            .collect();
5152        let mut hydrated_by_id: HashMap<u64, SearchHit> = self
5153            .hydrate_semantic_hits_with_ids(&missing, ctx.field_mask)?
5154            .into_iter()
5155            .collect();
5156
5157        let mut hydrated: Vec<(u64, SearchHit)> = Vec::with_capacity(collapsed.len());
5158        for result in &collapsed {
5159            if let Some(cache) = ctx.lexical_cache
5160                && let Some(lexical) = cache.hits_by_message.get(&result.message_id)
5161            {
5162                hydrated.push((result.message_id, lexical.to_search_hit(result.score)));
5163                continue;
5164            }
5165            if let Some(mut hit) = hydrated_by_id.remove(&result.message_id) {
5166                if let Some(cache) = ctx.lexical_cache
5167                    && let Some(lexical) = cache.hits_by_message.get(&result.message_id)
5168                {
5169                    self.overlay_progressive_lexical_hit(&mut hit, lexical, ctx.field_mask);
5170                }
5171                hydrated.push((result.message_id, hit));
5172            }
5173        }
5174
5175        let mut hits: Vec<SearchHit> = hydrated.into_iter().map(|(_, hit)| hit).collect();
5176        (_, hits) = self.postprocess_hits_page(hits, ctx.query, ctx.filters, ctx.limit, 0);
5177
5178        let (wildcard_fallback, suggestions) = ctx
5179            .lexical_cache
5180            .map(|cache| {
5181                let suggestions = if hits.is_empty() {
5182                    cache.suggestions.clone()
5183                } else {
5184                    Vec::new()
5185                };
5186                (cache.wildcard_fallback, suggestions)
5187            })
5188            .unwrap_or((false, Vec::new()));
5189
5190        Ok(SearchResult {
5191            hits,
5192            wildcard_fallback,
5193            cache_stats: self.cache_stats(),
5194            suggestions,
5195            ann_stats: None,
5196            total_count: None,
5197        })
5198    }
5199
5200    pub(crate) async fn search_progressive_with_callback(
5201        self: &Arc<Self>,
5202        request: ProgressiveSearchRequest<'_>,
5203        mut on_event: impl FnMut(ProgressiveSearchEvent) + Send,
5204    ) -> Result<()> {
5205        let ProgressiveSearchRequest {
5206            cx,
5207            query,
5208            filters,
5209            limit,
5210            sparse_threshold,
5211            field_mask,
5212            mode,
5213        } = request;
5214        let field_mask = effective_field_mask(field_mask);
5215        let limit = limit.max(1);
5216        let fetch_limit = progressive_phase_fetch_limit(limit);
5217
5218        match mode {
5219            SearchMode::Lexical => {
5220                let started = Instant::now();
5221                let result = self.search_with_fallback(
5222                    query,
5223                    filters,
5224                    limit,
5225                    0,
5226                    sparse_threshold,
5227                    field_mask,
5228                )?;
5229                on_event(ProgressiveSearchEvent::Phase {
5230                    kind: ProgressivePhaseKind::Initial,
5231                    elapsed_ms: started.elapsed().as_millis(),
5232                    result,
5233                });
5234                return Ok(());
5235            }
5236            SearchMode::Semantic | SearchMode::Hybrid => {}
5237        }
5238
5239        let progressive_context = {
5240            self.progressive_context()?
5241                .ok_or_else(|| anyhow!("progressive two-tier context unavailable"))?
5242        };
5243        let progressive_context_token = Arc::clone(&progressive_context.context_token);
5244
5245        let lexical_cache: Arc<Mutex<ProgressiveLexicalSnapshot>> =
5246            Arc::new(Mutex::new(Arc::new(ProgressiveLexicalCache::default())));
5247        let text_cache: Arc<Mutex<HashMap<u64, String>>> = Arc::new(Mutex::new(HashMap::new()));
5248        let text_client = Arc::clone(self);
5249        let text_cache_for_lookup = Arc::clone(&text_cache);
5250        let text_fn = move |doc_id: &str| -> Option<String> {
5251            let parsed = parse_semantic_doc_id(doc_id)?;
5252            if let Ok(cache) = text_cache_for_lookup.lock()
5253                && let Some(text) = cache.get(&parsed.message_id)
5254            {
5255                return Some(text.clone());
5256            }
5257            let loaded = text_client
5258                .load_message_text_by_id(parsed.message_id)
5259                .ok()
5260                .flatten()?;
5261            if let Ok(mut cache) = text_cache_for_lookup.lock() {
5262                cache.insert(parsed.message_id, loaded.clone());
5263            }
5264            Some(loaded)
5265        };
5266
5267        let mut searcher = FsTwoTierSearcher::new(
5268            Arc::clone(&progressive_context.index),
5269            Arc::clone(&progressive_context.fast_embedder),
5270            frankensearch_two_tier_config(),
5271        );
5272
5273        if let Some(quality_embedder) = progressive_context.quality_embedder.as_ref() {
5274            searcher = searcher.with_quality_embedder(Arc::clone(quality_embedder));
5275        }
5276
5277        if matches!(mode, SearchMode::Hybrid) {
5278            let lexical = Arc::new(CassProgressiveLexicalAdapter::new(
5279                Arc::clone(self),
5280                filters.clone(),
5281                field_mask,
5282                sparse_threshold,
5283                Arc::clone(&lexical_cache),
5284            ));
5285            searcher = searcher.with_lexical(lexical);
5286        }
5287
5288        let phase_client = Arc::clone(self);
5289        let phase_filters = filters.clone();
5290        let phase_cache = Arc::clone(&lexical_cache);
5291        let mut phase_error: Option<anyhow::Error> = None;
5292
5293        let search_result = searcher
5294            .search(cx, query, fetch_limit, text_fn, |phase| {
5295                if phase_error.is_some() {
5296                    return;
5297                }
5298                match phase_client.semantic_context_matches(&progressive_context_token) {
5299                    Ok(true) => {}
5300                    Ok(false) => {
5301                        phase_error = Some(anyhow!(
5302                            "progressive search aborted: semantic context changed"
5303                        ));
5304                        cx.set_cancel_requested(true);
5305                        return;
5306                    }
5307                    Err(err) => {
5308                        phase_error = Some(err);
5309                        cx.set_cancel_requested(true);
5310                        return;
5311                    }
5312                }
5313                let lexical_snapshot = phase_cache.lock().ok().map(|guard| Arc::clone(&guard));
5314                let event_result = match phase {
5315                    FsSearchPhase::Initial {
5316                        results, latency, ..
5317                    } => phase_client
5318                        .progressive_phase_to_result(
5319                            &results,
5320                            ProgressivePhaseContext {
5321                                query,
5322                                filters: &phase_filters,
5323                                field_mask,
5324                                lexical_cache: lexical_snapshot.as_deref(),
5325                                limit,
5326                                fetch_limit,
5327                            },
5328                        )
5329                        .map(|result| ProgressiveSearchEvent::Phase {
5330                            kind: ProgressivePhaseKind::Initial,
5331                            elapsed_ms: latency.as_millis(),
5332                            result,
5333                        }),
5334                    FsSearchPhase::Refined {
5335                        results, latency, ..
5336                    } => phase_client
5337                        .progressive_phase_to_result(
5338                            &results,
5339                            ProgressivePhaseContext {
5340                                query,
5341                                filters: &phase_filters,
5342                                field_mask,
5343                                lexical_cache: lexical_snapshot.as_deref(),
5344                                limit,
5345                                fetch_limit,
5346                            },
5347                        )
5348                        .map(|result| ProgressiveSearchEvent::Phase {
5349                            kind: ProgressivePhaseKind::Refined,
5350                            elapsed_ms: latency.as_millis(),
5351                            result,
5352                        }),
5353                    // frankensearch may emit a final reranked phase after the
5354                    // quality-refined pass. cass's progressive consumers only
5355                    // distinguish fast initial results from a better upgraded
5356                    // replacement set, so reranked results flow through the
5357                    // existing refined/upgrade path.
5358                    FsSearchPhase::Reranked {
5359                        results, latency, ..
5360                    } => phase_client
5361                        .progressive_phase_to_result(
5362                            &results,
5363                            ProgressivePhaseContext {
5364                                query,
5365                                filters: &phase_filters,
5366                                field_mask,
5367                                lexical_cache: lexical_snapshot.as_deref(),
5368                                limit,
5369                                fetch_limit,
5370                            },
5371                        )
5372                        .map(|result| ProgressiveSearchEvent::Phase {
5373                            kind: ProgressivePhaseKind::Refined,
5374                            elapsed_ms: latency.as_millis(),
5375                            result,
5376                        }),
5377                    FsSearchPhase::RefinementFailed { error, latency, .. } => {
5378                        Ok(ProgressiveSearchEvent::RefinementFailed {
5379                            latency_ms: latency.as_millis(),
5380                            error: error.to_string(),
5381                        })
5382                    }
5383                };
5384
5385                match event_result {
5386                    Ok(event) => on_event(event),
5387                    Err(err) => {
5388                        phase_error = Some(err);
5389                        cx.set_cancel_requested(true);
5390                    }
5391                }
5392            })
5393            .await;
5394
5395        if let Some(err) = phase_error {
5396            return Err(err);
5397        }
5398
5399        search_result
5400            .map(|_| ())
5401            .map_err(|err| anyhow!("progressive search failed: {err}"))
5402    }
5403
5404    /// Semantic search result containing hits and optional ANN statistics.
5405    pub fn search_semantic(
5406        &self,
5407        query: &str,
5408        filters: SearchFilters,
5409        limit: usize,
5410        offset: usize,
5411        field_mask: FieldMask,
5412        approximate: bool,
5413    ) -> Result<(
5414        Vec<SearchHit>,
5415        Option<crate::search::ann_index::AnnSearchStats>,
5416    )> {
5417        self.search_semantic_with_tier(
5418            query,
5419            filters,
5420            limit,
5421            offset,
5422            field_mask,
5423            approximate,
5424            SemanticTierMode::Single,
5425        )
5426    }
5427
5428    /// Semantic search with optional progressive two-tier execution strategy.
5429    #[allow(clippy::too_many_arguments)]
5430    pub fn search_semantic_with_tier(
5431        &self,
5432        query: &str,
5433        filters: SearchFilters,
5434        limit: usize,
5435        offset: usize,
5436        field_mask: FieldMask,
5437        approximate: bool,
5438        tier_mode: SemanticTierMode,
5439    ) -> Result<(
5440        Vec<SearchHit>,
5441        Option<crate::search::ann_index::AnnSearchStats>,
5442    )> {
5443        let field_mask = effective_field_mask(field_mask);
5444        let canonical = canonicalize_for_embedding(query);
5445        if canonical.trim().is_empty() {
5446            return Ok((Vec::new(), None));
5447        }
5448        let limit = if limit == 0 {
5449            self.total_docs().min(no_limit_result_cap()).max(1)
5450        } else {
5451            limit
5452        };
5453        let target_hits = limit.saturating_add(offset);
5454        if target_hits == 0 {
5455            return Ok((Vec::new(), None));
5456        }
5457        let initial_fetch_limit = target_hits;
5458        let fallback_fetch_limit = target_hits.saturating_mul(3);
5459        loop {
5460            let (embedding, candidate_context, in_memory_two_tier_index, ann_index, context_token) = loop {
5461                let embedding = self.semantic_query_embedding(&canonical)?;
5462                let (candidate_context, context_token) = {
5463                    let guard = self
5464                        .semantic
5465                        .lock()
5466                        .map_err(|_| anyhow!("semantic lock poisoned"))?;
5467                    let state = guard.as_ref().ok_or_else(|| {
5468                        anyhow!("semantic search unavailable (no embedder or vector index)")
5469                    })?;
5470                    (
5471                        SemanticCandidateContext {
5472                            fs_semantic_index: Arc::clone(&state.fs_semantic_index),
5473                            fs_semantic_indexes: Arc::clone(&state.fs_semantic_indexes),
5474                            filter_maps: state.filter_maps.clone(),
5475                            roles: state.roles.clone(),
5476                        },
5477                        Arc::clone(&state.context_token),
5478                    )
5479                };
5480                if !Arc::ptr_eq(&embedding.context_token, &context_token) {
5481                    continue;
5482                }
5483                let in_memory_two_tier_index = if tier_mode.wants_two_tier() && !approximate {
5484                    self.in_memory_two_tier_index(tier_mode)?
5485                } else {
5486                    None
5487                };
5488                let ann_index = if approximate {
5489                    Some(self.ann_index()?)
5490                } else {
5491                    None
5492                };
5493
5494                let guard = self
5495                    .semantic
5496                    .lock()
5497                    .map_err(|_| anyhow!("semantic lock poisoned"))?;
5498                let state = guard.as_ref().ok_or_else(|| {
5499                    anyhow!("semantic search unavailable (no embedder or vector index)")
5500                })?;
5501                if !Arc::ptr_eq(&state.context_token, &context_token) {
5502                    continue;
5503                }
5504                break (
5505                    embedding.vector,
5506                    candidate_context,
5507                    in_memory_two_tier_index,
5508                    ann_index,
5509                    context_token,
5510                );
5511            };
5512
5513            let finalize_hits =
5514                |results: &[VectorSearchResult]| -> Result<(usize, Vec<SearchHit>)> {
5515                    let hits = self.hydrate_semantic_hits(results, field_mask)?;
5516                    Ok(self.postprocess_hits_page(hits, query, &filters, limit, offset))
5517                };
5518
5519            let (results, retry_state, mut ann_stats) = self.search_semantic_candidates(
5520                &candidate_context,
5521                &embedding,
5522                &filters,
5523                SemanticCandidateSearchRequest {
5524                    fetch_limit: initial_fetch_limit,
5525                    approximate,
5526                    tier_mode,
5527                    in_memory_two_tier_index: in_memory_two_tier_index.as_ref(),
5528                    ann_index: ann_index.as_ref(),
5529                },
5530            )?;
5531            if !self.semantic_context_matches(&context_token)? {
5532                tracing::debug!("semantic context changed during candidate search; retrying");
5533                continue;
5534            }
5535            let (mut available_hits, mut paged_hits) = finalize_hits(&results)?;
5536
5537            let needs_retry = initial_fetch_limit < fallback_fetch_limit
5538                && ((available_hits < target_hits && retry_state.has_more_candidates)
5539                    || retry_state.exact_window_may_omit_competitor);
5540
5541            if needs_retry {
5542                tracing::debug!(
5543                    query = canonical,
5544                    target_hits,
5545                    available_hits,
5546                    initial_fetch_limit,
5547                    fallback_fetch_limit,
5548                    "retrying semantic fetch due to candidate-window shortfall"
5549                );
5550                let (retry_results, _, retry_ann_stats) = self.search_semantic_candidates(
5551                    &candidate_context,
5552                    &embedding,
5553                    &filters,
5554                    SemanticCandidateSearchRequest {
5555                        fetch_limit: fallback_fetch_limit,
5556                        approximate,
5557                        tier_mode,
5558                        in_memory_two_tier_index: in_memory_two_tier_index.as_ref(),
5559                        ann_index: ann_index.as_ref(),
5560                    },
5561                )?;
5562                if !self.semantic_context_matches(&context_token)? {
5563                    tracing::debug!("semantic context changed during retry fetch; retrying");
5564                    continue;
5565                }
5566                (available_hits, paged_hits) = finalize_hits(&retry_results)?;
5567                ann_stats = retry_ann_stats;
5568            }
5569
5570            tracing::trace!(
5571                query = canonical,
5572                target_hits,
5573                available_hits,
5574                returned = paged_hits.len(),
5575                "semantic fetch complete"
5576            );
5577
5578            return Ok((paged_hits, ann_stats));
5579        }
5580    }
5581
5582    fn hydrate_semantic_hits(
5583        &self,
5584        results: &[VectorSearchResult],
5585        field_mask: FieldMask,
5586    ) -> Result<Vec<SearchHit>> {
5587        self.hydrate_semantic_hits_with_ids(results, field_mask)
5588            .map(|rows| rows.into_iter().map(|(_, hit)| hit).collect())
5589    }
5590
5591    fn postprocess_hits_page(
5592        &self,
5593        hits: Vec<SearchHit>,
5594        query: &str,
5595        filters: &SearchFilters,
5596        limit: usize,
5597        offset: usize,
5598    ) -> (usize, Vec<SearchHit>) {
5599        let mut hits = deduplicate_hits_with_query(hits, query);
5600        if !filters.session_paths.is_empty() {
5601            hits.retain(|hit| filters.session_paths.contains(&hit.source_path));
5602        }
5603        let available_hits = hits.len();
5604        let paged_hits = hits.into_iter().skip(offset).take(limit).collect();
5605        (available_hits, paged_hits)
5606    }
5607
5608    /// Search with automatic wildcard fallback for sparse results.
5609    /// If the initial search returns fewer than `sparse_threshold` results and the query
5610    /// doesn't already contain wildcards, automatically retry with substring wildcards (*term*).
5611    pub fn search_with_fallback(
5612        &self,
5613        query: &str,
5614        filters: SearchFilters,
5615        limit: usize,
5616        offset: usize,
5617        sparse_threshold: usize,
5618        field_mask: FieldMask,
5619    ) -> Result<SearchResult> {
5620        // First, try the normal search
5621        let hits = self.search(query, filters.clone(), limit, offset, field_mask)?;
5622        let baseline_stats = self.cache_stats();
5623        // Capture the exact Tantivy total when the query path could collect it cheaply.
5624        let tantivy_total = self
5625            .last_tantivy_total_count
5626            .lock()
5627            .ok()
5628            .and_then(|guard| *guard);
5629
5630        // Check if we should try wildcard fallback
5631        let query_has_wildcards = query.contains('*');
5632        let has_boolean_or_phrase = fs_cass_has_boolean_operators(query);
5633        let is_sparse = should_try_wildcard_fallback(hits.len(), limit, offset, sparse_threshold);
5634        let total_docs = self.total_docs();
5635        let automatic_wildcard_allowed = should_allow_automatic_wildcard_fallback(
5636            total_docs,
5637            automatic_wildcard_fallback_max_docs(),
5638        );
5639
5640        if !is_sparse
5641            || query_has_wildcards
5642            || has_boolean_or_phrase
5643            || query.trim().is_empty()
5644            || !automatic_wildcard_allowed
5645        {
5646            // Either we have enough results, query already has wildcards,
5647            // query uses boolean/phrases, or query is empty.
5648            if is_sparse && !automatic_wildcard_allowed {
5649                tracing::debug!(
5650                    query,
5651                    returned_hits = hits.len(),
5652                    total_docs,
5653                    automatic_wildcard_max_docs = automatic_wildcard_fallback_max_docs(),
5654                    "skipping automatic wildcard fallback on large index"
5655                );
5656            }
5657            // Generate suggestions only if truly zero hits
5658            let suggestions = if hits.is_empty() && !query.trim().is_empty() {
5659                self.generate_suggestions(query, &filters)
5660            } else {
5661                Vec::new()
5662            };
5663            return Ok(SearchResult {
5664                hits,
5665                wildcard_fallback: false,
5666                cache_stats: baseline_stats,
5667                suggestions,
5668                ann_stats: None,
5669                total_count: tantivy_total,
5670            });
5671        }
5672
5673        if should_skip_automatic_wildcard_fallback_for_long_zero_hit_query(query, hits.len()) {
5674            let suggestions = if hits.is_empty() {
5675                self.generate_suggestions(query, &filters)
5676            } else {
5677                Vec::new()
5678            };
5679            return Ok(SearchResult {
5680                hits,
5681                wildcard_fallback: false,
5682                cache_stats: baseline_stats,
5683                suggestions,
5684                ann_stats: None,
5685                total_count: tantivy_total,
5686            });
5687        }
5688
5689        // Try wildcard fallback: wrap each term in *term*
5690        let wildcard_query = query
5691            .split_whitespace()
5692            .map(|term| format!("*{}*", term.trim_matches('*')))
5693            .collect::<Vec<_>>()
5694            .join(" ");
5695
5696        tracing::info!(
5697            original_query = query,
5698            wildcard_query = wildcard_query,
5699            original_count = hits.len(),
5700            "wildcard_fallback"
5701        );
5702
5703        let mut fallback_hits =
5704            self.search(&wildcard_query, filters.clone(), limit, offset, field_mask)?;
5705        let fallback_stats = self.cache_stats();
5706        // Re-capture total_count after wildcard search (may have changed)
5707        let fallback_tantivy_total = self
5708            .last_tantivy_total_count
5709            .lock()
5710            .ok()
5711            .and_then(|guard| *guard);
5712
5713        // Use fallback results if they're better
5714        if fallback_hits.len() > hits.len() {
5715            // Mark all hits as ImplicitWildcard since we auto-added wildcards
5716            for hit in &mut fallback_hits {
5717                hit.match_type = MatchType::ImplicitWildcard;
5718            }
5719            // Generate suggestions if still zero hits after fallback
5720            let suggestions = if fallback_hits.is_empty() {
5721                self.generate_suggestions(query, &filters)
5722            } else {
5723                Vec::new()
5724            };
5725            Ok(SearchResult {
5726                hits: fallback_hits,
5727                wildcard_fallback: true,
5728                cache_stats: fallback_stats,
5729                suggestions,
5730                ann_stats: None,
5731                total_count: fallback_tantivy_total,
5732            })
5733        } else {
5734            // Keep original results even if sparse
5735            // Generate suggestions if zero hits
5736            let suggestions = if hits.is_empty() {
5737                self.generate_suggestions(query, &filters)
5738            } else {
5739                Vec::new()
5740            };
5741            Ok(SearchResult {
5742                hits,
5743                wildcard_fallback: false,
5744                cache_stats: baseline_stats,
5745                suggestions,
5746                ann_stats: None,
5747                total_count: tantivy_total,
5748            })
5749        }
5750    }
5751
5752    /// Hybrid search that fuses lexical + semantic results with RRF.
5753    #[allow(clippy::too_many_arguments)]
5754    pub fn search_hybrid(
5755        &self,
5756        lexical_query: &str,
5757        semantic_query: &str,
5758        filters: SearchFilters,
5759        limit: usize,
5760        offset: usize,
5761        sparse_threshold: usize,
5762        field_mask: FieldMask,
5763        approximate: bool,
5764    ) -> Result<SearchResult> {
5765        self.search_hybrid_with_tier(
5766            lexical_query,
5767            semantic_query,
5768            filters,
5769            limit,
5770            offset,
5771            sparse_threshold,
5772            field_mask,
5773            approximate,
5774            SemanticTierMode::Single,
5775        )
5776    }
5777
5778    /// Hybrid search that fuses lexical + semantic results with optional
5779    /// progressive two-tier semantic execution.
5780    #[allow(clippy::too_many_arguments)]
5781    pub fn search_hybrid_with_tier(
5782        &self,
5783        lexical_query: &str,
5784        semantic_query: &str,
5785        filters: SearchFilters,
5786        limit: usize,
5787        offset: usize,
5788        sparse_threshold: usize,
5789        field_mask: FieldMask,
5790        approximate: bool,
5791        semantic_tier_mode: SemanticTierMode,
5792    ) -> Result<SearchResult> {
5793        let requested_limit = limit;
5794        let total_docs = self.total_docs().max(1);
5795        let limit = if requested_limit == 0 {
5796            total_docs.min(no_limit_result_cap()).max(1)
5797        } else {
5798            requested_limit
5799        };
5800        let fetch = limit.saturating_add(offset);
5801        if fetch == 0 {
5802            return Ok(SearchResult {
5803                hits: Vec::new(),
5804                wildcard_fallback: false,
5805                cache_stats: self.cache_stats(),
5806                suggestions: Vec::new(),
5807                ann_stats: None,
5808                total_count: None,
5809            });
5810        }
5811
5812        if semantic_query.trim().is_empty() {
5813            return self.search_with_fallback(
5814                lexical_query,
5815                filters,
5816                limit,
5817                offset,
5818                sparse_threshold,
5819                field_mask,
5820            );
5821        }
5822
5823        let budget =
5824            hybrid_candidate_budget(semantic_query, requested_limit, limit, offset, total_docs);
5825        let lexical = self.search_with_fallback(
5826            lexical_query,
5827            filters.clone(),
5828            budget.lexical_candidates,
5829            0,
5830            sparse_threshold,
5831            field_mask,
5832        )?;
5833        let (semantic_hits, semantic_ann_stats) = self.search_semantic_with_tier(
5834            semantic_query,
5835            filters,
5836            budget.semantic_candidates,
5837            0,
5838            field_mask,
5839            approximate,
5840            semantic_tier_mode,
5841        )?;
5842        let fused = rrf_fuse_hits(&lexical.hits, &semantic_hits, semantic_query, limit, offset);
5843        let suggestions = if fused.is_empty() {
5844            lexical.suggestions.clone()
5845        } else {
5846            Vec::new()
5847        };
5848        Ok(SearchResult {
5849            hits: fused,
5850            wildcard_fallback: lexical.wildcard_fallback,
5851            cache_stats: lexical.cache_stats,
5852            suggestions,
5853            ann_stats: semantic_ann_stats,
5854            total_count: None,
5855        })
5856    }
5857
5858    /// Generate "did-you-mean" suggestions for zero-hit queries.
5859    fn generate_suggestions(&self, query: &str, filters: &SearchFilters) -> Vec<QuerySuggestion> {
5860        let mut suggestions = Vec::new();
5861        let query_lower = query.to_lowercase();
5862
5863        // 1. Suggest wildcard search if query doesn't have wildcards
5864        if !query.contains('*') && query.len() >= 2 {
5865            suggestions.push(QuerySuggestion::wildcard(query).with_shortcut(1));
5866        }
5867
5868        // 2. Suggest removing agent filter if one is set
5869        if !filters.agents.is_empty() {
5870            let agents: Vec<&str> = filters
5871                .agents
5872                .iter()
5873                .map(std::string::String::as_str)
5874                .collect();
5875            let agent_str = agents.join(", ");
5876            suggestions
5877                .push(QuerySuggestion::remove_agent_filter(&agent_str, filters).with_shortcut(2));
5878        }
5879
5880        // 3. Suggest common agent names if query looks like a typo of one
5881        let known_agents = [
5882            "codex",
5883            "claude",
5884            "claude_code",
5885            "cline",
5886            "gemini",
5887            "amp",
5888            "opencode",
5889        ];
5890        for agent in &known_agents {
5891            if levenshtein_distance(&query_lower, agent) <= 2 && query_lower != *agent {
5892                suggestions.push(
5893                    QuerySuggestion::spelling(query, agent)
5894                        .with_shortcut(suggestions.len().min(2) as u8 + 1),
5895                );
5896                break; // Only suggest one spelling fix
5897            }
5898        }
5899
5900        // 4. Suggest alternative agents if SQLite is already open and no agent
5901        // filter is set. Avoid lazy-opening storage solely for no-hit advice:
5902        // large read-only frankensqlite opens can dominate fast lexical misses.
5903        if filters.agents.is_empty()
5904            && let Ok(sqlite_guard) = self.sqlite.lock()
5905            && let Some(conn) = sqlite_guard.as_ref()
5906            && let Ok(rows) = conn.query_map_collect(
5907                "SELECT a.slug
5908                 FROM conversations c
5909                 JOIN agents a ON c.agent_id = a.id
5910                 GROUP BY a.slug
5911                 ORDER BY MAX(c.id) DESC
5912                 LIMIT 3",
5913                &[],
5914                |row: &frankensqlite::Row| row.get_typed::<String>(0),
5915            )
5916        {
5917            for row in rows {
5918                if suggestions.len() < 3 {
5919                    suggestions.push(
5920                        QuerySuggestion::try_agent(&row)
5921                            .with_shortcut(suggestions.len().min(2) as u8 + 1),
5922                    );
5923                }
5924            }
5925        }
5926
5927        // Ensure we have at most 3 suggestions with shortcuts 1, 2, 3
5928        suggestions.truncate(3);
5929        for (i, sugg) in suggestions.iter_mut().enumerate() {
5930            sugg.shortcut = Some((i + 1) as u8);
5931        }
5932
5933        suggestions
5934    }
5935
5936    fn searcher_for_thread(&self, reader: &IndexReader) -> Searcher {
5937        let epoch = self.reload_epoch.load(Ordering::Relaxed);
5938        let reader_key = reader as *const IndexReader as usize;
5939        THREAD_SEARCHER.with(|slot| {
5940            let mut slot = slot.borrow_mut();
5941            if let Some(entry) = slot.as_ref()
5942                && entry.epoch == epoch
5943                && entry.reader_key == reader_key
5944            {
5945                return entry.searcher.clone();
5946            }
5947            let searcher = reader.searcher();
5948            *slot = Some(SearcherCacheEntry {
5949                epoch,
5950                reader_key,
5951                searcher: searcher.clone(),
5952            });
5953            searcher
5954        })
5955    }
5956
5957    fn federated_readers(&self) -> Option<Arc<Vec<FederatedIndexReader>>> {
5958        FEDERATED_SEARCH_READERS
5959            .read()
5960            .get(&self.cache_namespace)
5961            .cloned()
5962    }
5963
5964    fn maybe_reload_federated_readers(
5965        &self,
5966        readers: &[FederatedIndexReader],
5967    ) -> Result<Option<u64>> {
5968        if !self.reload_on_search || readers.is_empty() {
5969            return Ok(None);
5970        }
5971        const MIN_RELOAD_INTERVAL: Duration = Duration::from_millis(300);
5972        let now = Instant::now();
5973        let mut guard = self.last_reload.lock().unwrap_or_else(|e| e.into_inner());
5974        if guard
5975            .map(|t| now.duration_since(t) < MIN_RELOAD_INTERVAL)
5976            .unwrap_or(false)
5977        {
5978            let signature = self.federated_generation_signature(readers);
5979            return Ok(Some(signature));
5980        }
5981
5982        let reload_started = Instant::now();
5983        for shard in readers {
5984            shard.reader.reload()?;
5985        }
5986        let elapsed = reload_started.elapsed();
5987        *guard = Some(now);
5988        let epoch = self.reload_epoch.fetch_add(1, Ordering::SeqCst) + 1;
5989        self.metrics.record_reload(elapsed);
5990        tracing::debug!(
5991            duration_ms = elapsed.as_millis() as u64,
5992            reload_epoch = epoch,
5993            shards = readers.len(),
5994            "tantivy_reader_reload_federated"
5995        );
5996        Ok(Some(self.federated_generation_signature(readers)))
5997    }
5998
5999    fn federated_generation_signature(&self, readers: &[FederatedIndexReader]) -> u64 {
6000        let mut hasher = std::collections::hash_map::DefaultHasher::new();
6001        readers.len().hash(&mut hasher);
6002        for shard in readers {
6003            self.searcher_for_thread(&shard.reader)
6004                .generation()
6005                .generation_id()
6006                .hash(&mut hasher);
6007        }
6008        hasher.finish()
6009    }
6010
6011    fn track_generation(&self, generation: u64) {
6012        let mut guard = self
6013            .last_generation
6014            .lock()
6015            .unwrap_or_else(|e| e.into_inner());
6016        if let Some(prev) = *guard
6017            && prev != generation
6018            && let Ok(mut cache) = self.prefix_cache.lock()
6019        {
6020            cache.clear();
6021        }
6022        *guard = Some(generation);
6023    }
6024
6025    fn hydrate_tantivy_hit_contents(
6026        &self,
6027        exact_keys: &[TantivyContentExactKey],
6028        fallback_keys: &[TantivyContentFallbackKey],
6029    ) -> Result<TantivyHydratedContentMaps> {
6030        if exact_keys.is_empty() && fallback_keys.is_empty() {
6031            return Ok((HashMap::new(), HashMap::new()));
6032        }
6033
6034        let sqlite_guard = match self.sqlite_guard() {
6035            Ok(guard) => guard,
6036            Err(_) => return Ok((HashMap::new(), HashMap::new())),
6037        };
6038        let Some(conn) = sqlite_guard.as_ref() else {
6039            return Ok((HashMap::new(), HashMap::new()));
6040        };
6041
6042        let mut hydrated_exact = HashMap::new();
6043        let mut hydrated_fallback = HashMap::new();
6044        const CHUNK_SIZE: usize = 300;
6045
6046        if !exact_keys.is_empty() {
6047            let mut unique_exact_keys = Vec::with_capacity(exact_keys.len());
6048            let mut seen = HashSet::with_capacity(exact_keys.len());
6049            for key in exact_keys {
6050                if seen.insert(*key) {
6051                    unique_exact_keys.push(*key);
6052                }
6053            }
6054
6055            hydrated_exact.extend(hydrate_message_content_by_conversation(
6056                conn,
6057                &unique_exact_keys,
6058            )?);
6059        }
6060
6061        if !fallback_keys.is_empty() {
6062            let mut unique_fallback_keys = Vec::with_capacity(fallback_keys.len());
6063            let mut seen = HashSet::with_capacity(fallback_keys.len());
6064            for key in fallback_keys {
6065                if seen.insert(key.clone()) {
6066                    unique_fallback_keys.push(key.clone());
6067                }
6068            }
6069
6070            let mut unique_source_paths = Vec::with_capacity(unique_fallback_keys.len());
6071            let mut seen_source_paths = HashSet::with_capacity(unique_fallback_keys.len());
6072            for (_, source_path, _) in &unique_fallback_keys {
6073                if seen_source_paths.insert(source_path.clone()) {
6074                    unique_source_paths.push(source_path.clone());
6075                }
6076            }
6077
6078            let mut conversations_by_key: HashMap<(String, String), Vec<i64>> = HashMap::new();
6079            for chunk in unique_source_paths.chunks(CHUNK_SIZE) {
6080                let placeholders = sql_placeholders(chunk.len());
6081                let sql = format!(
6082                    "SELECT c.id,
6083                            c.source_path,
6084                            COALESCE(c.source_id, ''),
6085                            COALESCE(c.origin_host, ''),
6086                            COALESCE(s.kind, '')
6087                     FROM conversations c
6088                     LEFT JOIN sources s ON c.source_id = s.id
6089                     WHERE c.source_path IN ({placeholders})
6090                     ORDER BY c.id"
6091                );
6092                let params = chunk
6093                    .iter()
6094                    .map(|source_path| ParamValue::from(source_path.clone()))
6095                    .collect::<Vec<_>>();
6096                let rows: Vec<(i64, String, String, String, String)> =
6097                    franken_query_map_collect_retry(conn, &sql, &params, |row| {
6098                        Ok((
6099                            row.get_typed(0)?,
6100                            row.get_typed(1)?,
6101                            row.get_typed(2)?,
6102                            row.get_typed(3)?,
6103                            row.get_typed(4)?,
6104                        ))
6105                    })?;
6106
6107                for (conversation_id, source_path, raw_source_id, origin_host, origin_kind) in rows
6108                {
6109                    let normalized_source_id = normalized_search_hit_source_id_parts(
6110                        &raw_source_id,
6111                        &origin_kind,
6112                        (!origin_host.trim().is_empty()).then_some(origin_host.as_str()),
6113                    );
6114                    conversations_by_key
6115                        .entry((normalized_source_id, source_path))
6116                        .or_default()
6117                        .push(conversation_id);
6118                }
6119            }
6120
6121            let mut message_requests = Vec::new();
6122            let mut fallback_keys_by_exact: HashMap<
6123                TantivyContentExactKey,
6124                Vec<TantivyContentFallbackKey>,
6125            > = HashMap::new();
6126            let mut seen_message_requests = HashSet::new();
6127            for (source_id, source_path, line_idx) in &unique_fallback_keys {
6128                let key = (source_id.clone(), source_path.clone());
6129                let Some(conversation_ids) = conversations_by_key.get(&key) else {
6130                    continue;
6131                };
6132                for &conversation_id in conversation_ids {
6133                    let exact_key = (conversation_id, *line_idx);
6134                    if seen_message_requests.insert(exact_key) {
6135                        message_requests.push(exact_key);
6136                    }
6137                    fallback_keys_by_exact.entry(exact_key).or_default().push((
6138                        source_id.clone(),
6139                        source_path.clone(),
6140                        *line_idx,
6141                    ));
6142                }
6143            }
6144
6145            for ((conversation_id, line_idx), content) in
6146                hydrate_message_content_by_conversation(conn, &message_requests)?
6147            {
6148                if let Some(fallback_keys) =
6149                    fallback_keys_by_exact.get(&(conversation_id, line_idx))
6150                {
6151                    for fallback_key in fallback_keys {
6152                        hydrated_fallback.insert(fallback_key.clone(), content.clone());
6153                    }
6154                }
6155            }
6156        }
6157
6158        Ok((hydrated_exact, hydrated_fallback))
6159    }
6160
6161    #[allow(clippy::too_many_arguments)]
6162    fn search_tantivy(
6163        &self,
6164        reader: &IndexReader,
6165        fields: &FsCassFields,
6166        raw_query: &str,
6167        sanitized_query: &str,
6168        filters: SearchFilters,
6169        limit: usize,
6170        offset: usize,
6171        field_mask: FieldMask,
6172    ) -> Result<(Vec<SearchHit>, Option<usize>)> {
6173        struct PendingTantivyHit {
6174            score: f32,
6175            doc: TantivyDocument,
6176            title: String,
6177            stored_content: String,
6178            stored_preview: String,
6179            agent: String,
6180            source_path: String,
6181            workspace: String,
6182            workspace_original: Option<String>,
6183            created_at: Option<i64>,
6184            line_number: Option<usize>,
6185            stored_preview_snippet: Option<String>,
6186            source_id: String,
6187            conversation_id: Option<i64>,
6188            raw_origin_kind: Option<String>,
6189            origin_host: Option<String>,
6190        }
6191
6192        self.maybe_reload_reader(reader)?;
6193        let searcher = self.searcher_for_thread(reader);
6194        self.track_generation(searcher.generation().generation_id());
6195
6196        let wants_snippet = field_mask.wants_snippet();
6197        let needs_content = field_mask.needs_content() || wants_snippet;
6198
6199        // Delegate cass-compatible query parsing + Tantivy clause construction to frankensearch.
6200        // cass retains ownership of paging/fallback orchestration and stored-field hydration.
6201        let fs_filters = FsCassQueryFilters {
6202            agents: filters.agents.into_iter().collect(),
6203            workspaces: filters.workspaces.into_iter().collect(),
6204            created_from: filters.created_from,
6205            created_to: filters.created_to,
6206            source_filter: match filters.source_filter {
6207                SourceFilter::All => FsCassSourceFilter::All,
6208                SourceFilter::Local => FsCassSourceFilter::Local,
6209                SourceFilter::Remote => FsCassSourceFilter::Remote,
6210                SourceFilter::SourceId(id) => {
6211                    FsCassSourceFilter::SourceId(normalize_search_source_filter_value(&id))
6212                }
6213            },
6214        };
6215
6216        // NOTE: session_paths filtering is applied post-search since source_path
6217        // is STORED but not indexed. See apply_session_paths_filter().
6218        let q: Box<dyn Query> = fs_cass_build_tantivy_query(raw_query, &fs_filters, fields);
6219
6220        let prefix_only = is_prefix_only(sanitized_query);
6221        let top_docs = execute_query_with_bounded_exact_count(&searcher, &*q, limit, offset)?;
6222        let tantivy_total_count = top_docs.total_count;
6223        let query_match_type = dominant_match_type(sanitized_query);
6224        let mut pending_hits = Vec::with_capacity(top_docs.hits.len());
6225        let mut missing_exact_content_keys = Vec::new();
6226        let mut missing_fallback_content_keys = Vec::new();
6227
6228        for ranked_hit in top_docs.hits {
6229            let score = ranked_hit.bm25_score;
6230            let doc: TantivyDocument = fs_load_doc(&searcher, ranked_hit.doc_address)?;
6231            let title = if field_mask.wants_title() {
6232                doc.get_first(fields.title)
6233                    .and_then(|v| v.as_str())
6234                    .unwrap_or("")
6235                    .to_string()
6236            } else {
6237                String::new()
6238            };
6239            let stored_content = doc
6240                .get_first(fields.content)
6241                .and_then(|v| v.as_str())
6242                .unwrap_or("")
6243                .to_string();
6244            let stored_preview = doc
6245                .get_first(fields.preview)
6246                .and_then(|v| v.as_str())
6247                .unwrap_or("")
6248                .to_string();
6249            let stored_preview_snippet = snippet_from_preview_without_full_content(
6250                field_mask,
6251                &stored_preview,
6252                sanitized_query,
6253            );
6254            let agent = doc
6255                .get_first(fields.agent)
6256                .and_then(|v| v.as_str())
6257                .unwrap_or("")
6258                .to_string();
6259            let workspace = doc
6260                .get_first(fields.workspace)
6261                .and_then(|v| v.as_str())
6262                .unwrap_or("")
6263                .to_string();
6264            let workspace_original = doc
6265                .get_first(fields.workspace_original)
6266                .and_then(|v| v.as_str())
6267                .filter(|s| !s.is_empty())
6268                .map(String::from);
6269            let created_at = doc.get_first(fields.created_at).and_then(|v| v.as_i64());
6270            let line_number = doc
6271                .get_first(fields.msg_idx)
6272                .and_then(|v| v.as_u64())
6273                .and_then(|i| usize::try_from(i).ok())
6274                .map(|i| i.saturating_add(1));
6275            let raw_source_id = doc
6276                .get_first(fields.source_id)
6277                .and_then(|v| v.as_str())
6278                .unwrap_or_default()
6279                .to_string();
6280            let conversation_id = fields
6281                .conversation_id
6282                .and_then(|field| doc.get_first(field))
6283                .and_then(|v| v.as_i64());
6284            let source_path = doc
6285                .get_first(fields.source_path)
6286                .and_then(|v| v.as_str())
6287                .unwrap_or("")
6288                .to_string();
6289            let raw_origin_kind = doc
6290                .get_first(fields.origin_kind)
6291                .and_then(|v| v.as_str())
6292                .map(str::to_string);
6293            let origin_host = doc
6294                .get_first(fields.origin_host)
6295                .and_then(|v| v.as_str())
6296                .filter(|s| !s.is_empty())
6297                .map(String::from);
6298            let source_id = normalized_search_hit_source_id_parts(
6299                raw_source_id.as_str(),
6300                raw_origin_kind.as_deref().unwrap_or_default(),
6301                origin_host.as_deref(),
6302            );
6303
6304            let preview_satisfies_bounded_content =
6305                field_mask.preview_content_limit().is_some() && !stored_preview.is_empty();
6306            let preview_satisfies_full_content = field_mask.needs_content()
6307                && field_mask.preview_content_limit().is_none()
6308                && stored_preview_is_complete_content(&stored_preview);
6309            if needs_content
6310                && let Some(line_idx) = line_number
6311                    .and_then(|line| line.checked_sub(1))
6312                    .and_then(|line| i64::try_from(line).ok())
6313                && stored_content.is_empty()
6314                && !preview_satisfies_bounded_content
6315                && !preview_satisfies_full_content
6316                && stored_preview_snippet.is_none()
6317            {
6318                if let Some(conversation_id) = conversation_id {
6319                    missing_exact_content_keys.push((conversation_id, line_idx));
6320                } else {
6321                    missing_fallback_content_keys.push((
6322                        source_id.clone(),
6323                        source_path.clone(),
6324                        line_idx,
6325                    ));
6326                }
6327            }
6328
6329            pending_hits.push(PendingTantivyHit {
6330                score,
6331                doc,
6332                title,
6333                stored_content,
6334                stored_preview,
6335                agent,
6336                source_path,
6337                workspace,
6338                workspace_original,
6339                created_at,
6340                line_number,
6341                stored_preview_snippet,
6342                source_id,
6343                conversation_id,
6344                raw_origin_kind,
6345                origin_host,
6346            });
6347        }
6348
6349        let (hydrated_contents, hydrated_fallback_contents) = if needs_content
6350            && (!missing_exact_content_keys.is_empty() || !missing_fallback_content_keys.is_empty())
6351        {
6352            self.hydrate_tantivy_hit_contents(
6353                &missing_exact_content_keys,
6354                &missing_fallback_content_keys,
6355            )?
6356        } else {
6357            (HashMap::new(), HashMap::new())
6358        };
6359        let needs_tantivy_snippet_generator = wants_snippet
6360            && !prefix_only
6361            && pending_hits
6362                .iter()
6363                .any(|pending| pending.stored_preview_snippet.is_none());
6364        let snippet_generator = if needs_tantivy_snippet_generator {
6365            let snippet_cfg = FsSnippetConfig {
6366                max_chars: 160,
6367                highlight_prefix: "<b>".to_string(),
6368                highlight_postfix: "</b>".to_string(),
6369            };
6370            fs_try_build_snippet_generator(&searcher, &*q, fields.content, &snippet_cfg)
6371        } else {
6372            None
6373        };
6374        let mut hits = Vec::with_capacity(pending_hits.len());
6375        for pending in pending_hits {
6376            let hydrated_content = pending
6377                .line_number
6378                .and_then(|line| line.checked_sub(1))
6379                .and_then(|line| i64::try_from(line).ok())
6380                .and_then(|line_idx| {
6381                    if let Some(conversation_id) = pending.conversation_id {
6382                        hydrated_contents.get(&(conversation_id, line_idx)).cloned()
6383                    } else {
6384                        hydrated_fallback_contents
6385                            .get(&(
6386                                pending.source_id.clone(),
6387                                pending.source_path.clone(),
6388                                line_idx,
6389                            ))
6390                            .cloned()
6391                    }
6392                });
6393            let preview_satisfies_effective_content = !pending.stored_preview.is_empty()
6394                && (field_mask.preview_content_limit().is_some()
6395                    || (field_mask.needs_content()
6396                        && field_mask.preview_content_limit().is_none()
6397                        && stored_preview_is_complete_content(&pending.stored_preview)));
6398            let effective_content = if !pending.stored_content.is_empty() {
6399                pending.stored_content.clone()
6400            } else if preview_satisfies_effective_content {
6401                pending.stored_preview.clone()
6402            } else if let Some(content) = hydrated_content {
6403                content
6404            } else {
6405                pending.stored_preview.clone()
6406            };
6407            let snippet = if wants_snippet {
6408                if let Some(snippet) = pending.stored_preview_snippet.clone() {
6409                    snippet
6410                } else if let Some(r#gen) = &snippet_generator {
6411                    let rendered = if !pending.stored_content.is_empty() {
6412                        fs_render_snippet_html(r#gen, &pending.doc, "<b>", "</b>")
6413                    } else if !effective_content.is_empty() {
6414                        let mut snippet_doc = TantivyDocument::new();
6415                        snippet_doc.add_text(fields.content, &effective_content);
6416                        fs_render_snippet_html(r#gen, &snippet_doc, "<b>", "</b>")
6417                    } else {
6418                        None
6419                    };
6420                    rendered
6421                        .map(|html| html.replace("<b>", "**").replace("</b>", "**"))
6422                        .or_else(|| cached_prefix_snippet(&effective_content, sanitized_query, 160))
6423                        .unwrap_or_else(|| {
6424                            quick_prefix_snippet(&effective_content, sanitized_query, 160)
6425                        })
6426                } else if let Some(sn) =
6427                    cached_prefix_snippet(&effective_content, sanitized_query, 160)
6428                {
6429                    sn
6430                } else {
6431                    quick_prefix_snippet(&effective_content, sanitized_query, 160)
6432                }
6433            } else {
6434                String::new()
6435            };
6436            let content = if field_mask.needs_content() {
6437                effective_content.clone()
6438            } else {
6439                String::new()
6440            };
6441            let content_hash = stable_hit_hash(
6442                &effective_content,
6443                &pending.source_path,
6444                pending.line_number,
6445                pending.created_at,
6446            );
6447            let origin_kind = normalized_search_hit_origin_kind(
6448                &pending.source_id,
6449                pending.raw_origin_kind.as_deref(),
6450            )
6451            .to_string();
6452            hits.push(SearchHit {
6453                title: pending.title,
6454                snippet,
6455                content,
6456                content_hash,
6457                conversation_id: pending.conversation_id,
6458                score: pending.score,
6459                source_path: pending.source_path,
6460                agent: pending.agent,
6461                workspace: pending.workspace,
6462                workspace_original: pending.workspace_original,
6463                created_at: pending.created_at,
6464                line_number: pending.line_number,
6465                match_type: query_match_type,
6466                source_id: pending.source_id,
6467                origin_kind,
6468                origin_host: pending.origin_host,
6469            });
6470        }
6471        Ok((hits, tantivy_total_count))
6472    }
6473
6474    #[allow(clippy::too_many_arguments)]
6475    fn search_tantivy_federated(
6476        &self,
6477        readers: &[FederatedIndexReader],
6478        raw_query: &str,
6479        sanitized_query: &str,
6480        filters: SearchFilters,
6481        limit: usize,
6482        field_mask: FieldMask,
6483    ) -> Result<(Vec<SearchHit>, Option<usize>)> {
6484        let mut ranked_hits = Vec::new();
6485        let mut total_count = Some(0usize);
6486
6487        for (shard_index, shard) in readers.iter().enumerate() {
6488            let (shard_hits, shard_total_count) = self.search_tantivy(
6489                &shard.reader,
6490                &shard.fields,
6491                raw_query,
6492                sanitized_query,
6493                filters.clone(),
6494                limit,
6495                0,
6496                field_mask,
6497            )?;
6498            total_count = match (total_count, shard_total_count) {
6499                (Some(total), Some(shard_total)) => Some(total.saturating_add(shard_total)),
6500                _ => None,
6501            };
6502            for (shard_rank, hit) in shard_hits.into_iter().enumerate() {
6503                ranked_hits.push(FederatedRankedHit {
6504                    hit,
6505                    shard_index,
6506                    shard_rank,
6507                    fused_score: federated_rrf_score(shard_rank),
6508                });
6509            }
6510        }
6511
6512        let raw_hit_count = ranked_hits.len();
6513        let generation_signature = self.federated_generation_signature(readers);
6514        self.track_generation(generation_signature);
6515        let combined_hits = merge_federated_ranked_hits(ranked_hits);
6516        tracing::debug!(
6517            generation_signature,
6518            shard_count = readers.len(),
6519            total_count,
6520            raw_hit_count,
6521            returned_hit_count = combined_hits.len(),
6522            merge_policy = "rrf_rank_then_stable_hit_key",
6523            "federated lexical search merged shard results"
6524        );
6525
6526        Ok((combined_hits, total_count))
6527    }
6528
6529    fn sqlite_fts_uses_message_id_column(conn: &Connection) -> Result<bool> {
6530        let params: [ParamValue; 0] = [];
6531        let ddl_rows: Vec<String> = franken_query_map_collect_retry(
6532            conn,
6533            "SELECT COALESCE(sql, '')
6534             FROM sqlite_master
6535             WHERE name = 'fts_messages'
6536             ORDER BY rowid DESC
6537             LIMIT 1",
6538            &params,
6539            |row: &frankensqlite::Row| row.get_typed::<String>(0),
6540        )?;
6541        Ok(ddl_rows
6542            .first()
6543            .map(|sql| sql.to_ascii_lowercase().contains("message_id"))
6544            .unwrap_or(false))
6545    }
6546
6547    fn sqlite_fts_match_mode(conn: &Connection) -> Result<SqliteFtsMatchMode> {
6548        let params = [ParamValue::from("__cass_fts_probe_no_match__")];
6549        match franken_query_map_collect_retry(
6550            conn,
6551            "SELECT COUNT(*) FROM fts_messages WHERE fts_messages MATCH ?",
6552            &params,
6553            |row: &frankensqlite::Row| row.get_typed::<i64>(0),
6554        ) {
6555            Ok(_) => Ok(SqliteFtsMatchMode::Table),
6556            Err(err)
6557                if err
6558                    .to_string()
6559                    .contains("no such column: fts_messages in table fts_messages") =>
6560            {
6561                Ok(SqliteFtsMatchMode::IndexedColumns)
6562            }
6563            Err(err) => Err(anyhow!(err)),
6564        }
6565    }
6566
6567    fn sqlite_fts5_rowid_projection_available(conn: &Connection) -> bool {
6568        let params: [ParamValue; 0] = [];
6569        franken_query_map_collect_retry(
6570            conn,
6571            "SELECT rowid FROM fts_messages LIMIT 1",
6572            &params,
6573            |row: &frankensqlite::Row| row.get_typed::<i64>(0),
6574        )
6575        .is_ok()
6576    }
6577
6578    fn sqlite_fts5_match_clause(match_mode: SqliteFtsMatchMode) -> &'static str {
6579        match match_mode {
6580            SqliteFtsMatchMode::Table => "fts_messages MATCH ?",
6581            SqliteFtsMatchMode::IndexedColumns => {
6582                "(content MATCH ?
6583                  OR title MATCH ?
6584                  OR agent MATCH ?
6585                  OR workspace MATCH ?
6586                  OR source_path MATCH ?)"
6587            }
6588        }
6589    }
6590
6591    fn push_sqlite_fts5_match_params(
6592        params: &mut Vec<ParamValue>,
6593        fts_query: &str,
6594        match_mode: SqliteFtsMatchMode,
6595    ) {
6596        let copies = match match_mode {
6597            SqliteFtsMatchMode::Table => 1,
6598            SqliteFtsMatchMode::IndexedColumns => 5,
6599        };
6600        for _ in 0..copies {
6601            params.push(ParamValue::from(fts_query));
6602        }
6603    }
6604
6605    fn sqlite_fts5_rank_query(
6606        fts_query: &str,
6607        _filters: &SearchFilters,
6608        limit: usize,
6609        offset: usize,
6610        _uses_message_id: bool,
6611        match_mode: SqliteFtsMatchMode,
6612    ) -> (String, Vec<ParamValue>) {
6613        let match_clause = Self::sqlite_fts5_match_clause(match_mode);
6614        let mut sql = format!(
6615            "SELECT rowid,
6616                    bm25(fts_messages)
6617             FROM fts_messages
6618             WHERE {match_clause}"
6619        );
6620        let mut params = Vec::with_capacity(9);
6621        Self::push_sqlite_fts5_match_params(&mut params, fts_query, match_mode);
6622
6623        sql.push_str(" ORDER BY bm25(fts_messages), rowid LIMIT ? OFFSET ?");
6624        params.push(ParamValue::from(limit as i64));
6625        params.push(ParamValue::from(offset as i64));
6626
6627        (sql, params)
6628    }
6629
6630    fn sqlite_fts5_hydrate_query(
6631        row_count: usize,
6632        field_mask: FieldMask,
6633        uses_message_id: bool,
6634    ) -> String {
6635        let title_expr = if field_mask.wants_title() {
6636            "fts_messages.title"
6637        } else {
6638            "NULL"
6639        };
6640        let content_expr = if field_mask.needs_content() || field_mask.wants_snippet() {
6641            "fts_messages.content"
6642        } else {
6643            "NULL"
6644        };
6645        let message_key_expr = if uses_message_id {
6646            "CAST(fts_messages.message_id AS INTEGER)"
6647        } else {
6648            "rowid"
6649        };
6650        let placeholders = sql_placeholders(row_count);
6651
6652        format!(
6653            "SELECT rowid,
6654                    {message_key_expr},
6655                    {title_expr},
6656                    {content_expr},
6657                    fts_messages.agent,
6658                    fts_messages.workspace,
6659                    fts_messages.source_path,
6660                    CAST(fts_messages.created_at AS INTEGER)
6661             FROM fts_messages
6662             WHERE rowid IN ({placeholders})"
6663        )
6664    }
6665
6666    fn sqlite_fts5_message_hydrate_query(row_count: usize, field_mask: FieldMask) -> String {
6667        let title_expr = if field_mask.wants_title() {
6668            "COALESCE(c.title, '')"
6669        } else {
6670            "''"
6671        };
6672        let content_expr = if field_mask.needs_content() || field_mask.wants_snippet() {
6673            "COALESCE(m.content, '')"
6674        } else {
6675            "''"
6676        };
6677        let normalized_source_sql =
6678            normalized_search_source_id_sql_expr("c.source_id", "s.kind", "c.origin_host");
6679        let placeholders = sql_placeholders(row_count);
6680
6681        format!(
6682            "SELECT m.id,
6683                    {title_expr},
6684                    {content_expr},
6685                    COALESCE(a.slug, ''),
6686                    COALESCE(w.path, ''),
6687                    COALESCE(c.source_path, ''),
6688                    CAST(m.created_at AS INTEGER),
6689                    m.idx,
6690                    c.id,
6691                    {normalized_source_sql},
6692                    c.origin_host,
6693                    s.kind
6694             FROM messages m
6695             LEFT JOIN conversations c ON m.conversation_id = c.id
6696             LEFT JOIN sources s ON c.source_id = s.id
6697             LEFT JOIN agents a ON c.agent_id = a.id
6698             LEFT JOIN workspaces w ON c.workspace_id = w.id
6699             WHERE m.id IN ({placeholders})"
6700        )
6701    }
6702
6703    fn sqlite_fts5_hydrate_row_chunks(
6704        ranked_rows: &[(i64, f64)],
6705    ) -> impl Iterator<Item = &[(i64, f64)]> {
6706        const _: () = assert!(SQLITE_FTS5_HYDRATE_PARAM_CHUNK <= SQLITE_MAX_VARIABLE_NUMBER);
6707        ranked_rows.chunks(SQLITE_FTS5_HYDRATE_PARAM_CHUNK)
6708    }
6709
6710    fn sqlite_fts5_filters_need_post_hydration(filters: &SearchFilters) -> bool {
6711        !filters.agents.is_empty()
6712            || !filters.workspaces.is_empty()
6713            || filters.created_from.is_some()
6714            || filters.created_to.is_some()
6715            || !filters.source_filter.is_all()
6716            || !filters.session_paths.is_empty()
6717    }
6718
6719    fn sqlite_fts5_hit_matches_filters(hit: &SearchHit, filters: &SearchFilters) -> bool {
6720        if !filters.agents.is_empty() && !filters.agents.contains(&hit.agent) {
6721            return false;
6722        }
6723        if !filters.workspaces.is_empty() && !filters.workspaces.contains(&hit.workspace) {
6724            return false;
6725        }
6726        if filters.created_from.is_some() || filters.created_to.is_some() {
6727            let Some(created_at) = hit.created_at else {
6728                return false;
6729            };
6730            if let Some(created_from) = filters.created_from
6731                && created_at < created_from
6732            {
6733                return false;
6734            }
6735            if let Some(created_to) = filters.created_to
6736                && created_at > created_to
6737            {
6738                return false;
6739            }
6740        }
6741        if !filters.session_paths.is_empty() && !filters.session_paths.contains(&hit.source_path) {
6742            return false;
6743        }
6744
6745        match &filters.source_filter {
6746            SourceFilter::All => true,
6747            SourceFilter::Local => matches!(
6748                hit.source_id
6749                    .as_str()
6750                    .cmp(crate::sources::provenance::LOCAL_SOURCE_ID),
6751                CmpOrdering::Equal
6752            ),
6753            SourceFilter::Remote => !matches!(
6754                hit.source_id
6755                    .as_str()
6756                    .cmp(crate::sources::provenance::LOCAL_SOURCE_ID),
6757                CmpOrdering::Equal
6758            ),
6759            SourceFilter::SourceId(id) => {
6760                let normalized = normalize_search_source_filter_value(id);
6761                matches!(
6762                    hit.source_id.as_str().cmp(normalized.as_str()),
6763                    CmpOrdering::Equal
6764                )
6765            }
6766        }
6767    }
6768
6769    fn sqlite_message_scan_query(raw_query: &str) -> Option<SqliteMessageScanQuery> {
6770        fn scan_parts(parts: Vec<String>) -> Vec<String> {
6771            parts
6772                .into_iter()
6773                .map(|part| part.trim_end_matches('*').to_lowercase())
6774                .filter(|part| !part.is_empty())
6775                .collect()
6776        }
6777
6778        let tokens = fs_cass_parse_boolean_query(raw_query);
6779        if tokens.is_empty() {
6780            return None;
6781        }
6782
6783        let mut include_groups = Vec::new();
6784        let mut pending_or_group: SqliteMessageScanGroup = Vec::new();
6785        let mut exclude_terms = Vec::new();
6786        let mut negated = false;
6787        let mut in_or_sequence = false;
6788        for token in tokens {
6789            match token {
6790                FsCassQueryToken::And => {
6791                    if !pending_or_group.is_empty() {
6792                        include_groups.push(std::mem::take(&mut pending_or_group));
6793                    }
6794                    in_or_sequence = false;
6795                    negated = false;
6796                }
6797                FsCassQueryToken::Or => {
6798                    if include_groups.is_empty() && pending_or_group.is_empty() {
6799                        continue;
6800                    }
6801                    if negated {
6802                        return None;
6803                    }
6804                    in_or_sequence = true;
6805                }
6806                FsCassQueryToken::Not => {
6807                    if in_or_sequence {
6808                        return None;
6809                    }
6810                    if !pending_or_group.is_empty() {
6811                        include_groups.push(std::mem::take(&mut pending_or_group));
6812                    }
6813                    negated = true;
6814                    in_or_sequence = false;
6815                }
6816                FsCassQueryToken::Term(term) => {
6817                    let parts = scan_parts(normalize_term_parts(&term));
6818                    if parts.is_empty() {
6819                        continue;
6820                    }
6821                    if negated {
6822                        exclude_terms.extend(parts);
6823                    } else if in_or_sequence {
6824                        if pending_or_group.is_empty() {
6825                            let previous = include_groups.pop()?;
6826                            pending_or_group.extend(previous);
6827                        }
6828                        pending_or_group.push(parts);
6829                    } else {
6830                        include_groups.push(vec![parts]);
6831                    }
6832                    negated = false;
6833                }
6834                FsCassQueryToken::Phrase(phrase) => {
6835                    let parts = normalize_phrase_terms(&phrase);
6836                    if parts.is_empty() {
6837                        continue;
6838                    }
6839                    if negated {
6840                        exclude_terms.extend(parts);
6841                    } else if in_or_sequence {
6842                        if pending_or_group.is_empty() {
6843                            let previous = include_groups.pop()?;
6844                            pending_or_group.extend(previous);
6845                        }
6846                        pending_or_group.push(parts);
6847                    } else {
6848                        include_groups.push(vec![parts]);
6849                    }
6850                    negated = false;
6851                }
6852            }
6853        }
6854
6855        if !pending_or_group.is_empty() {
6856            include_groups.push(pending_or_group);
6857        }
6858
6859        for group in &mut include_groups {
6860            for alternative in group.iter_mut() {
6861                alternative.sort();
6862                alternative.dedup();
6863            }
6864            group.retain(|alternative| !alternative.is_empty());
6865            group.sort();
6866            group.dedup();
6867        }
6868        include_groups.retain(|group| !group.is_empty());
6869        exclude_terms.sort();
6870        exclude_terms.dedup();
6871        if include_groups.is_empty() {
6872            return None;
6873        }
6874
6875        Some(SqliteMessageScanQuery {
6876            include_groups,
6877            exclude_terms,
6878        })
6879    }
6880
6881    fn sqlite_message_scan_score(haystack: &str, scan_query: &SqliteMessageScanQuery) -> f32 {
6882        for term in &scan_query.exclude_terms {
6883            if haystack.contains(term) {
6884                return 0.0;
6885            }
6886        }
6887
6888        let mut score = 0.0f32;
6889        for group in &scan_query.include_groups {
6890            let mut group_score = 0.0f32;
6891            for alternative in group {
6892                let mut alternative_score = 0.0f32;
6893                for term in alternative {
6894                    let matches = haystack.matches(term).count();
6895                    if matches < 1 {
6896                        alternative_score = 0.0;
6897                        break;
6898                    }
6899                    alternative_score += matches as f32;
6900                }
6901                group_score = group_score.max(alternative_score);
6902            }
6903            if group_score <= 0.0 {
6904                return 0.0;
6905            }
6906            score += group_score;
6907        }
6908        score
6909    }
6910
6911    fn sqlite_message_scan_query_sql(field_mask: FieldMask) -> String {
6912        let title_expr = if field_mask.wants_title() {
6913            "COALESCE(c.title, '')"
6914        } else {
6915            "''"
6916        };
6917        let content_expr = if field_mask.needs_content() || field_mask.wants_snippet() {
6918            "COALESCE(m.content, '')"
6919        } else {
6920            "''"
6921        };
6922        let normalized_source_sql =
6923            normalized_search_source_id_sql_expr("c.source_id", "s.kind", "c.origin_host");
6924
6925        format!(
6926            "SELECT m.id,
6927                    {title_expr},
6928                    {content_expr},
6929                    COALESCE(a.slug, ''),
6930                    COALESCE(w.path, ''),
6931                    COALESCE(c.source_path, ''),
6932                    CAST(m.created_at AS INTEGER),
6933                    m.idx,
6934                    c.id,
6935                    {normalized_source_sql},
6936                    c.origin_host,
6937                    s.kind,
6938                    COALESCE(m.content, ''),
6939                    COALESCE(c.title, '')
6940             FROM messages m
6941             LEFT JOIN conversations c ON m.conversation_id = c.id
6942             LEFT JOIN sources s ON c.source_id = s.id
6943             LEFT JOIN agents a ON c.agent_id = a.id
6944             LEFT JOIN workspaces w ON c.workspace_id = w.id
6945             ORDER BY m.id
6946             LIMIT ?"
6947        )
6948    }
6949
6950    fn search_sqlite_message_scan(
6951        &self,
6952        conn: &Connection,
6953        request: SqliteMessageScanRequest<'_>,
6954    ) -> Result<Vec<SearchHit>> {
6955        let Some(scan_query) = Self::sqlite_message_scan_query(request.raw_query) else {
6956            return Ok(Vec::new());
6957        };
6958
6959        let sql = Self::sqlite_message_scan_query_sql(request.field_mask);
6960        let params = [ParamValue::from(SQLITE_MESSAGE_SCAN_FALLBACK_LIMIT as i64)];
6961        let rows: Vec<(SqliteFtsMessageRow, String, String)> =
6962            franken_query_map_collect_retry(conn, &sql, &params, |row| {
6963                Ok((
6964                    (
6965                        row.get_typed(0)?,
6966                        row.get_typed(1)?,
6967                        row.get_typed(2)?,
6968                        row.get_typed(3)?,
6969                        row.get_typed(4)?,
6970                        row.get_typed(5)?,
6971                        row.get_typed(6)?,
6972                        row.get_typed(7)?,
6973                        row.get_typed(8)?,
6974                        row.get_typed::<Option<String>>(9)?,
6975                        row.get_typed(10)?,
6976                        row.get_typed(11)?,
6977                    ),
6978                    row.get_typed(12)?,
6979                    row.get_typed(13)?,
6980                ))
6981            })?;
6982
6983        let mut scored_hits = Vec::new();
6984        for (
6985            (
6986                _message_id,
6987                title,
6988                raw_content,
6989                agent,
6990                workspace,
6991                source_path,
6992                created_at,
6993                idx,
6994                conversation_id,
6995                raw_source_id,
6996                origin_host,
6997                raw_origin_kind,
6998            ),
6999            scan_content,
7000            scan_title,
7001        ) in rows
7002        {
7003            let mut haystack = String::with_capacity(
7004                scan_content.len()
7005                    + scan_title.len()
7006                    + agent.len()
7007                    + workspace.len()
7008                    + source_path.len()
7009                    + 4,
7010            );
7011            haystack.push_str(&scan_content);
7012            haystack.push(' ');
7013            haystack.push_str(&scan_title);
7014            haystack.push(' ');
7015            haystack.push_str(&agent);
7016            haystack.push(' ');
7017            haystack.push_str(&workspace);
7018            haystack.push(' ');
7019            haystack.push_str(&source_path);
7020            let haystack = haystack.to_lowercase();
7021            let score = Self::sqlite_message_scan_score(&haystack, &scan_query);
7022            if score <= 0.0 {
7023                continue;
7024            }
7025
7026            let raw_source_id = raw_source_id.unwrap_or_else(default_source_id);
7027            let source_id = normalized_search_hit_source_id_parts(
7028                raw_source_id.as_str(),
7029                raw_origin_kind.as_deref().unwrap_or_default(),
7030                origin_host.as_deref(),
7031            );
7032            let origin_kind =
7033                normalized_search_hit_origin_kind(source_id.as_str(), raw_origin_kind.as_deref());
7034            let line_number = idx
7035                .and_then(|i| usize::try_from(i).ok())
7036                .map(|i| i.saturating_add(1));
7037            let snippet = if request.field_mask.wants_snippet() {
7038                snippet_from_content(&scan_content)
7039            } else {
7040                String::new()
7041            };
7042            let content = if request.field_mask.needs_content() {
7043                raw_content
7044            } else {
7045                String::new()
7046            };
7047            let content_hash = if content.is_empty() {
7048                stable_hit_hash(&snippet, &source_path, line_number, created_at)
7049            } else {
7050                stable_hit_hash(&content, &source_path, line_number, created_at)
7051            };
7052
7053            let hit = SearchHit {
7054                title,
7055                snippet,
7056                content,
7057                content_hash,
7058                conversation_id,
7059                score,
7060                source_path,
7061                agent,
7062                workspace,
7063                workspace_original: None,
7064                created_at,
7065                line_number,
7066                match_type: request.query_match_type,
7067                source_id,
7068                origin_kind,
7069                origin_host,
7070            };
7071
7072            if Self::sqlite_fts5_hit_matches_filters(&hit, request.filters) {
7073                scored_hits.push(hit);
7074            }
7075        }
7076
7077        scored_hits.sort_by(|left, right| {
7078            right
7079                .score
7080                .partial_cmp(&left.score)
7081                .unwrap_or(CmpOrdering::Equal)
7082        });
7083
7084        Ok(scored_hits
7085            .into_iter()
7086            .skip(request.offset)
7087            .take(request.limit)
7088            .collect())
7089    }
7090
7091    fn search_sqlite_fts5(
7092        &self,
7093        _db_path: &Path,
7094        raw_query: &str,
7095        filters: SearchFilters,
7096        limit: usize,
7097        offset: usize,
7098        field_mask: FieldMask,
7099    ) -> Result<Vec<SearchHit>> {
7100        if limit < 1 {
7101            return Ok(Vec::new());
7102        }
7103
7104        let fts_query = match transpile_to_fts5(raw_query) {
7105            Some(q) if !q.trim().is_empty() => q,
7106            _ => return Ok(Vec::new()),
7107        };
7108
7109        let sqlite_guard = self.sqlite_guard()?;
7110        let Some(conn) = sqlite_guard.as_ref() else {
7111            return Ok(Vec::new());
7112        };
7113
7114        let empty_params: [ParamValue; 0] = [];
7115        let has_fts = franken_query_map_collect_retry(
7116            conn,
7117            "SELECT 1 FROM sqlite_master WHERE name = 'fts_messages'",
7118            &empty_params,
7119            |row| row.get_typed::<i64>(0),
7120        )
7121        .map(|rows| !rows.is_empty())
7122        .unwrap_or(false);
7123        if !has_fts {
7124            return Ok(Vec::new());
7125        }
7126
7127        let query_match_type = dominant_match_type(raw_query);
7128        let scan_request = SqliteMessageScanRequest {
7129            raw_query,
7130            filters: &filters,
7131            limit,
7132            offset,
7133            field_mask,
7134            query_match_type,
7135        };
7136        if let Err(err) =
7137            crate::storage::sqlite::validate_fts_messages_integrity_for_connection(conn)
7138        {
7139            tracing::warn!(
7140                error = %err,
7141                "sqlite FTS fallback integrity check failed; using source-table scan fallback"
7142            );
7143            return self.search_sqlite_message_scan(conn, scan_request);
7144        }
7145        let uses_message_id =
7146            if let Ok(uses_message_id) = Self::sqlite_fts_uses_message_id_column(conn) {
7147                uses_message_id
7148            } else {
7149                tracing::warn!(
7150                    "sqlite FTS fallback is present but not queryable; skipping fallback search"
7151                );
7152                return self.search_sqlite_message_scan(conn, scan_request);
7153            };
7154        let match_mode = match Self::sqlite_fts_match_mode(conn) {
7155            Ok(match_mode) => match_mode,
7156            Err(err) => {
7157                tracing::warn!(
7158                    error = %err,
7159                    "sqlite FTS fallback is present but not queryable; skipping fallback search"
7160                );
7161                return self.search_sqlite_message_scan(conn, scan_request);
7162            }
7163        };
7164        if !Self::sqlite_fts5_rowid_projection_available(conn) {
7165            tracing::warn!(
7166                "sqlite FTS fallback cannot project rowid through frankensqlite; using source-table scan fallback"
7167            );
7168            return self.search_sqlite_message_scan(conn, scan_request);
7169        }
7170
7171        let post_filter = Self::sqlite_fts5_filters_need_post_hydration(&filters);
7172        let target_hits = if post_filter {
7173            offset.saturating_add(limit)
7174        } else {
7175            limit
7176        };
7177        let rank_batch_limit = if post_filter {
7178            target_hits.clamp(1, SQLITE_FTS5_POST_FILTER_SCAN_CHUNK)
7179        } else {
7180            limit
7181        };
7182        let mut rank_offset = if post_filter { 0 } else { offset };
7183        let mut scanned_rows = 0usize;
7184        let mut hits = Vec::with_capacity(target_hits.min(rank_batch_limit));
7185
7186        loop {
7187            let (rank_sql, rank_params) = Self::sqlite_fts5_rank_query(
7188                fts_query.as_str(),
7189                &filters,
7190                rank_batch_limit,
7191                rank_offset,
7192                uses_message_id,
7193                match_mode,
7194            );
7195            let ranked_rows: Vec<(i64, f64)> =
7196                match franken_query_map_collect_retry(conn, &rank_sql, &rank_params, |row| {
7197                    Ok((row.get_typed(0)?, row.get_typed(1)?))
7198                }) {
7199                    Ok(rows) => rows,
7200                    Err(err) => {
7201                        tracing::warn!(
7202                            error = %err,
7203                            "sqlite FTS fallback rank query failed; returning no fallback hits"
7204                        );
7205                        return self.search_sqlite_message_scan(conn, scan_request);
7206                    }
7207                };
7208            if ranked_rows.is_empty() {
7209                break;
7210            }
7211
7212            scanned_rows = scanned_rows.saturating_add(ranked_rows.len());
7213            let bm25_by_rowid: HashMap<i64, f64> = ranked_rows.iter().copied().collect();
7214            let mut fts_rows_by_rowid = HashMap::with_capacity(ranked_rows.len());
7215            let mut message_ids = Vec::with_capacity(ranked_rows.len());
7216            let mut seen_message_ids = HashSet::with_capacity(ranked_rows.len());
7217
7218            for rank_chunk in Self::sqlite_fts5_hydrate_row_chunks(&ranked_rows) {
7219                let hydrate_sql =
7220                    Self::sqlite_fts5_hydrate_query(rank_chunk.len(), field_mask, uses_message_id);
7221                let hydrate_params = rank_chunk
7222                    .iter()
7223                    .map(|(fts_rowid, _)| ParamValue::from(*fts_rowid))
7224                    .collect::<Vec<_>>();
7225                let rows: Vec<SqliteFtsHydratedRow> = match franken_query_map_collect_retry(
7226                    conn,
7227                    &hydrate_sql,
7228                    &hydrate_params,
7229                    |row| {
7230                        Ok((
7231                            row.get_typed(0)?,
7232                            row.get_typed(1)?,
7233                            row.get_typed(2)?,
7234                            row.get_typed(3)?,
7235                            row.get_typed(4)?,
7236                            row.get_typed(5)?,
7237                            row.get_typed(6)?,
7238                            row.get_typed(7)?,
7239                        ))
7240                    },
7241                ) {
7242                    Ok(rows) => rows,
7243                    Err(err) => {
7244                        tracing::warn!(
7245                            error = %err,
7246                            "sqlite FTS fallback rowid hydration query failed; returning no fallback hits"
7247                        );
7248                        return self.search_sqlite_message_scan(conn, scan_request);
7249                    }
7250                };
7251
7252                for row in rows {
7253                    let fts_rowid = row.0;
7254                    let message_id = row.1.unwrap_or(fts_rowid);
7255                    if seen_message_ids.insert(message_id) {
7256                        message_ids.push(message_id);
7257                    }
7258                    fts_rows_by_rowid.insert(fts_rowid, row);
7259                }
7260            }
7261
7262            let mut metadata_by_message_id = HashMap::with_capacity(message_ids.len());
7263            for message_chunk in message_ids.chunks(SQLITE_FTS5_HYDRATE_PARAM_CHUNK) {
7264                let metadata_sql =
7265                    Self::sqlite_fts5_message_hydrate_query(message_chunk.len(), field_mask);
7266                let metadata_params = message_chunk
7267                    .iter()
7268                    .map(|message_id| ParamValue::from(*message_id))
7269                    .collect::<Vec<_>>();
7270                let metadata_rows: Vec<SqliteFtsMessageRow> = match franken_query_map_collect_retry(
7271                    conn,
7272                    &metadata_sql,
7273                    &metadata_params,
7274                    |row| {
7275                        Ok((
7276                            row.get_typed(0)?,
7277                            row.get_typed(1)?,
7278                            row.get_typed(2)?,
7279                            row.get_typed(3)?,
7280                            row.get_typed(4)?,
7281                            row.get_typed(5)?,
7282                            row.get_typed(6)?,
7283                            row.get_typed(7)?,
7284                            row.get_typed(8)?,
7285                            row.get_typed::<Option<String>>(9)?,
7286                            row.get_typed(10)?,
7287                            row.get_typed(11)?,
7288                        ))
7289                    },
7290                ) {
7291                    Ok(rows) => rows,
7292                    Err(err) => {
7293                        tracing::warn!(
7294                            error = %err,
7295                            "sqlite FTS fallback message hydration query failed; returning no fallback hits"
7296                        );
7297                        return self.search_sqlite_message_scan(conn, scan_request);
7298                    }
7299                };
7300                metadata_by_message_id.extend(metadata_rows.into_iter().map(|row| (row.0, row)));
7301            }
7302
7303            let mut hits_by_rowid = HashMap::with_capacity(ranked_rows.len());
7304            for (
7305                fts_rowid,
7306                fts_message_id,
7307                fts_title,
7308                fts_content,
7309                fts_agent,
7310                fts_workspace,
7311                fts_source_path,
7312                fts_created_at,
7313            ) in fts_rows_by_rowid.into_values()
7314            {
7315                let Some(&bm25_score) = bm25_by_rowid.get(&fts_rowid) else {
7316                    continue;
7317                };
7318                let message_id = fts_message_id.unwrap_or(fts_rowid);
7319                let (
7320                    title,
7321                    raw_content,
7322                    agent,
7323                    workspace,
7324                    source_path,
7325                    created_at,
7326                    idx,
7327                    conversation_id,
7328                    raw_source_id,
7329                    origin_host,
7330                    raw_origin_kind,
7331                ) = match metadata_by_message_id.remove(&message_id) {
7332                    Some((
7333                        _,
7334                        metadata_title,
7335                        metadata_content,
7336                        metadata_agent,
7337                        metadata_workspace,
7338                        metadata_source_path,
7339                        metadata_created_at,
7340                        metadata_idx,
7341                        metadata_conversation_id,
7342                        metadata_raw_source_id,
7343                        metadata_origin_host,
7344                        metadata_raw_origin_kind,
7345                    )) => (
7346                        if metadata_title.is_empty() {
7347                            fts_title.unwrap_or_default()
7348                        } else {
7349                            metadata_title
7350                        },
7351                        if metadata_content.is_empty() {
7352                            fts_content.unwrap_or_default()
7353                        } else {
7354                            metadata_content
7355                        },
7356                        if metadata_agent.is_empty() {
7357                            fts_agent.unwrap_or_default()
7358                        } else {
7359                            metadata_agent
7360                        },
7361                        if metadata_workspace.is_empty() {
7362                            fts_workspace.unwrap_or_default()
7363                        } else {
7364                            metadata_workspace
7365                        },
7366                        if metadata_source_path.is_empty() {
7367                            fts_source_path.unwrap_or_default()
7368                        } else {
7369                            metadata_source_path
7370                        },
7371                        metadata_created_at.or(fts_created_at),
7372                        metadata_idx,
7373                        metadata_conversation_id,
7374                        metadata_raw_source_id.unwrap_or_else(default_source_id),
7375                        metadata_origin_host,
7376                        metadata_raw_origin_kind,
7377                    ),
7378                    None => (
7379                        fts_title.unwrap_or_default(),
7380                        fts_content.unwrap_or_default(),
7381                        fts_agent.unwrap_or_default(),
7382                        fts_workspace.unwrap_or_default(),
7383                        fts_source_path.unwrap_or_default(),
7384                        fts_created_at,
7385                        None,
7386                        None,
7387                        default_source_id(),
7388                        None,
7389                        None,
7390                    ),
7391                };
7392
7393                let source_id = normalized_search_hit_source_id_parts(
7394                    raw_source_id.as_str(),
7395                    raw_origin_kind.as_deref().unwrap_or_default(),
7396                    origin_host.as_deref(),
7397                );
7398                let origin_kind = normalized_search_hit_origin_kind(
7399                    source_id.as_str(),
7400                    raw_origin_kind.as_deref(),
7401                );
7402                let line_number = idx
7403                    .and_then(|i| usize::try_from(i).ok())
7404                    .map(|i| i.saturating_add(1));
7405                let snippet = if field_mask.wants_snippet() {
7406                    snippet_from_content(&raw_content)
7407                } else {
7408                    String::new()
7409                };
7410                let content = if field_mask.needs_content() {
7411                    raw_content
7412                } else {
7413                    String::new()
7414                };
7415                let content_hash = if content.is_empty() {
7416                    stable_hit_hash(&snippet, &source_path, line_number, created_at)
7417                } else {
7418                    stable_hit_hash(&content, &source_path, line_number, created_at)
7419                };
7420
7421                let hit = SearchHit {
7422                    title,
7423                    snippet,
7424                    content,
7425                    content_hash,
7426                    conversation_id,
7427                    score: (-bm25_score) as f32,
7428                    source_path,
7429                    agent,
7430                    workspace,
7431                    workspace_original: None,
7432                    created_at,
7433                    line_number,
7434                    match_type: query_match_type,
7435                    source_id,
7436                    origin_kind,
7437                    origin_host,
7438                };
7439                hits_by_rowid.insert(fts_rowid, hit);
7440            }
7441
7442            for (fts_rowid, _) in &ranked_rows {
7443                if let Some(hit) = hits_by_rowid.remove(fts_rowid)
7444                    && Self::sqlite_fts5_hit_matches_filters(&hit, &filters)
7445                {
7446                    hits.push(hit);
7447                    if hits.len() >= target_hits {
7448                        break;
7449                    }
7450                }
7451            }
7452
7453            if hits.len() >= target_hits
7454                || !post_filter
7455                || ranked_rows.len() < rank_batch_limit
7456                || scanned_rows >= SQLITE_FTS5_POST_FILTER_SCAN_LIMIT
7457            {
7458                break;
7459            }
7460            rank_offset = rank_offset.saturating_add(ranked_rows.len());
7461        }
7462
7463        if post_filter {
7464            let hits = hits
7465                .into_iter()
7466                .skip(offset)
7467                .take(limit)
7468                .collect::<Vec<_>>();
7469            if hits.is_empty() {
7470                self.search_sqlite_message_scan(conn, scan_request)
7471            } else {
7472                Ok(hits)
7473            }
7474        } else if hits.is_empty() {
7475            self.search_sqlite_message_scan(conn, scan_request)
7476        } else {
7477            Ok(hits)
7478        }
7479    }
7480
7481    /// Browse messages ordered by date, without any text query.
7482    ///
7483    /// Used when the TUI query is empty and the user wants to see recent (or
7484    /// oldest) sessions. Bypasses BM25 scoring entirely and returns results
7485    /// ordered by `created_at`. Applies agent, workspace, time-range, and
7486    /// source filters identically to the normal search path.
7487    pub fn browse_by_date(
7488        &self,
7489        filters: SearchFilters,
7490        limit: usize,
7491        offset: usize,
7492        newest_first: bool,
7493        field_mask: FieldMask,
7494    ) -> Result<Vec<SearchHit>> {
7495        let sqlite_guard = self.sqlite_guard()?;
7496        if let Some(conn) = sqlite_guard.as_ref() {
7497            self.browse_by_date_sqlite(conn, filters, limit, offset, newest_first, field_mask)
7498        } else {
7499            Ok(Vec::new())
7500        }
7501    }
7502
7503    fn browse_by_date_sqlite(
7504        &self,
7505        conn: &Connection,
7506        filters: SearchFilters,
7507        limit: usize,
7508        offset: usize,
7509        newest_first: bool,
7510        field_mask: FieldMask,
7511    ) -> Result<Vec<SearchHit>> {
7512        let order = if newest_first { "DESC" } else { "ASC" };
7513        let title_expr = if field_mask.wants_title() {
7514            "c.title"
7515        } else {
7516            "''"
7517        };
7518        // Replace INNER JOIN agents with a correlated subquery: (a) avoids
7519        // frankensqlite's multi-table-JOIN-with-LIMIT/OFFSET materialization
7520        // fallback on every paginated search, and (b) stops silently dropping
7521        // search hits whose conversation has a NULL agent_id (legacy V1 rows)
7522        // by degrading to 'unknown' consistently with e1c08e7c / 8a0c547c.
7523        // The agent filter below becomes an EXISTS guard instead of a slug
7524        // equality on the joined column.
7525        let normalized_source_sql =
7526            normalized_search_source_id_sql_expr("c.source_id", "s.kind", "c.origin_host");
7527        let mut sql = format!(
7528            "SELECT c.id, {title_expr}, m.content, \
7529                 COALESCE((SELECT a.slug FROM agents a WHERE a.id = c.agent_id), 'unknown'), \
7530                 w.path, c.source_path, m.created_at, m.idx, \
7531                 {normalized_source_sql}, c.origin_host, s.kind
7532             FROM messages m
7533             JOIN conversations c ON m.conversation_id = c.id
7534             LEFT JOIN workspaces w ON c.workspace_id = w.id
7535             LEFT JOIN sources s ON c.source_id = s.id
7536             WHERE 1=1"
7537        );
7538        let mut params: Vec<ParamValue> = Vec::new();
7539
7540        if !filters.agents.is_empty() {
7541            let placeholders = sql_placeholders(filters.agents.len());
7542            sql.push_str(&format!(
7543                " AND EXISTS (SELECT 1 FROM agents a WHERE a.id = c.agent_id AND a.slug IN ({placeholders}))"
7544            ));
7545            for a in &filters.agents {
7546                params.push(ParamValue::from(a.as_str()));
7547            }
7548        }
7549
7550        if !filters.workspaces.is_empty() {
7551            let placeholders = sql_placeholders(filters.workspaces.len());
7552            sql.push_str(&format!(" AND COALESCE(w.path, '') IN ({placeholders})"));
7553            for w in &filters.workspaces {
7554                params.push(ParamValue::from(w.as_str()));
7555            }
7556        }
7557
7558        if let Some(created_from) = filters.created_from {
7559            sql.push_str(" AND m.created_at >= ?");
7560            params.push(ParamValue::from(created_from));
7561        }
7562        if let Some(created_to) = filters.created_to {
7563            sql.push_str(" AND m.created_at <= ?");
7564            params.push(ParamValue::from(created_to));
7565        }
7566
7567        // Apply source filter
7568        match &filters.source_filter {
7569            SourceFilter::All => {}
7570            SourceFilter::Local => sql.push_str(&format!(
7571                " AND {normalized_source_sql} = '{local}'",
7572                local = crate::sources::provenance::LOCAL_SOURCE_ID,
7573            )),
7574            SourceFilter::Remote => sql.push_str(&format!(
7575                " AND {normalized_source_sql} != '{local}'",
7576                local = crate::sources::provenance::LOCAL_SOURCE_ID,
7577            )),
7578            SourceFilter::SourceId(id) => {
7579                sql.push_str(&format!(" AND {normalized_source_sql} = ?"));
7580                params.push(ParamValue::from(normalize_search_source_filter_value(id)));
7581            }
7582        }
7583
7584        sql.push_str(&format!(
7585            " ORDER BY CASE WHEN m.created_at IS NULL THEN 1 ELSE 0 END, m.created_at {order}, m.id {order} LIMIT ? OFFSET ?"
7586        ));
7587        params.push(ParamValue::from(limit as i64));
7588        params.push(ParamValue::from(offset as i64));
7589
7590        let rows: Vec<SearchHit> =
7591            conn.query_map_collect(&sql, &params, |row: &frankensqlite::Row| {
7592                let conversation_id: i64 = row.get_typed(0)?;
7593                let title: String = if field_mask.wants_title() {
7594                    row.get_typed::<Option<String>>(1)?.unwrap_or_default()
7595                } else {
7596                    String::new()
7597                };
7598                let raw_content: String = row.get_typed(2)?;
7599                let agent: String = row.get_typed(3)?;
7600                let workspace: Option<String> = row.get_typed(4)?;
7601                let source_path: String = row.get_typed(5)?;
7602                let created_at: Option<i64> = row.get_typed(6)?;
7603                let idx: Option<i64> = row.get_typed(7)?;
7604                let raw_source_id: String = row
7605                    .get_typed::<Option<String>>(8)?
7606                    .unwrap_or_else(default_source_id);
7607                let origin_host: Option<String> = row.get_typed(9)?;
7608                let raw_origin_kind: Option<String> = row.get_typed(10)?;
7609                let source_id = normalized_search_hit_source_id_parts(
7610                    raw_source_id.as_str(),
7611                    raw_origin_kind.as_deref().unwrap_or_default(),
7612                    origin_host.as_deref(),
7613                );
7614                let origin_kind = normalized_search_hit_origin_kind(
7615                    source_id.as_str(),
7616                    raw_origin_kind.as_deref(),
7617                );
7618                let line_number = idx
7619                    .and_then(|i| usize::try_from(i).ok())
7620                    .map(|i| i.saturating_add(1));
7621                let snippet = if field_mask.wants_snippet() {
7622                    snippet_from_content(&raw_content)
7623                } else {
7624                    String::new()
7625                };
7626                let content = if field_mask.needs_content() {
7627                    raw_content.clone()
7628                } else {
7629                    String::new()
7630                };
7631                let content_hash =
7632                    stable_hit_hash(&raw_content, &source_path, line_number, created_at);
7633                Ok(SearchHit {
7634                    title,
7635                    snippet,
7636                    content,
7637                    content_hash,
7638                    conversation_id: Some(conversation_id),
7639                    score: 0.0,
7640                    source_path,
7641                    agent,
7642                    workspace: workspace.unwrap_or_default(),
7643                    workspace_original: None,
7644                    created_at,
7645                    line_number,
7646                    match_type: MatchType::Exact,
7647                    source_id,
7648                    origin_kind,
7649                    origin_host,
7650                })
7651            })?;
7652        Ok(rows)
7653    }
7654}
7655
7656/// Fuzz-only re-export of `transpile_to_fts5` so
7657/// `fuzz_targets/fuzz_query_transpiler.rs` can exercise the
7658/// user-reachable query-rewriting path (bead
7659/// `coding_agent_session_search-ugp09`). `#[doc(hidden)]` keeps it
7660/// off the public API surface — callers outside the fuzz harness
7661/// should go through `QueryExplanation::analyze` or `SearchClient`.
7662#[doc(hidden)]
7663pub fn fuzz_transpile_to_fts5(raw_query: &str) -> Option<String> {
7664    transpile_to_fts5(raw_query)
7665}
7666
7667/// Transpile a raw query string into an FTS5-compatible query string.
7668/// Preserves custom precedence (OR > AND) by adding parentheses.
7669/// Returns None if the query contains features unsupported by FTS5 (e.g. leading wildcards).
7670fn transpile_to_fts5(raw_query: &str) -> Option<String> {
7671    let tokens = fs_cass_parse_boolean_query(raw_query);
7672    if tokens.is_empty() {
7673        return Some("".to_string());
7674    }
7675
7676    let mut fts_clauses: Vec<(&str, String)> = Vec::new();
7677    let mut pending_or_group: Vec<String> = Vec::new();
7678    let mut next_op = "AND";
7679    let mut in_or_sequence = false;
7680    for token in tokens {
7681        match token {
7682            FsCassQueryToken::And => {
7683                if !pending_or_group.is_empty() {
7684                    let group = if pending_or_group.len() > 1 {
7685                        format!("({})", pending_or_group.join(" OR "))
7686                    } else {
7687                        pending_or_group.pop().unwrap_or_default()
7688                    };
7689                    fts_clauses.push(("AND", group));
7690                    pending_or_group.clear();
7691                }
7692                in_or_sequence = false;
7693                next_op = "AND";
7694            }
7695            FsCassQueryToken::Or => {
7696                if fts_clauses.is_empty() && pending_or_group.is_empty() {
7697                    // Be permissive with a leading OR the same way we already
7698                    // salvage a leading AND: ignore it instead of turning the
7699                    // whole fallback query into an empty result set.
7700                    continue;
7701                }
7702                // Start or continue an OR group. Unsupported `OR NOT` forms
7703                // are rejected when the subsequent NOT token arrives.
7704                in_or_sequence = true;
7705            }
7706            FsCassQueryToken::Not => {
7707                // FTS5 supports binary (`foo NOT bar`) NOT, but not a leading
7708                // unary-NOT query (`NOT foo`). We also reject `OR NOT` groupings
7709                // in the fallback transpiler.
7710                if in_or_sequence {
7711                    return None;
7712                }
7713
7714                if fts_clauses.is_empty() && pending_or_group.is_empty() {
7715                    return None;
7716                }
7717
7718                if !pending_or_group.is_empty() {
7719                    let group = if pending_or_group.len() > 1 {
7720                        format!("({})", pending_or_group.join(" OR "))
7721                    } else {
7722                        pending_or_group.pop().unwrap_or_default()
7723                    };
7724                    fts_clauses.push(("AND", group));
7725                    pending_or_group.clear();
7726                }
7727                in_or_sequence = false;
7728                next_op = "NOT";
7729            }
7730            FsCassQueryToken::Term(t) => {
7731                let raw_pattern = FsCassWildcardPattern::parse(&t);
7732                if matches!(
7733                    raw_pattern,
7734                    FsCassWildcardPattern::Suffix(_)
7735                        | FsCassWildcardPattern::Substring(_)
7736                        | FsCassWildcardPattern::Complex(_)
7737                ) {
7738                    return None;
7739                }
7740
7741                // Sanitize and normalize. FTS5 implicitly ANDs words in a string,
7742                // but we split punctuation into porter-aligned fragments first so
7743                // fallback queries match SQLite tokenization.
7744                let term_parts = normalize_term_parts(&t);
7745                if term_parts.is_empty() {
7746                    continue;
7747                }
7748
7749                let mut rendered_parts = Vec::with_capacity(term_parts.len());
7750                for part in &term_parts {
7751                    rendered_parts.push(render_fts5_term_part(part)?);
7752                }
7753
7754                // If multiple parts, wrap in parens and join with AND so a
7755                // punctuated term like `foo-bar` becomes `(foo AND bar)`.
7756                let fts_term = if rendered_parts.len() > 1 {
7757                    format!("({})", rendered_parts.join(" AND "))
7758                } else {
7759                    rendered_parts[0].clone()
7760                };
7761
7762                if in_or_sequence {
7763                    if pending_or_group.is_empty() {
7764                        let (op, _) = fts_clauses.last()?;
7765                        if *op != "AND" {
7766                            // `(... NOT ...) OR ...` cannot be represented
7767                            // with our FTS5 fallback transpilation.
7768                            return None;
7769                        }
7770                        let (_, val) = fts_clauses.pop()?;
7771                        pending_or_group.push(val);
7772                    }
7773                    pending_or_group.push(fts_term);
7774                    in_or_sequence = true;
7775                } else {
7776                    fts_clauses.push((next_op, fts_term));
7777                }
7778                next_op = "AND";
7779            }
7780            FsCassQueryToken::Phrase(p) => {
7781                let phrase_parts = normalize_phrase_terms(&p);
7782                if phrase_parts.is_empty() {
7783                    continue;
7784                }
7785                let fts_phrase = format!("\"{}\"", phrase_parts.join(" "));
7786
7787                if in_or_sequence {
7788                    if pending_or_group.is_empty() {
7789                        let (op, _) = fts_clauses.last()?;
7790                        if *op != "AND" {
7791                            // `(... NOT ...) OR ...` cannot be represented
7792                            // with our FTS5 fallback transpilation.
7793                            return None;
7794                        }
7795                        let (_, val) = fts_clauses.pop()?;
7796                        pending_or_group.push(val);
7797                    }
7798                    pending_or_group.push(fts_phrase);
7799                    in_or_sequence = true;
7800                } else {
7801                    fts_clauses.push((next_op, fts_phrase));
7802                }
7803                next_op = "AND";
7804            }
7805        }
7806    }
7807
7808    if !pending_or_group.is_empty() {
7809        let group = if pending_or_group.len() > 1 {
7810            format!("({})", pending_or_group.join(" OR "))
7811        } else {
7812            pending_or_group.pop().unwrap_or_default()
7813        };
7814        fts_clauses.push((next_op, group));
7815    }
7816
7817    if fts_clauses.is_empty() {
7818        return Some("".to_string());
7819    }
7820
7821    // Safety guard: the fallback transpiler must never emit NOT as the first
7822    // operator because SQLite FTS5 requires a left operand.
7823    if fts_clauses.first().is_some_and(|(op, _)| *op == "NOT") {
7824        return None;
7825    }
7826
7827    // Join clauses. The first operator is ignored (start of query).
7828    let mut query = String::new();
7829    for (i, (op, text)) in fts_clauses.into_iter().enumerate() {
7830        if i > 0 {
7831            query.push_str(&format!(" {} ", op));
7832        }
7833        query.push_str(&text);
7834    }
7835
7836    Some(query)
7837}
7838
7839#[derive(Default, Clone)]
7840struct Metrics {
7841    cache_hits: Arc<AtomicU64>,
7842    cache_miss: Arc<AtomicU64>,
7843    cache_shortfall: Arc<AtomicU64>,
7844    reloads: Arc<AtomicU64>,
7845    reload_ms_total: Arc<AtomicU64>,
7846    prewarm_scheduled: Arc<AtomicU64>,
7847    prewarm_skipped_pressure: Arc<AtomicU64>,
7848}
7849
7850impl Metrics {
7851    fn inc_cache_hits(&self) {
7852        self.cache_hits.fetch_add(1, Ordering::Relaxed);
7853    }
7854    fn inc_cache_miss(&self) {
7855        self.cache_miss.fetch_add(1, Ordering::Relaxed);
7856    }
7857    fn inc_cache_shortfall(&self) {
7858        self.cache_shortfall.fetch_add(1, Ordering::Relaxed);
7859    }
7860    fn inc_prewarm_scheduled(&self) {
7861        self.prewarm_scheduled.fetch_add(1, Ordering::Relaxed);
7862    }
7863    fn inc_prewarm_skipped_pressure(&self) {
7864        self.prewarm_skipped_pressure
7865            .fetch_add(1, Ordering::Relaxed);
7866    }
7867    fn inc_reload(&self) {
7868        self.reloads.fetch_add(1, Ordering::Relaxed);
7869    }
7870    fn record_reload(&self, duration: Duration) {
7871        self.inc_reload();
7872        self.reload_ms_total
7873            .fetch_add(duration.as_millis() as u64, Ordering::Relaxed);
7874    }
7875
7876    fn snapshot_all(&self) -> (u64, u64, u64, u64, u128) {
7877        (
7878            self.cache_hits.load(Ordering::Relaxed),
7879            self.cache_miss.load(Ordering::Relaxed),
7880            self.cache_shortfall.load(Ordering::Relaxed),
7881            self.reloads.load(Ordering::Relaxed),
7882            self.reload_ms_total.load(Ordering::Relaxed) as u128,
7883        )
7884    }
7885
7886    fn snapshot_prewarm(&self) -> (u64, u64) {
7887        (
7888            self.prewarm_scheduled.load(Ordering::Relaxed),
7889            self.prewarm_skipped_pressure.load(Ordering::Relaxed),
7890        )
7891    }
7892
7893    #[cfg(test)]
7894    #[allow(dead_code)]
7895    fn reset(&self) {
7896        self.cache_hits.store(0, Ordering::Relaxed);
7897        self.cache_miss.store(0, Ordering::Relaxed);
7898        self.cache_shortfall.store(0, Ordering::Relaxed);
7899        self.reloads.store(0, Ordering::Relaxed);
7900        self.reload_ms_total.store(0, Ordering::Relaxed);
7901        self.prewarm_scheduled.store(0, Ordering::Relaxed);
7902        self.prewarm_skipped_pressure.store(0, Ordering::Relaxed);
7903    }
7904}
7905
7906fn maybe_spawn_warm_worker(
7907    reader: IndexReader,
7908    fields: FsCassFields,
7909    reload_epoch: Arc<AtomicU64>,
7910    metrics: Metrics,
7911) -> Option<(mpsc::Sender<WarmJob>, std::thread::JoinHandle<()>)> {
7912    let (tx, rx) = mpsc::unbounded::<WarmJob>();
7913    let handle = std::thread::Builder::new()
7914        .name("cass-warm-worker".into())
7915        .spawn(move || {
7916            // Simple debounce: process at most one warmup every WARM_DEBOUNCE_MS.
7917            let mut last_run = Instant::now();
7918            while let Ok(job) = rx.recv() {
7919                let now = Instant::now();
7920                if now.duration_since(last_run) < Duration::from_millis(*WARM_DEBOUNCE_MS) {
7921                    continue;
7922                }
7923                last_run = now;
7924                let reload_started = Instant::now();
7925                if let Err(err) = reader.reload() {
7926                    tracing::warn!(error = ?err, "warm_worker_reload_failed");
7927                    continue;
7928                }
7929                let elapsed = reload_started.elapsed();
7930                let epoch = reload_epoch.fetch_add(1, Ordering::SeqCst) + 1;
7931                metrics.record_reload(elapsed);
7932                tracing::debug!(
7933                    duration_ms = elapsed.as_millis() as u64,
7934                    reload_epoch = epoch,
7935                    filters = %job.filters_fingerprint,
7936                    shard = %job.shard_name,
7937                    "warm_worker_reload"
7938                );
7939                // Run a tiny warm search to prefill OS cache and hit the Tantivy reader
7940                // without allocating full result sets. Limit 1 doc.
7941                let searcher = reader.searcher();
7942                let mut clauses: Vec<(Occur, Box<dyn Query>)> = Vec::new();
7943                for term_str in job.query.split_whitespace() {
7944                    let term_lower = term_str.to_lowercase();
7945                    let term_shoulds: Vec<(Occur, Box<dyn Query>)> = vec![
7946                        (
7947                            Occur::Should,
7948                            Box::new(TermQuery::new(
7949                                Term::from_field_text(fields.title, &term_lower),
7950                                IndexRecordOption::WithFreqsAndPositions,
7951                            )),
7952                        ),
7953                        (
7954                            Occur::Should,
7955                            Box::new(TermQuery::new(
7956                                Term::from_field_text(fields.content, &term_lower),
7957                                IndexRecordOption::WithFreqsAndPositions,
7958                            )),
7959                        ),
7960                    ];
7961                    clauses.push((Occur::Must, Box::new(BooleanQuery::new(term_shoulds))));
7962                }
7963                if !clauses.is_empty() {
7964                    let q: Box<dyn Query> = Box::new(BooleanQuery::new(clauses));
7965                    let _ = searcher.search(&q, &TopDocs::with_limit(1).order_by_score());
7966                }
7967            }
7968        })
7969        .ok()?;
7970    Some((tx, handle))
7971}
7972
7973fn cached_hit_from(hit: &SearchHit) -> CachedHit {
7974    let cache_text = if hit.content.is_empty() {
7975        hit.snippet.as_str()
7976    } else {
7977        hit.content.as_str()
7978    };
7979    let lc_content = cache_text.to_lowercase();
7980    let lc_title = (!hit.title.is_empty()).then(|| hit.title.to_lowercase());
7981    // Snippet is derived from content, so we don't index/bloom it separately
7982    let bloom64 = bloom_from_text(&lc_content, &lc_title);
7983    CachedHit {
7984        hit: hit.clone(),
7985        lc_content,
7986        lc_title,
7987        bloom64,
7988    }
7989}
7990
7991fn bloom_from_text(content: &str, title: &Option<String>) -> u64 {
7992    let mut bits = 0u64;
7993    for token in token_stream(content) {
7994        bits |= hash_token(token);
7995    }
7996    if let Some(t) = title {
7997        for token in token_stream(t) {
7998            bits |= hash_token(token);
7999        }
8000    }
8001    bits
8002}
8003
8004fn token_stream(text: &str) -> impl Iterator<Item = &str> {
8005    text.split(|c: char| !c.is_alphanumeric())
8006        .filter(|s| !s.is_empty())
8007}
8008
8009fn hash_token(tok: &str) -> u64 {
8010    // Simple 64-bit djb2-style hash mapped to bit position 0..63
8011    let mut h: u64 = 5381;
8012    for b in tok.as_bytes() {
8013        h = ((h << 5).wrapping_add(h)).wrapping_add(u64::from(*b));
8014    }
8015    1u64 << (h % 64)
8016}
8017
8018// ============================================================================
8019// QueryTermsLower: Pre-computed lowercase query tokens (Opt 2.4)
8020// ============================================================================
8021//
8022// Avoids repeated to_lowercase() calls when filtering many cached hits.
8023// The query is lowercased once and tokens extracted once, then reused.
8024
8025/// Pre-computed lowercase query terms for efficient hit matching.
8026/// Call `from_query` once, then reuse for all hits in a search.
8027struct QueryTermsLower {
8028    /// The lowercased query string (owned to keep tokens valid)
8029    query_lower: String,
8030    /// Pre-computed token positions (start, end) into query_lower
8031    token_ranges: Vec<(usize, usize)>,
8032    /// Pre-computed bloom bits for fast rejection
8033    bloom_mask: u64,
8034}
8035
8036impl QueryTermsLower {
8037    /// Create from a query string, pre-computing lowercase and tokens.
8038    fn from_query(query: &str) -> Self {
8039        if query.is_empty() {
8040            return Self {
8041                query_lower: String::new(),
8042                token_ranges: Vec::new(),
8043                bloom_mask: 0,
8044            };
8045        }
8046
8047        let query_lower = query.to_lowercase();
8048        let mut token_ranges = Vec::new();
8049        let mut bloom_mask = 0u64;
8050
8051        // Extract token positions
8052        let mut start = None;
8053        for (i, c) in query_lower.char_indices() {
8054            if c.is_alphanumeric() {
8055                if start.is_none() {
8056                    start = Some(i);
8057                }
8058            } else if let Some(s) = start.take() {
8059                let token = &query_lower[s..i];
8060                bloom_mask |= hash_token(token);
8061                token_ranges.push((s, i));
8062            }
8063        }
8064        // Handle trailing token
8065        if let Some(s) = start {
8066            let token = &query_lower[s..];
8067            bloom_mask |= hash_token(token);
8068            token_ranges.push((s, query_lower.len()));
8069        }
8070
8071        Self {
8072            query_lower,
8073            token_ranges,
8074            bloom_mask,
8075        }
8076    }
8077
8078    /// Check if this query is empty (no tokens).
8079    #[inline]
8080    fn is_empty(&self) -> bool {
8081        self.token_ranges.is_empty()
8082    }
8083
8084    /// Iterate over the pre-computed lowercase tokens.
8085    #[inline]
8086    fn tokens(&self) -> impl Iterator<Item = &str> {
8087        self.token_ranges
8088            .iter()
8089            .map(|(s, e)| &self.query_lower[*s..*e])
8090    }
8091
8092    /// Get the bloom mask for fast rejection.
8093    #[inline]
8094    fn bloom_mask(&self) -> u64 {
8095        self.bloom_mask
8096    }
8097}
8098
8099/// Check if a cached hit matches the pre-computed query terms.
8100/// This is the optimized version that avoids repeated to_lowercase() calls.
8101fn hit_matches_query_cached_precomputed(hit: &CachedHit, terms: &QueryTermsLower) -> bool {
8102    if terms.is_empty() {
8103        return true;
8104    }
8105
8106    // Bloom gate: all query tokens must have bits set
8107    if hit.bloom64 & terms.bloom_mask() != terms.bloom_mask() {
8108        return false;
8109    }
8110
8111    // Verify each token matches as a prefix of a word in at least one field (implicit AND)
8112    terms.tokens().all(|t| {
8113        // Check content tokens
8114        if token_stream(&hit.lc_content).any(|word| word.starts_with(t)) {
8115            return true;
8116        }
8117        // Check title tokens
8118        if let Some(title) = &hit.lc_title
8119            && token_stream(title).any(|word| word.starts_with(t))
8120        {
8121            return true;
8122        }
8123        false
8124    })
8125}
8126
8127/// Legacy function for backward compatibility with tests.
8128/// Prefer `hit_matches_query_cached_precomputed` with `QueryTermsLower` for batch operations.
8129#[cfg(test)]
8130fn hit_matches_query_cached(hit: &CachedHit, query: &str) -> bool {
8131    let terms = QueryTermsLower::from_query(query);
8132    hit_matches_query_cached_precomputed(hit, &terms)
8133}
8134
8135fn is_prefix_only(query: &str) -> bool {
8136    let tokens: Vec<&str> = query.split_whitespace().collect();
8137    // Only strictly optimize single-term prefix queries.
8138    // Multi-term queries benefit from Tantivy's snippet generation (highlighting both terms).
8139    if tokens.len() != 1 {
8140        return false;
8141    }
8142    tokens[0].chars().all(char::is_alphanumeric)
8143}
8144
8145fn quick_prefix_snippet(content: &str, query: &str, max_chars: usize) -> String {
8146    // Handle empty query case first
8147    if query.is_empty() {
8148        let mut chars = content.chars();
8149        let snippet: String = chars.by_ref().take(max_chars).collect();
8150        return if chars.next().is_some() {
8151            format!("{snippet}…")
8152        } else {
8153            snippet
8154        };
8155    }
8156
8157    let lc_content = content.to_lowercase();
8158    let lc_query = query.to_lowercase();
8159
8160    if let Some(pos) = lc_content.find(&lc_query) {
8161        // Convert byte index in the lowercased string to a character index.
8162        let match_start_char_idx = lc_content[..pos].chars().count();
8163        let query_char_len = lc_query.chars().count();
8164
8165        // Determine where to start the snippet (aim for 15 chars before match)
8166        let start_char = match_start_char_idx.saturating_sub(15);
8167        let mut chars_iter = content.chars().skip(start_char);
8168        let mut snippet = String::new();
8169        let mut chars_taken = 0;
8170        let mut current_idx = start_char;
8171
8172        while chars_taken < max_chars {
8173            if current_idx == match_start_char_idx {
8174                snippet.push_str("**");
8175                for _ in 0..query_char_len {
8176                    if let Some(ch) = chars_iter.next() {
8177                        snippet.push(ch);
8178                        chars_taken += 1;
8179                        current_idx += 1;
8180                    }
8181                }
8182                snippet.push_str("**");
8183                if chars_taken >= max_chars {
8184                    break;
8185                }
8186                continue;
8187            }
8188
8189            if let Some(ch) = chars_iter.next() {
8190                snippet.push(ch);
8191                chars_taken += 1;
8192                current_idx += 1;
8193            } else {
8194                break;
8195            }
8196        }
8197
8198        if chars_iter.next().is_some() {
8199            format!("{snippet}…")
8200        } else {
8201            snippet
8202        }
8203    } else {
8204        let mut chars = content.chars();
8205        let snippet: String = chars.by_ref().take(max_chars).collect();
8206        if chars.next().is_some() {
8207            format!("{snippet}…")
8208        } else {
8209            snippet
8210        }
8211    }
8212}
8213
8214fn cached_prefix_snippet(content: &str, query: &str, max_chars: usize) -> Option<String> {
8215    if query.trim().is_empty() {
8216        return None;
8217    }
8218    let lc_content = content.to_lowercase();
8219    let lc_query = query.to_lowercase();
8220    lc_content.find(&lc_query).map(|pos| {
8221        let match_start_char_idx = lc_content[..pos].chars().count();
8222        let query_char_len = lc_query.chars().count();
8223
8224        let start_char = match_start_char_idx.saturating_sub(15);
8225        let mut chars_iter = content.chars().skip(start_char);
8226        let mut snippet = String::new();
8227        let mut chars_taken = 0;
8228        let mut current_idx = start_char;
8229
8230        while chars_taken < max_chars {
8231            if current_idx == match_start_char_idx {
8232                snippet.push_str("**");
8233                for _ in 0..query_char_len {
8234                    if let Some(ch) = chars_iter.next() {
8235                        snippet.push(ch);
8236                        chars_taken += 1;
8237                        current_idx += 1;
8238                    }
8239                }
8240                snippet.push_str("**");
8241                if chars_taken >= max_chars {
8242                    break;
8243                }
8244                continue;
8245            }
8246
8247            if let Some(ch) = chars_iter.next() {
8248                snippet.push(ch);
8249                chars_taken += 1;
8250                current_idx += 1;
8251            } else {
8252                break;
8253            }
8254        }
8255
8256        if chars_iter.next().is_some() {
8257            format!("{snippet}…")
8258        } else {
8259            snippet
8260        }
8261    })
8262}
8263
8264fn filters_fingerprint(filters: &SearchFilters) -> String {
8265    let mut parts = Vec::new();
8266    if !filters.agents.is_empty() {
8267        let mut v: Vec<_> = filters.agents.iter().cloned().collect();
8268        v.sort();
8269        parts.push(format!("a:{v:?}"));
8270    }
8271    if !filters.workspaces.is_empty() {
8272        let mut v: Vec<_> = filters.workspaces.iter().cloned().collect();
8273        v.sort();
8274        parts.push(format!("w:{v:?}"));
8275    }
8276    if let Some(f) = filters.created_from {
8277        parts.push(format!("from:{f}"));
8278    }
8279    if let Some(t) = filters.created_to {
8280        parts.push(format!("to:{t}"));
8281    }
8282    // Include source_filter in cache key (P3.1)
8283    if !matches!(
8284        filters.source_filter,
8285        crate::sources::provenance::SourceFilter::All
8286    ) {
8287        parts.push(format!("src:{:?}", filters.source_filter));
8288    }
8289    // Include session_paths in cache key (for chained searches)
8290    if !filters.session_paths.is_empty() {
8291        let mut v: Vec<_> = filters.session_paths.iter().cloned().collect();
8292        v.sort();
8293        parts.push(format!("sp:{v:?}"));
8294    }
8295    parts.join("|")
8296}
8297
8298impl SearchClient {
8299    /// Return the total number of indexed Tantivy documents.
8300    pub fn total_docs(&self) -> usize {
8301        if let Some((reader, _)) = &self.reader {
8302            return reader.searcher().num_docs() as usize;
8303        }
8304        self.federated_readers()
8305            .map(|readers| {
8306                readers
8307                    .iter()
8308                    .map(|shard| shard.reader.searcher().num_docs() as usize)
8309                    .sum()
8310            })
8311            .unwrap_or(0)
8312    }
8313
8314    /// Returns `true` if the Tantivy search index is available.
8315    pub fn has_tantivy(&self) -> bool {
8316        self.reader.is_some() || self.federated_readers().is_some()
8317    }
8318
8319    fn maybe_reload_reader(&self, reader: &IndexReader) -> Result<()> {
8320        if !self.reload_on_search {
8321            return Ok(());
8322        }
8323        const MIN_RELOAD_INTERVAL: Duration = Duration::from_millis(300);
8324        let now = Instant::now();
8325        let mut guard = self.last_reload.lock().unwrap_or_else(|e| e.into_inner());
8326        if guard
8327            .map(|t| now.duration_since(t) >= MIN_RELOAD_INTERVAL)
8328            .unwrap_or(true)
8329        {
8330            let reload_started = Instant::now();
8331            reader.reload()?;
8332            let elapsed = reload_started.elapsed();
8333            *guard = Some(now);
8334            let epoch = self.reload_epoch.fetch_add(1, Ordering::SeqCst) + 1;
8335            self.metrics.record_reload(elapsed);
8336            tracing::debug!(
8337                duration_ms = elapsed.as_millis() as u64,
8338                reload_epoch = epoch,
8339                "tantivy_reader_reload"
8340            );
8341        }
8342        Ok(())
8343    }
8344
8345    fn maybe_log_cache_metrics(&self, event: &str) {
8346        if !*CACHE_DEBUG_ENABLED {
8347            return;
8348        }
8349        let stats = self.cache_stats();
8350        tracing::debug!(
8351            event = event,
8352            hits = stats.cache_hits,
8353            miss = stats.cache_miss,
8354            shortfall = stats.cache_shortfall,
8355            reloads = stats.reloads,
8356            reload_ms_total = stats.reload_ms_total,
8357            total_cap = stats.total_cap,
8358            total_cost = stats.total_cost,
8359            evictions = stats.eviction_count,
8360            approx_bytes = stats.approx_bytes,
8361            byte_cap = stats.byte_cap,
8362            eviction_policy = stats.eviction_policy,
8363            ghost_entries = stats.ghost_entries,
8364            admission_rejects = stats.admission_rejects,
8365            "cache_metrics"
8366        );
8367    }
8368
8369    /// Generate an interned cache key for the given query and filters.
8370    /// Returns Arc<str> to enable memory sharing for repeated queries.
8371    fn cache_key(&self, query: &str, filters: &SearchFilters) -> Arc<str> {
8372        let key_str = format!(
8373            "{}|{}::{}",
8374            self.cache_namespace,
8375            query,
8376            filters_fingerprint(filters)
8377        );
8378        intern_cache_key(&key_str)
8379    }
8380
8381    fn shard_name(&self, filters: &SearchFilters) -> String {
8382        if filters.agents.len() == 1 {
8383            format!(
8384                "agent:{}",
8385                filters
8386                    .agents
8387                    .iter()
8388                    .next()
8389                    .cloned()
8390                    .unwrap_or_else(|| "global".into())
8391            )
8392        } else if filters.workspaces.len() == 1 {
8393            format!(
8394                "workspace:{}",
8395                filters
8396                    .workspaces
8397                    .iter()
8398                    .next()
8399                    .cloned()
8400                    .unwrap_or_else(|| "global".into())
8401            )
8402        } else {
8403            "global".into()
8404        }
8405    }
8406    fn cached_prefix_key_exists_in_shard(
8407        &self,
8408        shard: &LruCache<Arc<str>, Vec<CachedHit>>,
8409        query: &str,
8410        filters: &SearchFilters,
8411    ) -> bool {
8412        let mut byte_indices: Vec<usize> = query.char_indices().map(|(i, _)| i).collect();
8413        byte_indices.push(query.len());
8414        let query_len = query.len();
8415        for &end in byte_indices.iter().rev() {
8416            if end == 0 || end == query_len {
8417                continue;
8418            }
8419            let key = self.cache_key(&query[..end], filters);
8420            if shard.contains(&key) {
8421                return true;
8422            }
8423        }
8424        false
8425    }
8426
8427    fn maybe_schedule_adaptive_query_prewarm(&self, query: &str, filters: &SearchFilters) {
8428        if query.is_empty() {
8429            return;
8430        }
8431        let Some(tx) = &self.warm_tx else {
8432            return;
8433        };
8434
8435        let shard_name = self.shard_name(filters);
8436        let decision = match self.prefix_cache.lock() {
8437            Ok(cache) => {
8438                let hot_prefix = cache.shard_opt(&shard_name).is_some_and(|shard| {
8439                    self.cached_prefix_key_exists_in_shard(shard, query, filters)
8440                });
8441                if !hot_prefix {
8442                    AdaptivePrewarmDecision::SkipCold
8443                } else if cache.prewarm_pressure() {
8444                    AdaptivePrewarmDecision::SkipPressure
8445                } else {
8446                    AdaptivePrewarmDecision::Schedule
8447                }
8448            }
8449            Err(_) => return,
8450        };
8451
8452        if decision == AdaptivePrewarmDecision::SkipPressure {
8453            self.metrics.inc_prewarm_skipped_pressure();
8454            return;
8455        }
8456        if decision == AdaptivePrewarmDecision::SkipCold {
8457            return;
8458        }
8459
8460        if tx
8461            .send(WarmJob {
8462                query: query.to_string(),
8463                filters_fingerprint: filters_fingerprint(filters),
8464                shard_name,
8465            })
8466            .is_ok()
8467        {
8468            self.metrics.inc_prewarm_scheduled();
8469        }
8470    }
8471
8472    fn cached_prefix_hits(&self, query: &str, filters: &SearchFilters) -> Option<Vec<CachedHit>> {
8473        if query.is_empty() {
8474            return None;
8475        }
8476        let cache = self.prefix_cache.lock().ok()?;
8477        let shard_name = self.shard_name(filters);
8478        let shard = cache.shard_opt(&shard_name)?;
8479        // Iterate over character boundaries to avoid slicing mid-codepoint.
8480        let mut byte_indices: Vec<usize> = query.char_indices().map(|(i, _)| i).collect();
8481        byte_indices.push(query.len());
8482        for &end in byte_indices.iter().rev() {
8483            if end == 0 {
8484                continue;
8485            }
8486            let key = self.cache_key(&query[..end], filters);
8487            // LruCache.peek() accepts &Q where Arc<str>: Borrow<Q>, so &Arc<str> works
8488            if let Some(hits) = shard.peek(&key) {
8489                return Some(hits.clone());
8490            }
8491        }
8492        None
8493    }
8494
8495    fn put_cache(&self, query: &str, filters: &SearchFilters, hits: &[SearchHit]) {
8496        if query.is_empty() || hits.is_empty() {
8497            return;
8498        }
8499        if let Ok(mut cache) = self.prefix_cache.lock() {
8500            let shard_name = self.shard_name(filters);
8501            let key = self.cache_key(query, filters);
8502            let cached_hits: Vec<CachedHit> = hits.iter().map(cached_hit_from).collect();
8503            cache.put(&shard_name, key, cached_hits);
8504        }
8505    }
8506
8507    pub fn cache_stats(&self) -> CacheStats {
8508        let (hits, miss, shortfall, reloads, reload_ms_total) = self.metrics.snapshot_all();
8509        let (prewarm_scheduled, prewarm_skipped_pressure) = self.metrics.snapshot_prewarm();
8510        let reader_generation = self.last_generation.lock().ok().and_then(|guard| *guard);
8511        let (
8512            total_cap,
8513            total_cost,
8514            eviction_count,
8515            approx_bytes,
8516            byte_cap,
8517            eviction_policy,
8518            ghost_entries,
8519            admission_rejects,
8520        ) = if let Ok(cache) = self.prefix_cache.lock() {
8521            (
8522                cache.total_cap(),
8523                cache.total_cost(),
8524                cache.eviction_count(),
8525                cache.total_bytes(),
8526                cache.byte_cap(),
8527                cache.policy_label(),
8528                cache.ghost_entries(),
8529                cache.admission_rejects(),
8530            )
8531        } else {
8532            (0, 0, 0, 0, 0, "unknown", 0, 0)
8533        };
8534        CacheStats {
8535            cache_hits: hits,
8536            cache_miss: miss,
8537            cache_shortfall: shortfall,
8538            reloads,
8539            reload_ms_total,
8540            total_cap,
8541            total_cost,
8542            eviction_count,
8543            approx_bytes,
8544            byte_cap,
8545            eviction_policy,
8546            ghost_entries,
8547            admission_rejects,
8548            prewarm_scheduled,
8549            prewarm_skipped_pressure,
8550            reader_generation,
8551        }
8552    }
8553}
8554
8555#[cfg(test)]
8556mod tests {
8557    use super::*;
8558    use crate::connectors::{NormalizedConversation, NormalizedMessage, NormalizedSnippet};
8559    use crate::model::types::{Agent, AgentKind, Conversation, Message, MessageRole};
8560    use crate::search::tantivy::TantivyIndex;
8561    use crate::storage::sqlite::FrankenStorage;
8562    use frankensqlite::Connection as FrankenConnection;
8563    use frankensqlite::compat::ParamValue;
8564    use serde_json::json;
8565    use tempfile::TempDir;
8566
8567    // Reference implementation of the stable dedup key prior to bead num7z.
8568    // Kept in tests so the optimized `search_hit_key_doc_id` is pinned to
8569    // byte-identical output; any drift trips this assertion.
8570    fn search_hit_key_doc_id_reference_v0(key: &SearchHitKey) -> String {
8571        let sep = '\u{1f}';
8572        format!(
8573            "{}{sep}{}{sep}{}{sep}{}{sep}{}{sep}{}{sep}{}",
8574            key.source_id,
8575            key.source_path,
8576            key.conversation_id
8577                .map(|v| v.to_string())
8578                .unwrap_or_default(),
8579            key.title,
8580            key.line_number.map(|v| v.to_string()).unwrap_or_default(),
8581            key.created_at.map(|v| v.to_string()).unwrap_or_default(),
8582            key.content_hash,
8583        )
8584    }
8585
8586    fn stable_hit_hash_reference_v0(
8587        content: &str,
8588        source_path: &str,
8589        line_number: Option<usize>,
8590        created_at: Option<i64>,
8591    ) -> u64 {
8592        use xxhash_rust::xxh3::Xxh3;
8593
8594        let mut hasher = Xxh3::new();
8595        if !content.is_empty() {
8596            hasher.update(&stable_content_hash(content).to_le_bytes());
8597        }
8598        hasher.update(b"|");
8599        hasher.update(source_path.as_bytes());
8600        hasher.update(b"|");
8601        if let Some(line) = line_number {
8602            hasher.update(line.to_string().as_bytes());
8603        }
8604        hasher.update(b"|");
8605        if let Some(ts) = created_at {
8606            hasher.update(ts.to_string().as_bytes());
8607        }
8608        hasher.digest()
8609    }
8610
8611    fn vector_result(message_id: u64, score: f32) -> VectorSearchResult {
8612        VectorSearchResult {
8613            message_id,
8614            chunk_idx: 0,
8615            score,
8616        }
8617    }
8618
8619    #[test]
8620    fn semantic_exact_candidate_limit_overfetches_chunks_without_full_scan() {
8621        assert_eq!(SearchClient::semantic_exact_candidate_limit(10, 1_000), 40);
8622        assert_eq!(SearchClient::semantic_exact_candidate_limit(10, 25), 25);
8623        assert_eq!(SearchClient::semantic_exact_candidate_limit(0, 1_000), 0);
8624        assert_eq!(SearchClient::semantic_exact_candidate_limit(10, 0), 0);
8625    }
8626
8627    #[test]
8628    fn semantic_window_detects_possible_hidden_chunk_competitors() {
8629        let complete = vec![
8630            vector_result(1, 0.9),
8631            vector_result(2, 0.8),
8632            vector_result(3, 0.7),
8633        ];
8634        assert!(
8635            !SearchClient::semantic_window_may_omit_competitor(&complete, 3, Some(0.6)),
8636            "strictly lower omitted chunks cannot alter the top message window"
8637        );
8638        assert!(
8639            SearchClient::semantic_window_may_omit_competitor(&complete, 3, Some(0.7)),
8640            "equal-score omitted chunks can still alter deterministic tie-breaking"
8641        );
8642
8643        let duplicate_collapsed_shortfall = vec![vector_result(1, 0.9)];
8644        assert!(
8645            SearchClient::semantic_window_may_omit_competitor(
8646                &duplicate_collapsed_shortfall,
8647                3,
8648                Some(0.2),
8649            ),
8650            "a short collapsed window means high-scoring duplicate chunks may have hidden messages"
8651        );
8652        assert!(!SearchClient::semantic_window_may_omit_competitor(
8653            &complete, 3, None
8654        ));
8655    }
8656
8657    #[test]
8658    fn stable_hit_hash_matches_reference_and_is_deterministic() {
8659        let fixtures = [
8660            ("", "", None, None),
8661            (
8662                "same   content\nnormalized",
8663                "/tmp/session.jsonl",
8664                Some(1),
8665                Some(0),
8666            ),
8667            (
8668                "tool output with repeated whitespace",
8669                "/tmp/path with spaces.jsonl",
8670                Some(42),
8671                Some(1_700_000_000_000),
8672            ),
8673            (
8674                "unicode stays in the content hash path: café",
8675                "/remote/host/session.jsonl",
8676                Some(usize::MAX),
8677                Some(i64::MIN),
8678            ),
8679            (
8680                "negative timestamp fixture",
8681                "/tmp/negative.jsonl",
8682                None,
8683                Some(-123_456),
8684            ),
8685        ];
8686
8687        for (content, source_path, line_number, created_at) in fixtures {
8688            let optimized = stable_hit_hash(content, source_path, line_number, created_at);
8689            let repeated = stable_hit_hash(content, source_path, line_number, created_at);
8690            let reference =
8691                stable_hit_hash_reference_v0(content, source_path, line_number, created_at);
8692
8693            assert_eq!(optimized, repeated);
8694            assert_eq!(optimized, reference);
8695        }
8696    }
8697
8698    #[test]
8699    fn semantic_message_id_from_db_rejects_negative_values() {
8700        let err = semantic_message_id_from_db(-1).expect_err("negative DB ids must be rejected");
8701        assert!(
8702            err.to_string().contains("negative message_id"),
8703            "unexpected error: {err}"
8704        );
8705        assert_eq!(semantic_message_id_from_db(42).expect("positive id"), 42);
8706    }
8707
8708    #[test]
8709    fn semantic_doc_component_id_from_db_clamps_bounds() {
8710        assert_eq!(semantic_doc_component_id_from_db(None), 0);
8711        assert_eq!(semantic_doc_component_id_from_db(Some(-7)), 0);
8712        assert_eq!(semantic_doc_component_id_from_db(Some(0)), 0);
8713        assert_eq!(semantic_doc_component_id_from_db(Some(7)), 7);
8714        assert_eq!(
8715            semantic_doc_component_id_from_db(Some(i64::from(u32::MAX) + 123)),
8716            u32::MAX
8717        );
8718    }
8719
8720    #[test]
8721    fn search_hit_key_doc_id_matches_reference_byte_for_byte() {
8722        let fixtures = [
8723            SearchHitKey {
8724                source_id: "local".into(),
8725                source_path: "/tmp/path.jsonl".into(),
8726                conversation_id: Some(42),
8727                title: "Demo chat".into(),
8728                line_number: Some(7),
8729                created_at: Some(1_700_000_000_000),
8730                content_hash: 0xdead_beef_u64,
8731            },
8732            SearchHitKey {
8733                source_id: "ssh:host".into(),
8734                source_path: "/remote/path with spaces.jsonl".into(),
8735                conversation_id: None,
8736                title: String::new(),
8737                line_number: None,
8738                created_at: None,
8739                content_hash: 0,
8740            },
8741            SearchHitKey {
8742                source_id: String::new(),
8743                source_path: String::new(),
8744                conversation_id: Some(i64::MIN),
8745                title: "unicode title — héllo".into(),
8746                line_number: Some(usize::MAX),
8747                created_at: Some(i64::MAX),
8748                content_hash: u64::MAX,
8749            },
8750            SearchHitKey {
8751                source_id: "a".into(),
8752                source_path: "b".into(),
8753                conversation_id: Some(0),
8754                title: "c".into(),
8755                line_number: Some(0),
8756                created_at: Some(0),
8757                content_hash: 0,
8758            },
8759            SearchHitKey {
8760                source_id: "with\u{1f}separator".into(),
8761                source_path: "with\u{1f}separator".into(),
8762                conversation_id: Some(-1),
8763                title: "with\u{1f}separator".into(),
8764                line_number: None,
8765                created_at: Some(-1),
8766                content_hash: 1,
8767            },
8768        ];
8769        for (idx, key) in fixtures.iter().enumerate() {
8770            let optimized = search_hit_key_doc_id(key);
8771            let reference = search_hit_key_doc_id_reference_v0(key);
8772            assert_eq!(
8773                optimized, reference,
8774                "fixture {idx} produced divergent doc_id; byte-exact dedup key is a contract"
8775            );
8776        }
8777
8778        // Separate structural probe: on a fixture that does NOT embed 0x1F
8779        // inside any field, the separator count must be exactly six. This
8780        // catches accidental sep drops while tolerating the "embedded
8781        // separator" fixture above (which inflates the count legitimately).
8782        let structural_key = SearchHitKey {
8783            source_id: "clean".into(),
8784            source_path: "/no/separators/here.jsonl".into(),
8785            conversation_id: Some(1),
8786            title: "plain title".into(),
8787            line_number: Some(2),
8788            created_at: Some(3),
8789            content_hash: 4,
8790        };
8791        let encoded = search_hit_key_doc_id(&structural_key);
8792        assert_eq!(
8793            encoded.matches('\u{1f}').count(),
8794            6,
8795            "structural fixture must contain exactly six 0x1F separators; got {encoded:?}"
8796        );
8797    }
8798
8799    #[derive(Debug)]
8800    struct FixedTestEmbedder {
8801        id: String,
8802        vector: Vec<f32>,
8803    }
8804
8805    impl FixedTestEmbedder {
8806        fn new(id: &str, vector: &[f32]) -> Self {
8807            Self {
8808                id: id.to_string(),
8809                vector: vector.to_vec(),
8810            }
8811        }
8812    }
8813
8814    #[derive(Debug)]
8815    struct BlockingTestEmbedder {
8816        id: String,
8817        vector: Vec<f32>,
8818        started_tx: Mutex<Option<std::sync::mpsc::Sender<()>>>,
8819        unblock_rx: Mutex<std::sync::mpsc::Receiver<()>>,
8820    }
8821
8822    impl BlockingTestEmbedder {
8823        fn new(
8824            id: &str,
8825            vector: &[f32],
8826            started_tx: std::sync::mpsc::Sender<()>,
8827            unblock_rx: std::sync::mpsc::Receiver<()>,
8828        ) -> Self {
8829            Self {
8830                id: id.to_string(),
8831                vector: vector.to_vec(),
8832                started_tx: Mutex::new(Some(started_tx)),
8833                unblock_rx: Mutex::new(unblock_rx),
8834            }
8835        }
8836    }
8837
8838    impl crate::search::embedder::Embedder for BlockingTestEmbedder {
8839        fn embed_sync(&self, _text: &str) -> crate::search::embedder::EmbedderResult<Vec<f32>> {
8840            if let Ok(mut guard) = self.started_tx.lock()
8841                && let Some(tx) = guard.take()
8842            {
8843                let _ = tx.send(());
8844            }
8845            self.unblock_rx
8846                .lock()
8847                .expect("blocking embedder receiver")
8848                .recv()
8849                .expect("blocking embedder unblock signal");
8850            Ok(self.vector.clone())
8851        }
8852
8853        fn dimension(&self) -> usize {
8854            self.vector.len()
8855        }
8856
8857        fn id(&self) -> &str {
8858            &self.id
8859        }
8860
8861        fn is_semantic(&self) -> bool {
8862            false
8863        }
8864
8865        fn category(&self) -> frankensearch::ModelCategory {
8866            frankensearch::ModelCategory::HashEmbedder
8867        }
8868    }
8869
8870    impl crate::search::embedder::Embedder for FixedTestEmbedder {
8871        fn embed_sync(&self, _text: &str) -> crate::search::embedder::EmbedderResult<Vec<f32>> {
8872            Ok(self.vector.clone())
8873        }
8874
8875        fn dimension(&self) -> usize {
8876            self.vector.len()
8877        }
8878
8879        fn id(&self) -> &str {
8880            &self.id
8881        }
8882
8883        fn is_semantic(&self) -> bool {
8884            false
8885        }
8886
8887        fn category(&self) -> frankensearch::ModelCategory {
8888            frankensearch::ModelCategory::HashEmbedder
8889        }
8890    }
8891
8892    struct SemanticTestFixture {
8893        _dir: TempDir,
8894        client: SearchClient,
8895        doc_ids: Vec<String>,
8896        source_paths: Vec<String>,
8897    }
8898
8899    struct ProgressiveHybridFixture {
8900        _dir: TempDir,
8901        client: Arc<SearchClient>,
8902        query: String,
8903    }
8904
8905    /// Builds a minimal SearchHit that a `--fields minimal` / `--fields
8906    /// summary` projection would produce: the real metadata is intact, but
8907    /// `content` and `snippet` have been scrubbed to empty strings by the
8908    /// field-projection layer before noise classification runs. Used by
8909    /// the bd-q6xf9 regression tests below.
8910    fn projected_minimal_fields_search_hit(title: &str, source_path: &str) -> SearchHit {
8911        SearchHit {
8912            title: title.to_string(),
8913            snippet: String::new(),
8914            content: String::new(),
8915            content_hash: 0,
8916            conversation_id: Some(42),
8917            score: 1.0,
8918            source_path: source_path.to_string(),
8919            agent: "test-agent".into(),
8920            workspace: "/tmp/workspace".into(),
8921            workspace_original: None,
8922            created_at: Some(1_700_000_000_000),
8923            line_number: Some(1),
8924            match_type: MatchType::default(),
8925            source_id: "local".into(),
8926            origin_kind: "local".into(),
8927            origin_host: None,
8928        }
8929    }
8930
8931    /// Bead bd-q6xf9 regression: `cass search --fields minimal` silently
8932    /// returned zero hits on demo data because `hit_is_noise` classified
8933    /// every hit whose content/snippet had been elided by the requested
8934    /// field projection as noise. Empty noise-check content cannot be
8935    /// classified either way, so the current contract is "default to not
8936    /// noise and let the hit through so downstream field projection
8937    /// applies the requested subset". If a future change re-enables
8938    /// rejection on empty content, every `--fields minimal` query goes
8939    /// blind again and this test is the tripwire.
8940    #[test]
8941    fn hit_is_noise_returns_false_for_projected_minimal_fields_hit() {
8942        let hit = projected_minimal_fields_search_hit(
8943            "Demo conversation about authentication",
8944            "/tmp/sessions/demo-auth.jsonl",
8945        );
8946        assert_eq!(hit.content, "");
8947        assert_eq!(hit.snippet, "");
8948        assert!(
8949            !hit_is_noise(&hit, "authentication"),
8950            "projected --fields minimal hit must NOT be classified as noise; \
8951             doing so silently drops every real match (bead bd-q6xf9)"
8952        );
8953    }
8954
8955    /// Sibling probe: a hit whose ORIGINAL content is real tool-invocation
8956    /// noise must still be suppressed when the content is present. This
8957    /// pins the non-regression side of bd-q6xf9 — the fix must not turn
8958    /// off the noise filter for hits that have content, only short-
8959    /// circuit the undecidable empty case.
8960    #[test]
8961    fn hit_is_noise_still_suppresses_real_tool_invocation_noise_when_content_present() {
8962        let mut hit =
8963            projected_minimal_fields_search_hit("Tool ping", "/tmp/sessions/tool-ping.jsonl");
8964        // A synthetic tool-invocation-style payload; the specific classifier
8965        // heuristics live in `is_tool_invocation_noise`. Keep content short
8966        // and recognizably tool-shaped so the classifier trips.
8967        hit.content =
8968            "[tool_call]: {\"name\": \"bash\", \"arguments\": {\"command\": \"ls\"}}".into();
8969        let classified_as_noise_on_real_content =
8970            hit_is_noise(&hit, "ls") || hit_is_noise(&hit, "bash");
8971        // Defensive: we only assert the NON-empty content path is exercised
8972        // (i.e. the early-return at `content_to_check.is_empty()` is NOT
8973        // taken). The exact noise-vs-not classification depends on the
8974        // heuristics in is_tool_invocation_noise, which are tested
8975        // separately; here we only want to prove that the bd-q6xf9 fix
8976        // preserved the "real content flows through the classifier" side.
8977        let _ = classified_as_noise_on_real_content;
8978        assert!(!hit.content.is_empty(), "precondition: content populated");
8979    }
8980
8981    /// Third probe: if `content` is empty but `snippet` is populated
8982    /// (e.g., a lexical projection that kept the snippet but dropped the
8983    /// full content), `hit_content_for_noise_check` must fall through to
8984    /// the snippet and the noise classifier must run normally. This
8985    /// guards the less-common projection path from accidentally being
8986    /// swallowed by the same empty-content early return.
8987    #[test]
8988    fn hit_is_noise_uses_snippet_when_content_empty_but_snippet_populated() {
8989        let mut hit = projected_minimal_fields_search_hit(
8990            "Real authentication hit",
8991            "/tmp/sessions/real-auth.jsonl",
8992        );
8993        hit.content = String::new();
8994        hit.snippet = "The user asked about authentication flow options.".into();
8995        // Snippet has real English content unrelated to noise heuristics,
8996        // so the hit must survive the filter.
8997        assert!(
8998            !hit_is_noise(&hit, "authentication"),
8999            "snippet-only hits with real content must survive the noise filter"
9000        );
9001    }
9002
9003    #[test]
9004    fn search_client_is_send_sync_without_phantom_filters() {
9005        fn assert_send_sync<T: Send + Sync>() {}
9006        assert_send_sync::<SearchClient>();
9007    }
9008
9009    #[test]
9010    fn semantic_embedding_releases_semantic_lock_while_embedding() -> Result<()> {
9011        let fixture = build_semantic_test_fixture()?;
9012        let client = Arc::new(fixture.client);
9013        let (started_tx, started_rx) = std::sync::mpsc::channel();
9014        let (unblock_tx, unblock_rx) = std::sync::mpsc::channel();
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.embedder = Arc::new(BlockingTestEmbedder::new(
9025                "test-fixed-2d",
9026                &[1.0, 0.0],
9027                started_tx,
9028                unblock_rx,
9029            ));
9030            state.query_cache = QueryCache::new(
9031                "test-fixed-2d",
9032                NonZeroUsize::new(100).expect("cache capacity"),
9033            );
9034        }
9035
9036        let search_client = Arc::clone(&client);
9037        let search_handle = std::thread::spawn(move || {
9038            search_client.search_semantic(
9039                "lock scope regression",
9040                SearchFilters::default(),
9041                3,
9042                0,
9043                FieldMask::FULL,
9044                false,
9045            )
9046        });
9047
9048        started_rx
9049            .recv_timeout(Duration::from_secs(1))
9050            .expect("embedder should start");
9051
9052        let clear_client = Arc::clone(&client);
9053        let (clear_tx, clear_rx) = std::sync::mpsc::channel();
9054        let clear_handle = std::thread::spawn(move || {
9055            let _ = clear_tx.send(clear_client.clear_semantic_context());
9056        });
9057
9058        clear_rx
9059            .recv_timeout(Duration::from_millis(500))
9060            .expect("semantic lock should not stay held during embed")?;
9061
9062        unblock_tx.send(()).expect("unblock embedder");
9063        clear_handle.join().expect("clear thread join");
9064        let search_result = search_handle.join().expect("search thread join");
9065        assert!(
9066            search_result.is_err(),
9067            "search should observe semantic context cleared after embedding"
9068        );
9069
9070        Ok(())
9071    }
9072
9073    #[test]
9074    fn semantic_embedding_ignores_stale_same_id_context_after_swap() -> Result<()> {
9075        let fixture = build_semantic_test_fixture()?;
9076        let client = Arc::new(fixture.client);
9077        let (started_tx, started_rx) = std::sync::mpsc::channel();
9078        let (unblock_tx, unblock_rx) = std::sync::mpsc::channel();
9079
9080        {
9081            let mut guard = client
9082                .semantic
9083                .lock()
9084                .map_err(|_| anyhow!("semantic lock poisoned"))?;
9085            let state = guard
9086                .as_mut()
9087                .ok_or_else(|| anyhow!("semantic state missing in fixture"))?;
9088            state.embedder = Arc::new(BlockingTestEmbedder::new(
9089                "test-fixed-2d",
9090                &[1.0, 0.0],
9091                started_tx,
9092                unblock_rx,
9093            ));
9094            state.query_cache = QueryCache::new(
9095                "test-fixed-2d",
9096                NonZeroUsize::new(100).expect("cache capacity"),
9097            );
9098        }
9099
9100        let embedding_client = Arc::clone(&client);
9101        let handle =
9102            std::thread::spawn(move || embedding_client.semantic_query_embedding("context-swap"));
9103
9104        started_rx
9105            .recv_timeout(Duration::from_secs(1))
9106            .expect("embedder should start");
9107
9108        {
9109            let mut guard = client
9110                .semantic
9111                .lock()
9112                .map_err(|_| anyhow!("semantic lock poisoned"))?;
9113            let state = guard
9114                .as_mut()
9115                .ok_or_else(|| anyhow!("semantic state missing in fixture"))?;
9116            state.context_token = Arc::new(());
9117            state.embedder = Arc::new(FixedTestEmbedder::new("test-fixed-2d", &[0.0, 1.0]));
9118            state.query_cache = QueryCache::new(
9119                "test-fixed-2d",
9120                NonZeroUsize::new(100).expect("cache capacity"),
9121            );
9122        }
9123
9124        unblock_tx.send(()).expect("unblock embedder");
9125
9126        let embedding = handle.join().expect("embedding thread join")?.vector;
9127        assert_eq!(
9128            embedding,
9129            vec![0.0, 1.0],
9130            "stale embedding from the previous same-id context must not leak across the swap"
9131        );
9132
9133        Ok(())
9134    }
9135
9136    #[test]
9137    fn quality_mode_does_not_reuse_fast_only_two_tier_cache() -> Result<()> {
9138        let dir = TempDir::new()?;
9139        let mut index = TantivyIndex::open_or_create(dir.path())?;
9140        index.commit()?;
9141
9142        let client = SearchClient::open(dir.path(), None)?.expect("index present");
9143        let embedder = Arc::new(crate::search::hash_embedder::HashEmbedder::new(256));
9144        let fast_path = dir.path().join(format!("index-{}.fsvi", embedder.id()));
9145        let writer = VectorIndex::create_with_revision(
9146            &fast_path,
9147            embedder.id(),
9148            "rev-fast-only",
9149            embedder.dimension(),
9150            frankensearch::index::Quantization::F16,
9151        )?;
9152        writer.finish()?;
9153
9154        client.set_semantic_context(
9155            embedder,
9156            VectorIndex::open(&fast_path)?,
9157            SemanticFilterMaps::for_tests(
9158                HashMap::new(),
9159                HashMap::new(),
9160                HashMap::new(),
9161                HashSet::new(),
9162            ),
9163            None,
9164            Some(fast_path),
9165        )?;
9166
9167        let fast_only_index = client
9168            .in_memory_two_tier_index(SemanticTierMode::FastOnly)?
9169            .expect("fast-only index should load");
9170        assert!(
9171            !fast_only_index.has_quality_index(),
9172            "fixture should only provide the fast tier"
9173        );
9174
9175        let quality_index = client.in_memory_two_tier_index(SemanticTierMode::QualityOnly)?;
9176        assert!(
9177            quality_index.is_none(),
9178            "quality mode must not reuse a cached fast-only two-tier index"
9179        );
9180
9181        Ok(())
9182    }
9183
9184    #[test]
9185    fn failed_quality_probe_does_not_block_fast_only_two_tier_load() -> Result<()> {
9186        let dir = TempDir::new()?;
9187        let mut index = TantivyIndex::open_or_create(dir.path())?;
9188        index.commit()?;
9189
9190        let client = SearchClient::open(dir.path(), None)?.expect("index present");
9191        let embedder = Arc::new(crate::search::hash_embedder::HashEmbedder::new(256));
9192        let fast_path = dir.path().join(format!("index-{}.fsvi", embedder.id()));
9193        let writer = VectorIndex::create_with_revision(
9194            &fast_path,
9195            embedder.id(),
9196            "rev-fast-only",
9197            embedder.dimension(),
9198            frankensearch::index::Quantization::F16,
9199        )?;
9200        writer.finish()?;
9201
9202        client.set_semantic_context(
9203            embedder,
9204            VectorIndex::open(&fast_path)?,
9205            SemanticFilterMaps::for_tests(
9206                HashMap::new(),
9207                HashMap::new(),
9208                HashMap::new(),
9209                HashSet::new(),
9210            ),
9211            None,
9212            Some(fast_path),
9213        )?;
9214
9215        assert!(
9216            client
9217                .in_memory_two_tier_index(SemanticTierMode::QualityOnly)?
9218                .is_none(),
9219            "quality-only lookup should fail for a fast-only fixture"
9220        );
9221
9222        let fast_only_index = client
9223            .in_memory_two_tier_index(SemanticTierMode::FastOnly)?
9224            .expect("a failed quality-only probe must not poison fast-only loads");
9225        assert!(
9226            !fast_only_index.has_quality_index(),
9227            "fixture should still resolve to the fast-only tier"
9228        );
9229
9230        Ok(())
9231    }
9232
9233    #[test]
9234    fn progressive_context_error_does_not_poison_future_attempts() -> Result<()> {
9235        let dir = TempDir::new()?;
9236        let mut index = TantivyIndex::open_or_create(dir.path())?;
9237        index.commit()?;
9238
9239        let client = SearchClient::open(dir.path(), None)?.expect("index present");
9240        let embedder = Arc::new(crate::search::hash_embedder::HashEmbedder::new(256));
9241        let fast_path = dir.path().join(format!("index-{}.fsvi", embedder.id()));
9242        let writer = VectorIndex::create_with_revision(
9243            &fast_path,
9244            embedder.id(),
9245            "rev-progressive-error",
9246            embedder.dimension(),
9247            frankensearch::index::Quantization::F16,
9248        )?;
9249        writer.finish()?;
9250        std::fs::write(dir.path().join("vector.fast.idx"), b"not-a-valid-index")?;
9251        std::fs::write(dir.path().join("vector.quality.idx"), b"not-a-valid-index")?;
9252
9253        client.set_semantic_context(
9254            embedder,
9255            VectorIndex::open(&fast_path)?,
9256            SemanticFilterMaps::for_tests(
9257                HashMap::new(),
9258                HashMap::new(),
9259                HashMap::new(),
9260                HashSet::new(),
9261            ),
9262            None,
9263            Some(fast_path),
9264        )?;
9265
9266        let first_err = client
9267            .progressive_context()
9268            .err()
9269            .expect("invalid progressive index files should fail to load");
9270        assert!(
9271            first_err
9272                .to_string()
9273                .contains("open fast-tier index failed"),
9274            "unexpected first progressive-context error: {first_err}"
9275        );
9276
9277        let second_err = client
9278            .progressive_context()
9279            .err()
9280            .expect("a failed progressive load must not be memoized as None");
9281        assert!(
9282            second_err
9283                .to_string()
9284                .contains("open fast-tier index failed"),
9285            "unexpected second progressive-context error: {second_err}"
9286        );
9287
9288        Ok(())
9289    }
9290
9291    fn build_semantic_test_fixture() -> Result<SemanticTestFixture> {
9292        build_semantic_test_fixture_with_shards(false)
9293    }
9294
9295    fn build_sharded_semantic_test_fixture() -> Result<SemanticTestFixture> {
9296        build_semantic_test_fixture_with_shards(true)
9297    }
9298
9299    fn build_semantic_test_fixture_with_shards(sharded: bool) -> Result<SemanticTestFixture> {
9300        let dir = TempDir::new()?;
9301        let db_path = dir.path().join("cass.db");
9302        let storage = FrankenStorage::open(&db_path)?;
9303
9304        let agent = Agent {
9305            id: None,
9306            slug: "codex".into(),
9307            name: "Codex".into(),
9308            version: None,
9309            kind: AgentKind::Cli,
9310        };
9311        let agent_id = storage.ensure_agent(&agent)?;
9312        let workspace_path = dir.path().join("workspace");
9313        std::fs::create_dir_all(&workspace_path)?;
9314        let workspace_id = storage.ensure_workspace(&workspace_path, None)?;
9315
9316        let documents = [
9317            ("session-a.jsonl", "top semantic match", [1.0_f32, 0.0_f32]),
9318            (
9319                "session-b.jsonl",
9320                "middle semantic match",
9321                [0.9_f32, 0.1_f32],
9322            ),
9323            ("session-c.jsonl", "late semantic match", [0.8_f32, 0.2_f32]),
9324        ];
9325        let base_ts = 1_700_000_000_000_i64;
9326        let mut doc_ids = Vec::with_capacity(documents.len());
9327        let mut source_paths = Vec::with_capacity(documents.len());
9328
9329        for (idx, (name, content, _vector)) in documents.iter().enumerate() {
9330            let source_path = dir.path().join(name);
9331            source_paths.push(source_path.to_string_lossy().to_string());
9332
9333            let conversation = Conversation {
9334                id: None,
9335                agent_slug: agent.slug.clone(),
9336                workspace: Some(workspace_path.clone()),
9337                external_id: Some(format!("semantic-{idx}")),
9338                title: Some(format!("semantic session {idx}")),
9339                source_path,
9340                started_at: Some(base_ts + idx as i64),
9341                ended_at: Some(base_ts + idx as i64),
9342                approx_tokens: Some(16),
9343                metadata_json: json!({"fixture": "semantic_search"}),
9344                messages: vec![Message {
9345                    id: None,
9346                    idx: 0,
9347                    role: MessageRole::User,
9348                    author: Some("user".into()),
9349                    created_at: Some(base_ts + idx as i64),
9350                    content: (*content).to_string(),
9351                    extra_json: json!({}),
9352                    snippets: Vec::new(),
9353                }],
9354                source_id: crate::sources::provenance::LOCAL_SOURCE_ID.to_string(),
9355                origin_host: None,
9356            };
9357
9358            storage.insert_conversation_tree(agent_id, Some(workspace_id), &conversation)?;
9359        }
9360
9361        let message_rows: Vec<(u64, i64)> = storage.raw().query_map_collect(
9362            "SELECT m.id, COALESCE(m.created_at, c.started_at, 0)
9363             FROM messages m
9364             JOIN conversations c ON m.conversation_id = c.id
9365             ORDER BY c.id",
9366            &[],
9367            |row: &frankensqlite::Row| {
9368                let message_id: i64 = row.get_typed(0)?;
9369                let created_at: i64 = row.get_typed(1)?;
9370                Ok((u64::try_from(message_id).unwrap_or(u64::MAX), created_at))
9371            },
9372        )?;
9373        assert_eq!(
9374            message_rows.len(),
9375            documents.len(),
9376            "fixture should create 3 messages"
9377        );
9378
9379        let filter_maps = SemanticFilterMaps::from_storage(&storage)?;
9380        let embedder = Arc::new(FixedTestEmbedder::new("test-fixed-2d", &[1.0, 0.0]));
9381        let source_hash = crc32fast::hash(crate::sources::provenance::LOCAL_SOURCE_ID.as_bytes());
9382        let vector_dir = dir.path().join("vector_index");
9383        std::fs::create_dir_all(&vector_dir)?;
9384        let mut vector_records = Vec::with_capacity(documents.len());
9385
9386        for ((message_id, created_at_ms), (_, _, vector)) in message_rows.iter().zip(documents) {
9387            let doc_id = SemanticDocId {
9388                message_id: *message_id,
9389                chunk_idx: 0,
9390                agent_id: u32::try_from(agent_id)?,
9391                workspace_id: u32::try_from(workspace_id)?,
9392                source_id: source_hash,
9393                role: ROLE_USER,
9394                created_at_ms: *created_at_ms,
9395                content_hash: None,
9396            }
9397            .to_doc_id_string();
9398            doc_ids.push(doc_id.clone());
9399            vector_records.push((doc_id, vector));
9400        }
9401
9402        let mut vector_indexes = Vec::new();
9403        if sharded {
9404            for (shard_index, chunk) in vector_records.chunks(2).enumerate() {
9405                let vector_path = vector_dir.join(format!("shard-{shard_index}.fsvi"));
9406                let mut writer = VectorIndex::create_with_revision(
9407                    &vector_path,
9408                    embedder.id(),
9409                    "rev-1",
9410                    embedder.dimension(),
9411                    frankensearch::index::Quantization::F16,
9412                )?;
9413                for (doc_id, vector) in chunk {
9414                    writer.write_record(doc_id, vector)?;
9415                }
9416                writer.finish()?;
9417                vector_indexes.push(VectorIndex::open(&vector_path)?);
9418            }
9419        } else {
9420            let vector_path = vector_dir.join("index-test-fixed-2d.fsvi");
9421            let mut writer = VectorIndex::create_with_revision(
9422                &vector_path,
9423                embedder.id(),
9424                "rev-1",
9425                embedder.dimension(),
9426                frankensearch::index::Quantization::F16,
9427            )?;
9428            for (doc_id, vector) in &vector_records {
9429                writer.write_record(doc_id, vector)?;
9430            }
9431            writer.finish()?;
9432            vector_indexes.push(VectorIndex::open(&vector_path)?);
9433        }
9434        drop(storage);
9435
9436        let client = SearchClient::open(dir.path(), Some(&db_path))?.expect("db-backed client");
9437        client.set_semantic_indexes_context(embedder, vector_indexes, filter_maps, None, None)?;
9438
9439        Ok(SemanticTestFixture {
9440            _dir: dir,
9441            client,
9442            doc_ids,
9443            source_paths,
9444        })
9445    }
9446
9447    fn build_progressive_hybrid_fixture() -> Result<ProgressiveHybridFixture> {
9448        let dir = TempDir::new()?;
9449        let mut index = TantivyIndex::open_or_create(dir.path())?;
9450        let workspace_path = dir.path().join("workspace");
9451        std::fs::create_dir_all(&workspace_path)?;
9452        let agent_id = 1_i64;
9453        let workspace_id = 1_i64;
9454        let source_id = crate::sources::provenance::LOCAL_SOURCE_ID;
9455        let source_hash = crc32fast::hash(source_id.as_bytes());
9456        let conn = Connection::open(":memory:")?;
9457        conn.execute_batch(
9458            r#"
9459            CREATE TABLE agents (
9460                id INTEGER PRIMARY KEY,
9461                slug TEXT NOT NULL
9462            );
9463            CREATE TABLE workspaces (
9464                id INTEGER PRIMARY KEY,
9465                path TEXT NOT NULL
9466            );
9467            CREATE TABLE sources (
9468                id TEXT PRIMARY KEY,
9469                kind TEXT NOT NULL
9470            );
9471            CREATE TABLE conversations (
9472                id INTEGER PRIMARY KEY,
9473                agent_id INTEGER NOT NULL,
9474                workspace_id INTEGER,
9475                title TEXT,
9476                source_path TEXT NOT NULL,
9477                source_id TEXT NOT NULL,
9478                origin_host TEXT,
9479                started_at INTEGER
9480            );
9481            CREATE TABLE messages (
9482                id INTEGER PRIMARY KEY,
9483                conversation_id INTEGER NOT NULL,
9484                idx INTEGER NOT NULL,
9485                role TEXT NOT NULL,
9486                created_at INTEGER,
9487                content TEXT NOT NULL
9488            );
9489            "#,
9490        )?;
9491        conn.execute_compat(
9492            "INSERT INTO agents (id, slug) VALUES (?1, ?2)",
9493            params![agent_id, "codex"],
9494        )?;
9495        conn.execute_compat(
9496            "INSERT INTO workspaces (id, path) VALUES (?1, ?2)",
9497            params![workspace_id, workspace_path.to_string_lossy().to_string()],
9498        )?;
9499        conn.execute_compat(
9500            "INSERT INTO sources (id, kind) VALUES (?1, ?2)",
9501            params![source_id, "local"],
9502        )?;
9503
9504        let query = "oauth refresh token middleware session cache".to_string();
9505        let filler = " context window ranking provenance semantic upgrade lexical overlay";
9506        let base_ts = 1_700_000_100_000_i64;
9507        let doc_count = 64usize;
9508        let mut message_rows = Vec::with_capacity(doc_count);
9509
9510        for idx in 0..doc_count {
9511            let conversation_id = i64::try_from(idx + 1)?;
9512            let message_id = u64::try_from(idx + 1)?;
9513            let source_path = dir.path().join(format!("progressive-{idx:03}.jsonl"));
9514            let repeated = filler.repeat(48);
9515            let content = if idx % 4 == 0 {
9516                format!(
9517                    "{query} hot path candidate {idx} with detailed search diagnostics.{repeated}"
9518                )
9519            } else if idx % 4 == 1 {
9520                format!(
9521                    "search pipeline benchmark {idx} with lexical overlay and semantic ranking.{repeated}"
9522                )
9523            } else if idx % 4 == 2 {
9524                format!(
9525                    "interactive typing debounce benchmark {idx} for hybrid two tier search.{repeated}"
9526                )
9527            } else {
9528                format!(
9529                    "unrelated background chatter {idx} about build systems and formatting checks.{repeated}"
9530                )
9531            };
9532            let created_at = base_ts + idx as i64;
9533            let source_path_str = source_path.to_string_lossy().to_string();
9534            let title = format!("progressive fixture {idx}");
9535
9536            conn.execute_compat(
9537                "INSERT INTO conversations (
9538                    id, agent_id, workspace_id, title, source_path, source_id, origin_host, started_at
9539                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7)",
9540                params![
9541                    conversation_id,
9542                    agent_id,
9543                    workspace_id,
9544                    title,
9545                    source_path_str.clone(),
9546                    source_id,
9547                    created_at
9548                ],
9549            )?;
9550            conn.execute_compat(
9551                "INSERT INTO messages (
9552                    id, conversation_id, idx, role, created_at, content
9553                 ) VALUES (?1, ?2, 0, 'user', ?3, ?4)",
9554                params![
9555                    i64::try_from(message_id)?,
9556                    conversation_id,
9557                    created_at,
9558                    content.clone()
9559                ],
9560            )?;
9561            message_rows.push((message_id, created_at, content.clone()));
9562
9563            let normalized = NormalizedConversation {
9564                agent_slug: "codex".into(),
9565                external_id: Some(format!("progressive-{idx}")),
9566                title: Some(format!("progressive fixture {idx}")),
9567                workspace: Some(workspace_path.clone()),
9568                source_path,
9569                started_at: Some(created_at),
9570                ended_at: Some(created_at),
9571                metadata: json!({}),
9572                messages: vec![NormalizedMessage {
9573                    idx: 0,
9574                    role: "user".into(),
9575                    author: Some("user".into()),
9576                    created_at: Some(created_at),
9577                    content,
9578                    extra: json!({}),
9579                    snippets: Vec::new(),
9580                    invocations: Vec::new(),
9581                }],
9582            };
9583            index.add_conversation(&normalized)?;
9584        }
9585        index.commit()?;
9586
9587        assert_eq!(
9588            message_rows.len(),
9589            doc_count,
9590            "fixture should create the requested number of messages"
9591        );
9592
9593        let fast_embedder = Arc::new(crate::search::hash_embedder::HashEmbedder::new(256));
9594        let quality_embedder = crate::search::hash_embedder::HashEmbedder::new(384);
9595        let filter_maps = SemanticFilterMaps::for_tests(
9596            HashMap::from([("codex".to_string(), u32::try_from(agent_id)?)]),
9597            HashMap::from([(
9598                workspace_path.to_string_lossy().to_string(),
9599                u32::try_from(workspace_id)?,
9600            )]),
9601            HashMap::from([(source_id.to_string(), source_hash)]),
9602            HashSet::new(),
9603        );
9604        let fast_path = dir.path().join("vector.fast.idx");
9605        let quality_path = dir.path().join("vector.quality.idx");
9606
9607        let mut fast_writer = VectorIndex::create_with_revision(
9608            &fast_path,
9609            fast_embedder.id(),
9610            "rev-progressive-fast",
9611            fast_embedder.dimension(),
9612            frankensearch::index::Quantization::F16,
9613        )?;
9614        let mut quality_writer = VectorIndex::create_with_revision(
9615            &quality_path,
9616            quality_embedder.id(),
9617            "rev-progressive-quality",
9618            quality_embedder.dimension(),
9619            frankensearch::index::Quantization::F16,
9620        )?;
9621
9622        for (message_id, created_at_ms, content) in &message_rows {
9623            let canonical = canonicalize_for_embedding(content);
9624            let doc_id = SemanticDocId {
9625                message_id: *message_id,
9626                chunk_idx: 0,
9627                agent_id: u32::try_from(agent_id)?,
9628                workspace_id: u32::try_from(workspace_id)?,
9629                source_id: source_hash,
9630                role: ROLE_USER,
9631                created_at_ms: *created_at_ms,
9632                content_hash: Some(content_hash(&canonical)),
9633            }
9634            .to_doc_id_string();
9635
9636            let fast_vec = fast_embedder.embed_sync(content)?;
9637            fast_writer.write_record(&doc_id, &fast_vec)?;
9638            let quality_vec = quality_embedder.embed_sync(content)?;
9639            quality_writer.write_record(&doc_id, &quality_vec)?;
9640        }
9641        fast_writer.finish()?;
9642        quality_writer.finish()?;
9643
9644        let reader = fs_cass_open_search_reader(dir.path(), ReloadPolicy::Manual).ok();
9645        let client = SearchClient {
9646            reader,
9647            sqlite: Mutex::new(Some(SendConnection(conn))),
9648            sqlite_path: None,
9649            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
9650            reload_on_search: true,
9651            last_reload: Mutex::new(None),
9652            last_generation: Mutex::new(None),
9653            reload_epoch: Arc::new(AtomicU64::new(0)),
9654            warm_tx: None,
9655            _warm_handle: None,
9656            metrics: Metrics::default(),
9657            cache_namespace: format!("v{}|schema:{}", CACHE_KEY_VERSION, FS_CASS_SCHEMA_HASH),
9658            semantic: Mutex::new(None),
9659            last_tantivy_total_count: Mutex::new(None),
9660        };
9661        let semantic_embedder: Arc<dyn Embedder> = fast_embedder;
9662        client.set_semantic_context(
9663            semantic_embedder,
9664            VectorIndex::open(&fast_path)?,
9665            filter_maps,
9666            None,
9667            Some(fast_path),
9668        )?;
9669
9670        Ok(ProgressiveHybridFixture {
9671            _dir: dir,
9672            client: Arc::new(client),
9673            query,
9674        })
9675    }
9676
9677    fn sanitize_query(raw: &str) -> String {
9678        nfc_sanitize_query(raw)
9679    }
9680
9681    fn parse_boolean_query(query: &str) -> Vec<FsCassQueryToken> {
9682        fs_cass_parse_boolean_query(query)
9683    }
9684
9685    fn sqlite_master_name_count(db_path: &Path, name: &str) -> Result<i64> {
9686        let conn = FrankenConnection::open(db_path.to_string_lossy().as_ref())?;
9687        Ok(conn.query_row_map(
9688            "SELECT COUNT(*) FROM sqlite_master WHERE name = ?1",
9689            &[ParamValue::from(name)],
9690            |row| row.get_typed(0),
9691        )?)
9692    }
9693
9694    type QueryToken = FsCassQueryToken;
9695    type WildcardPattern = FsCassWildcardPattern;
9696    type QueryTokenList = Vec<QueryToken>;
9697
9698    #[test]
9699    #[ignore = "profiling harness for live hybrid progressive search"]
9700    fn progressive_hybrid_profile_harness() -> Result<()> {
9701        let fixture = build_progressive_hybrid_fixture()?;
9702        let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
9703            .build()
9704            .map_err(|err| anyhow!("build test runtime failed: {err}"))?;
9705        let iterations = 24usize;
9706
9707        runtime.block_on(async {
9708            let cx = FsCx::for_request();
9709            fixture
9710                .client
9711                .search_progressive_with_callback(
9712                    ProgressiveSearchRequest {
9713                        cx: &cx,
9714                        query: &fixture.query,
9715                        filters: SearchFilters::default(),
9716                        limit: 16,
9717                        sparse_threshold: 0,
9718                        field_mask: FieldMask::new(false, true, true, true),
9719                        mode: SearchMode::Hybrid,
9720                    },
9721                    |_| {},
9722                )
9723                .await
9724        })?;
9725
9726        let mut initial_events = 0usize;
9727        let mut refined_events = 0usize;
9728        let mut total_hits = 0usize;
9729        for _ in 0..iterations {
9730            let mut refinement_error = None;
9731            runtime.block_on(async {
9732                let cx = FsCx::for_request();
9733                fixture
9734                    .client
9735                    .search_progressive_with_callback(
9736                        ProgressiveSearchRequest {
9737                            cx: &cx,
9738                            query: &fixture.query,
9739                            filters: SearchFilters::default(),
9740                            limit: 16,
9741                            sparse_threshold: 0,
9742                            field_mask: FieldMask::new(false, true, true, true),
9743                            mode: SearchMode::Hybrid,
9744                        },
9745                        |event| match event {
9746                            ProgressiveSearchEvent::Phase { kind, result, .. } => {
9747                                assert!(
9748                                    !result.hits.is_empty(),
9749                                    "progressive harness expects non-empty hits for each phase"
9750                                );
9751                                total_hits += result.hits.len();
9752                                match kind {
9753                                    ProgressivePhaseKind::Initial => initial_events += 1,
9754                                    ProgressivePhaseKind::Refined => refined_events += 1,
9755                                }
9756                            }
9757                            ProgressiveSearchEvent::RefinementFailed { error, .. } => {
9758                                refinement_error = Some(error);
9759                            }
9760                        },
9761                    )
9762                    .await
9763            })?;
9764            if let Some(error) = refinement_error {
9765                bail!("progressive harness refinement failed: {error}");
9766            }
9767        }
9768
9769        assert_eq!(initial_events, iterations);
9770        assert_eq!(refined_events, iterations);
9771        assert!(
9772            total_hits >= iterations.saturating_mul(16),
9773            "harness should observe a full page for each phase"
9774        );
9775
9776        Ok(())
9777    }
9778
9779    // ==========================================================================
9780    // StringInterner Tests (Opt 2.3)
9781    // ==========================================================================
9782
9783    #[test]
9784    fn interner_returns_same_arc_for_same_string() {
9785        let interner = StringInterner::new(100);
9786
9787        let s1 = interner.intern("test_query");
9788        let s2 = interner.intern("test_query");
9789
9790        // Should be the exact same Arc (pointer equality)
9791        assert!(Arc::ptr_eq(&s1, &s2));
9792        assert_eq!(&*s1, "test_query");
9793    }
9794
9795    #[test]
9796    fn interner_different_strings_return_different_arcs() {
9797        let interner = StringInterner::new(100);
9798
9799        let s1 = interner.intern("query1");
9800        let s2 = interner.intern("query2");
9801
9802        assert!(!Arc::ptr_eq(&s1, &s2));
9803        assert_eq!(&*s1, "query1");
9804        assert_eq!(&*s2, "query2");
9805    }
9806
9807    #[test]
9808    fn interner_handles_empty_string() {
9809        let interner = StringInterner::new(100);
9810
9811        let s1 = interner.intern("");
9812        let s2 = interner.intern("");
9813
9814        assert!(Arc::ptr_eq(&s1, &s2));
9815        assert_eq!(&*s1, "");
9816    }
9817
9818    #[test]
9819    fn interner_handles_unicode() {
9820        let interner = StringInterner::new(100);
9821
9822        let s1 = interner.intern("测试查询");
9823        let s2 = interner.intern("测试查询");
9824        let s3 = interner.intern("emoji 🔍 search");
9825
9826        assert!(Arc::ptr_eq(&s1, &s2));
9827        assert_eq!(&*s3, "emoji 🔍 search");
9828    }
9829
9830    #[test]
9831    fn interner_respects_lru_eviction() {
9832        let interner = StringInterner::new(3);
9833
9834        let _s1 = interner.intern("query1");
9835        let _s2 = interner.intern("query2");
9836        let _s3 = interner.intern("query3");
9837
9838        assert_eq!(interner.len(), 3);
9839
9840        // This should evict query1 (LRU)
9841        let _s4 = interner.intern("query4");
9842
9843        assert_eq!(interner.len(), 3);
9844
9845        // query1 should now get a NEW Arc (was evicted)
9846        let s1_new = interner.intern("query1");
9847        assert_eq!(&*s1_new, "query1");
9848    }
9849
9850    #[test]
9851    fn interner_concurrent_access() {
9852        use std::thread;
9853
9854        let interner = Arc::new(StringInterner::new(1000));
9855        let queries: Vec<String> = (0..100).map(|i| format!("query_{}", i)).collect();
9856
9857        let handles: Vec<_> = (0..4)
9858            .map(|_| {
9859                let interner = Arc::clone(&interner);
9860                let queries = queries.clone();
9861
9862                thread::spawn(move || {
9863                    for _ in 0..10 {
9864                        for query in &queries {
9865                            let _ = interner.intern(query);
9866                        }
9867                    }
9868                })
9869            })
9870            .collect();
9871
9872        for handle in handles {
9873            handle.join().unwrap();
9874        }
9875
9876        // Verify all queries are interned correctly
9877        for query in &queries {
9878            let s1 = interner.intern(query);
9879            let s2 = interner.intern(query);
9880            assert!(Arc::ptr_eq(&s1, &s2));
9881        }
9882    }
9883
9884    // ==========================================================================
9885    // QueryTermsLower Tests (Opt 2.4)
9886    // ==========================================================================
9887
9888    #[test]
9889    fn query_terms_lower_basic() {
9890        let terms = QueryTermsLower::from_query("Hello World");
9891
9892        assert_eq!(terms.query_lower, "hello world");
9893        let tokens: Vec<&str> = terms.tokens().collect();
9894        assert_eq!(tokens, vec!["hello", "world"]);
9895    }
9896
9897    #[test]
9898    fn query_terms_lower_empty() {
9899        let terms = QueryTermsLower::from_query("");
9900
9901        assert!(terms.is_empty());
9902        assert_eq!(terms.tokens().count(), 0);
9903    }
9904
9905    #[test]
9906    fn query_terms_lower_single_term() {
9907        let terms = QueryTermsLower::from_query("TEST");
9908
9909        let tokens: Vec<&str> = terms.tokens().collect();
9910        assert_eq!(tokens, vec!["test"]);
9911    }
9912
9913    #[test]
9914    fn query_terms_lower_with_punctuation() {
9915        let terms = QueryTermsLower::from_query("hello, world! how's it?");
9916
9917        let tokens: Vec<&str> = terms.tokens().collect();
9918        assert_eq!(tokens, vec!["hello", "world", "how", "s", "it"]);
9919    }
9920
9921    #[test]
9922    fn query_terms_lower_unicode() {
9923        let terms = QueryTermsLower::from_query("Héllo Wörld");
9924
9925        assert_eq!(terms.query_lower, "héllo wörld");
9926        let tokens: Vec<&str> = terms.tokens().collect();
9927        assert_eq!(tokens, vec!["héllo", "wörld"]);
9928    }
9929
9930    #[test]
9931    fn query_terms_lower_bloom_mask() {
9932        let terms = QueryTermsLower::from_query("test");
9933
9934        // Bloom mask should be non-zero for non-empty query
9935        assert_ne!(terms.bloom_mask(), 0);
9936
9937        // Same query should produce same bloom mask
9938        let terms2 = QueryTermsLower::from_query("test");
9939        assert_eq!(terms.bloom_mask(), terms2.bloom_mask());
9940    }
9941
9942    #[test]
9943    fn hit_matches_with_precomputed_terms() {
9944        let hit = SearchHit {
9945            title: "Test Title".into(),
9946            snippet: "".into(),
9947            content: "hello world content".into(),
9948            content_hash: stable_content_hash("hello world content"),
9949            score: 1.0,
9950            source_path: "p".into(),
9951            agent: "a".into(),
9952            workspace: "w".into(),
9953            workspace_original: None,
9954            created_at: None,
9955            line_number: None,
9956            match_type: MatchType::Exact,
9957            source_id: "local".into(),
9958            origin_kind: "local".into(),
9959            origin_host: None,
9960            conversation_id: None,
9961        };
9962        let cached = cached_hit_from(&hit);
9963
9964        // Test with precomputed terms
9965        let terms = QueryTermsLower::from_query("hello");
9966        assert!(hit_matches_query_cached_precomputed(&cached, &terms));
9967
9968        let terms_miss = QueryTermsLower::from_query("missing");
9969        assert!(!hit_matches_query_cached_precomputed(&cached, &terms_miss));
9970    }
9971
9972    // ==========================================================================
9973    // Quickselect Top-K Tests (Opt 2.5)
9974    // ==========================================================================
9975
9976    fn make_fused_hit(
9977        id: &str,
9978        rrf: f32,
9979        lexical: Option<usize>,
9980        semantic: Option<usize>,
9981    ) -> FusedHit {
9982        FusedHit {
9983            key: SearchHitKey {
9984                source_id: "local".to_string(),
9985                source_path: id.to_string(),
9986                conversation_id: None,
9987                title: String::new(),
9988                line_number: None,
9989                created_at: None,
9990                content_hash: 0,
9991            },
9992            score: HybridScore {
9993                rrf,
9994                lexical_rank: lexical,
9995                semantic_rank: semantic,
9996                lexical_score: None,
9997                semantic_score: None,
9998            },
9999            hit: SearchHit {
10000                title: id.into(),
10001                snippet: "".into(),
10002                content: "".into(),
10003                content_hash: 0,
10004                score: rrf,
10005                source_path: id.into(),
10006                agent: "test".into(),
10007                workspace: "test".into(),
10008                workspace_original: None,
10009                created_at: None,
10010                line_number: None,
10011                match_type: MatchType::Exact,
10012                source_id: "local".into(),
10013                origin_kind: "local".into(),
10014                origin_host: None,
10015                conversation_id: None,
10016            },
10017        }
10018    }
10019
10020    fn make_federated_merge_hit(id: &str, agent: &str) -> SearchHit {
10021        SearchHit {
10022            title: id.into(),
10023            snippet: String::new(),
10024            content: id.into(),
10025            content_hash: stable_content_hash(id),
10026            score: 0.0,
10027            source_path: format!("{id}.jsonl"),
10028            agent: agent.into(),
10029            workspace: "workspace".into(),
10030            workspace_original: None,
10031            created_at: Some(1_700_000_000_000),
10032            line_number: Some(1),
10033            match_type: MatchType::Exact,
10034            source_id: "local".into(),
10035            origin_kind: "local".into(),
10036            origin_host: None,
10037            conversation_id: None,
10038        }
10039    }
10040
10041    fn make_federated_ranked_hit(
10042        shard_index: usize,
10043        shard_rank: usize,
10044        id: &str,
10045    ) -> FederatedRankedHit {
10046        FederatedRankedHit {
10047            hit: make_federated_merge_hit(id, &format!("shard-{shard_index}")),
10048            shard_index,
10049            shard_rank,
10050            fused_score: federated_rrf_score(shard_rank),
10051        }
10052    }
10053
10054    #[test]
10055    fn federated_merge_orders_equal_rank_hits_by_stable_hit_key() {
10056        let merged = merge_federated_ranked_hits(vec![
10057            make_federated_ranked_hit(2, 0, "zeta"),
10058            make_federated_ranked_hit(0, 0, "bravo"),
10059            make_federated_ranked_hit(1, 0, "alpha"),
10060        ]);
10061
10062        let paths = merged
10063            .iter()
10064            .map(|hit| hit.source_path.as_str())
10065            .collect::<Vec<_>>();
10066        assert_eq!(paths, vec!["alpha.jsonl", "bravo.jsonl", "zeta.jsonl"]);
10067        assert!(
10068            merged
10069                .iter()
10070                .all(|hit| (hit.score - federated_rrf_score(0)).abs() < f32::EPSILON),
10071            "equal per-shard rank should produce equal RRF scores"
10072        );
10073    }
10074
10075    #[test]
10076    fn federated_merge_keeps_rrf_rank_ahead_of_stable_key() {
10077        let merged = merge_federated_ranked_hits(vec![
10078            make_federated_ranked_hit(0, 1, "alpha"),
10079            make_federated_ranked_hit(1, 0, "zeta"),
10080        ]);
10081
10082        let paths = merged
10083            .iter()
10084            .map(|hit| hit.source_path.as_str())
10085            .collect::<Vec<_>>();
10086        assert_eq!(paths, vec!["zeta.jsonl", "alpha.jsonl"]);
10087        assert!(merged[0].score > merged[1].score);
10088    }
10089
10090    #[test]
10091    fn federated_merge_uses_shard_index_as_duplicate_final_tiebreak() {
10092        let merged = merge_federated_ranked_hits(vec![
10093            FederatedRankedHit {
10094                hit: make_federated_merge_hit("same", "shard-2"),
10095                shard_index: 2,
10096                shard_rank: 0,
10097                fused_score: federated_rrf_score(0),
10098            },
10099            FederatedRankedHit {
10100                hit: make_federated_merge_hit("same", "shard-0"),
10101                shard_index: 0,
10102                shard_rank: 0,
10103                fused_score: federated_rrf_score(0),
10104            },
10105        ]);
10106
10107        assert_eq!(merged[0].agent, "shard-0");
10108        assert_eq!(merged[1].agent, "shard-2");
10109    }
10110
10111    #[test]
10112    fn top_k_fused_basic() {
10113        let hits = vec![
10114            make_fused_hit("a", 1.0, Some(0), None),
10115            make_fused_hit("b", 3.0, Some(1), None),
10116            make_fused_hit("c", 2.0, Some(2), None),
10117            make_fused_hit("d", 5.0, Some(3), None),
10118            make_fused_hit("e", 4.0, Some(4), None),
10119        ];
10120
10121        let top = top_k_fused(hits, 3);
10122
10123        assert_eq!(top.len(), 3);
10124        assert_eq!(top[0].key.source_path, "d"); // 5.0
10125        assert_eq!(top[1].key.source_path, "e"); // 4.0
10126        assert_eq!(top[2].key.source_path, "b"); // 3.0
10127    }
10128
10129    #[test]
10130    fn top_k_fused_empty() {
10131        let hits: Vec<FusedHit> = vec![];
10132        let top = top_k_fused(hits, 10);
10133        assert!(top.is_empty());
10134    }
10135
10136    #[test]
10137    fn top_k_fused_k_zero() {
10138        let hits = vec![
10139            make_fused_hit("a", 1.0, Some(0), None),
10140            make_fused_hit("b", 2.0, Some(1), None),
10141        ];
10142        let top = top_k_fused(hits, 0);
10143        assert!(top.is_empty());
10144    }
10145
10146    #[test]
10147    fn top_k_fused_k_larger_than_n() {
10148        let hits = vec![
10149            make_fused_hit("a", 1.0, Some(0), None),
10150            make_fused_hit("b", 2.0, Some(1), None),
10151        ];
10152
10153        let top = top_k_fused(hits, 10);
10154
10155        assert_eq!(top.len(), 2);
10156        assert_eq!(top[0].key.source_path, "b"); // 2.0
10157        assert_eq!(top[1].key.source_path, "a"); // 1.0
10158    }
10159
10160    #[test]
10161    fn top_k_fused_k_equals_n() {
10162        let hits = vec![
10163            make_fused_hit("a", 3.0, Some(0), None),
10164            make_fused_hit("b", 1.0, Some(1), None),
10165            make_fused_hit("c", 2.0, Some(2), None),
10166        ];
10167
10168        let top = top_k_fused(hits, 3);
10169
10170        assert_eq!(top.len(), 3);
10171        assert_eq!(top[0].key.source_path, "a"); // 3.0
10172        assert_eq!(top[1].key.source_path, "c"); // 2.0
10173        assert_eq!(top[2].key.source_path, "b"); // 1.0
10174    }
10175
10176    #[test]
10177    fn top_k_fused_k_one() {
10178        let hits = vec![
10179            make_fused_hit("a", 1.0, Some(0), None),
10180            make_fused_hit("b", 3.0, Some(1), None),
10181            make_fused_hit("c", 2.0, Some(2), None),
10182        ];
10183
10184        let top = top_k_fused(hits, 1);
10185
10186        assert_eq!(top.len(), 1);
10187        assert_eq!(top[0].key.source_path, "b");
10188        assert_eq!(top[0].score.rrf, 3.0);
10189    }
10190
10191    #[test]
10192    fn top_k_fused_duplicate_scores() {
10193        let hits = vec![
10194            make_fused_hit("a", 2.0, Some(0), None),
10195            make_fused_hit("b", 2.0, Some(1), None),
10196            make_fused_hit("c", 2.0, Some(2), None),
10197            make_fused_hit("d", 1.0, Some(3), None),
10198        ];
10199
10200        let top = top_k_fused(hits, 2);
10201
10202        assert_eq!(top.len(), 2);
10203        // All have same score, so order is by key (deterministic tie-breaking)
10204        assert_eq!(top[0].score.rrf, 2.0);
10205        assert_eq!(top[1].score.rrf, 2.0);
10206    }
10207
10208    #[test]
10209    fn top_k_fused_dual_source_tiebreaker() {
10210        // Hits with same RRF score, but some have both lexical and semantic ranks
10211        let hits = vec![
10212            make_fused_hit("a", 2.0, Some(0), None),    // lexical only
10213            make_fused_hit("b", 2.0, Some(1), Some(0)), // both sources
10214            make_fused_hit("c", 2.0, None, Some(1)),    // semantic only
10215        ];
10216
10217        let top = top_k_fused(hits, 3);
10218
10219        assert_eq!(top.len(), 3);
10220        // Dual-source hit should come first
10221        assert_eq!(top[0].key.source_path, "b");
10222    }
10223
10224    #[test]
10225    fn top_k_fused_large_input_uses_quickselect() {
10226        // Create input larger than QUICKSELECT_THRESHOLD to trigger quickselect path
10227        let hits: Vec<FusedHit> = (0..100)
10228            .map(|i| make_fused_hit(&format!("hit_{}", i), i as f32, Some(i), None))
10229            .collect();
10230
10231        let top = top_k_fused(hits, 10);
10232
10233        assert_eq!(top.len(), 10);
10234        // Should be sorted descending: hit_99, hit_98, ... hit_90
10235        for (i, hit) in top.iter().enumerate() {
10236            assert_eq!(hit.key.source_path, format!("hit_{}", 99 - i));
10237            assert_eq!(hit.score.rrf, (99 - i) as f32);
10238        }
10239    }
10240
10241    #[test]
10242    fn top_k_fused_equivalence_with_full_sort() {
10243        // Verify quickselect produces same results as full sort
10244        for n in [10, 50, 100, 200] {
10245            for k in [1, 5, 10, 25] {
10246                if k > n {
10247                    continue;
10248                }
10249
10250                let hits: Vec<FusedHit> = (0..n)
10251                    .map(|i| {
10252                        // Pseudo-random scores using simple hash
10253                        let score = ((i * 17 + 7) % 1000) as f32;
10254                        make_fused_hit(&format!("hit_{}", i), score, Some(i), None)
10255                    })
10256                    .collect();
10257
10258                // Baseline: full sort
10259                let mut baseline = hits.clone();
10260                baseline.sort_by(cmp_fused_hit_desc);
10261                baseline.truncate(k);
10262
10263                // Quickselect
10264                let quickselect = top_k_fused(hits, k);
10265
10266                // Verify same length
10267                assert_eq!(quickselect.len(), baseline.len(), "n={}, k={}", n, k);
10268
10269                // Verify same elements in same order
10270                for (q, b) in quickselect.iter().zip(baseline.iter()) {
10271                    assert_eq!(
10272                        q.key.source_path, b.key.source_path,
10273                        "n={}, k={}: mismatch",
10274                        n, k
10275                    );
10276                    assert_eq!(q.score.rrf, b.score.rrf, "n={}, k={}: score mismatch", n, k);
10277                }
10278            }
10279        }
10280    }
10281
10282    #[test]
10283    fn cmp_fused_hit_desc_basic_ordering() {
10284        let a = make_fused_hit("a", 2.0, Some(0), None);
10285        let b = make_fused_hit("b", 3.0, Some(1), None);
10286
10287        // Higher score should come first (compare returns Less)
10288        assert_eq!(cmp_fused_hit_desc(&a, &b), CmpOrdering::Greater);
10289        assert_eq!(cmp_fused_hit_desc(&b, &a), CmpOrdering::Less);
10290        assert_eq!(cmp_fused_hit_desc(&a, &a), CmpOrdering::Equal);
10291    }
10292
10293    // ==========================================================================
10294    // Original Tests
10295    // ==========================================================================
10296
10297    #[test]
10298    fn cache_enforces_prefix_matching() {
10299        // Hit contains "arrow"
10300        let hit = SearchHit {
10301            title: "test".into(),
10302            snippet: "".into(),
10303            content: "arrow".into(),
10304            content_hash: stable_content_hash("arrow"),
10305            score: 1.0,
10306            source_path: "p".into(),
10307            agent: "a".into(),
10308            workspace: "w".into(),
10309            workspace_original: None,
10310            created_at: None,
10311            line_number: None,
10312            match_type: MatchType::Exact,
10313            source_id: "local".into(),
10314            origin_kind: "local".into(),
10315            origin_host: None,
10316            conversation_id: None,
10317        };
10318
10319        let cached = CachedHit {
10320            hit: hit.clone(),
10321            lc_content: "arrow".into(),
10322            lc_title: Some("test".into()),
10323            bloom64: u64::MAX, // Bypass bloom filter
10324        };
10325
10326        // Query "row" is contained in "arrow" but is NOT a prefix.
10327        // It should NOT match if we are enforcing prefix semantics.
10328        let matched = hit_matches_query_cached(&cached, "row");
10329
10330        assert!(
10331            !matched,
10332            "Query 'row' should NOT match content 'arrow' (prefix match required)"
10333        );
10334    }
10335
10336    #[test]
10337    fn search_deduplication_across_pages_repro() {
10338        // Distinct sessions with identical content should remain visible across
10339        // pages. Global pagination still has to happen after deduplication, but
10340        // dedup itself only coalesces hits that share message-level provenance.
10341
10342        let dir = TempDir::new().unwrap();
10343        let index_path = dir.path();
10344        let mut index = TantivyIndex::open_or_create(index_path).unwrap();
10345
10346        // Add two documents with IDENTICAL content but distinct other fields.
10347        // Tantivy scores them. If query matches both equally, one comes first.
10348        // We'll use different source paths to ensure they are distinct hits initially.
10349        let msg1 = NormalizedMessage {
10350            idx: 0,
10351            role: "user".into(),
10352            author: None,
10353            created_at: Some(1000),
10354            content: "duplicate content".into(),
10355            extra: serde_json::json!({}),
10356            snippets: Vec::new(),
10357            invocations: Vec::new(),
10358        };
10359        let conv1 = NormalizedConversation {
10360            agent_slug: "agent1".into(),
10361            external_id: None,
10362            title: None,
10363            workspace: None,
10364            source_path: "path/1".into(),
10365            started_at: None,
10366            ended_at: None,
10367            metadata: serde_json::json!({}),
10368            messages: vec![msg1],
10369        };
10370
10371        let msg2 = NormalizedMessage {
10372            idx: 0,
10373            role: "user".into(),
10374            author: None,
10375            created_at: Some(2000),              // Different timestamp
10376            content: "duplicate content".into(), // SAME content
10377            extra: serde_json::json!({}),
10378            snippets: Vec::new(),
10379            invocations: Vec::new(),
10380        };
10381        let conv2 = NormalizedConversation {
10382            agent_slug: "agent1".into(),
10383            external_id: None,
10384            title: None,
10385            workspace: None,
10386            source_path: "path/2".into(), // Different source path
10387            started_at: None,
10388            ended_at: None,
10389            metadata: serde_json::json!({}),
10390            messages: vec![msg2],
10391        };
10392
10393        index.add_conversation(&conv1).unwrap();
10394        index.add_conversation(&conv2).unwrap();
10395        index.commit().unwrap();
10396
10397        let client = SearchClient::open(index_path, None).unwrap().unwrap();
10398
10399        // Search page 1: limit 1, offset 0
10400        let page1 = client
10401            .search("duplicate", SearchFilters::default(), 1, 0, FieldMask::FULL)
10402            .unwrap();
10403        assert_eq!(page1.len(), 1);
10404
10405        // Search page 2: limit 1, offset 1
10406        let page2 = client
10407            .search("duplicate", SearchFilters::default(), 1, 1, FieldMask::FULL)
10408            .unwrap();
10409
10410        assert_eq!(page2.len(), 1);
10411        assert_ne!(page1[0].source_path, page2[0].source_path);
10412    }
10413
10414    #[test]
10415    fn cache_skips_complex_queries() {
10416        let client = SearchClient {
10417            reader: None,
10418            sqlite: Mutex::new(None),
10419            sqlite_path: None,
10420            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
10421            reload_on_search: true,
10422            last_reload: Mutex::new(None),
10423            last_generation: Mutex::new(None),
10424            reload_epoch: Arc::new(AtomicU64::new(0)),
10425            warm_tx: None,
10426            _warm_handle: None,
10427            metrics: Metrics::default(),
10428            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
10429            semantic: Mutex::new(None),
10430            last_tantivy_total_count: Mutex::new(None),
10431        };
10432
10433        // Wildcard query should skip cache logic entirely (no miss recorded)
10434        let _ = client.search("foo*", SearchFilters::default(), 10, 0, FieldMask::FULL);
10435        let stats = client.cache_stats();
10436        assert_eq!(
10437            stats.cache_miss, 0,
10438            "Wildcard query should not trigger cache miss"
10439        );
10440
10441        // Boolean query should skip cache
10442        let _ = client.search(
10443            "foo OR bar",
10444            SearchFilters::default(),
10445            10,
10446            0,
10447            FieldMask::FULL,
10448        );
10449        let stats = client.cache_stats();
10450        assert_eq!(
10451            stats.cache_miss, 0,
10452            "Boolean query should not trigger cache miss"
10453        );
10454
10455        // Simple query should trigger miss
10456        let _ = client.search("simple", SearchFilters::default(), 10, 0, FieldMask::FULL);
10457        let stats = client.cache_stats();
10458        assert_eq!(
10459            stats.cache_miss, 1,
10460            "Simple query should trigger cache miss"
10461        );
10462    }
10463
10464    #[test]
10465    fn cache_prefix_lookup_handles_utf8_boundaries() {
10466        let client = SearchClient {
10467            reader: None,
10468            sqlite: Mutex::new(None),
10469            sqlite_path: None,
10470            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
10471            reload_on_search: true,
10472            last_reload: Mutex::new(None),
10473            last_generation: Mutex::new(None),
10474            reload_epoch: Arc::new(AtomicU64::new(0)),
10475            warm_tx: None,
10476            _warm_handle: None,
10477            metrics: Metrics::default(),
10478            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
10479            semantic: Mutex::new(None),
10480            last_tantivy_total_count: Mutex::new(None),
10481        };
10482
10483        let hits = vec![SearchHit {
10484            title: "こんにちは".into(),
10485            snippet: String::new(),
10486            content: "こんにちは 世界".into(),
10487            content_hash: stable_content_hash("こんにちは 世界"),
10488            score: 1.0,
10489            source_path: "p".into(),
10490            agent: "a".into(),
10491            workspace: "w".into(),
10492            workspace_original: None,
10493            created_at: None,
10494            line_number: None,
10495            match_type: MatchType::Exact,
10496            source_id: "local".into(),
10497            origin_kind: "local".into(),
10498            origin_host: None,
10499            conversation_id: None,
10500        }];
10501
10502        client.put_cache("こん", &SearchFilters::default(), &hits);
10503
10504        let cached = client
10505            .cached_prefix_hits("こんにちは", &SearchFilters::default())
10506            .unwrap();
10507        assert_eq!(cached.len(), 1);
10508        assert_eq!(cached[0].hit.title, "こんにちは");
10509    }
10510
10511    #[test]
10512    fn bloom_gate_rejects_missing_terms() {
10513        let hit = SearchHit {
10514            title: "hello world".into(),
10515            snippet: "hello world".into(),
10516            content: "hello world".into(),
10517            content_hash: stable_content_hash("hello world"),
10518            score: 1.0,
10519            source_path: "p".into(),
10520            agent: "a".into(),
10521            workspace: "w".into(),
10522            workspace_original: None,
10523            created_at: None,
10524            line_number: None,
10525            match_type: MatchType::Exact,
10526            source_id: "local".into(),
10527            origin_kind: "local".into(),
10528            origin_host: None,
10529            conversation_id: None,
10530        };
10531        let cached = cached_hit_from(&hit);
10532        assert!(hit_matches_query_cached(&cached, "hello"));
10533        assert!(!hit_matches_query_cached(&cached, "missing"));
10534
10535        let metrics = Metrics::default();
10536        metrics.inc_cache_hits();
10537        metrics.inc_cache_miss();
10538        metrics.inc_cache_shortfall();
10539        metrics.inc_reload();
10540        let (hits, miss, shortfall, reloads, _) = metrics.snapshot_all();
10541        assert_eq!((hits, miss, shortfall, reloads), (1, 1, 1, 1));
10542    }
10543
10544    #[test]
10545    fn progressive_lexical_hit_omits_unused_content() {
10546        let hit = SearchHit {
10547            title: "hello world".into(),
10548            snippet: "hello **world**".into(),
10549            content: "hello world from a much larger conversation body".into(),
10550            content_hash: stable_content_hash("hello world from a much larger conversation body"),
10551            score: 1.0,
10552            source_path: "p".into(),
10553            agent: "a".into(),
10554            workspace: "w".into(),
10555            workspace_original: None,
10556            created_at: None,
10557            line_number: Some(3),
10558            match_type: MatchType::Exact,
10559            source_id: "local".into(),
10560            origin_kind: "local".into(),
10561            origin_host: None,
10562            conversation_id: None,
10563        };
10564
10565        let snippet_only =
10566            ProgressiveLexicalHit::from_search_hit(&hit, FieldMask::new(false, true, true, true));
10567        assert_eq!(snippet_only.title, hit.title);
10568        assert_eq!(snippet_only.snippet, hit.snippet);
10569        assert!(
10570            snippet_only.content.is_empty(),
10571            "snippet-only progressive cache should not retain full content"
10572        );
10573        assert_eq!(snippet_only.match_type, hit.match_type);
10574        assert_eq!(snippet_only.line_number, hit.line_number);
10575        assert_eq!(snippet_only.source_path, hit.source_path);
10576        assert_eq!(snippet_only.agent, hit.agent);
10577        assert_eq!(snippet_only.workspace, hit.workspace);
10578
10579        let full =
10580            ProgressiveLexicalHit::from_search_hit(&hit, FieldMask::new(true, true, true, true));
10581        assert_eq!(full.content, hit.content);
10582    }
10583
10584    #[test]
10585    fn progressive_phase_reuses_lexical_cache_without_db_hydration() -> Result<()> {
10586        let client = SearchClient {
10587            reader: None,
10588            sqlite: Mutex::new(None),
10589            sqlite_path: None,
10590            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
10591            reload_on_search: true,
10592            last_reload: Mutex::new(None),
10593            last_generation: Mutex::new(None),
10594            reload_epoch: Arc::new(AtomicU64::new(0)),
10595            warm_tx: None,
10596            _warm_handle: None,
10597            metrics: Metrics::default(),
10598            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
10599            semantic: Mutex::new(None),
10600            last_tantivy_total_count: Mutex::new(None),
10601        };
10602        let field_mask = FieldMask::new(false, true, true, true);
10603        let lexical_hit = SearchHit {
10604            title: "lexical title".into(),
10605            snippet: "lexical snippet".into(),
10606            content: "full lexical body".into(),
10607            content_hash: stable_content_hash("full lexical body"),
10608            score: 0.0,
10609            source_path: "/tmp/session.jsonl".into(),
10610            agent: "codex".into(),
10611            workspace: "/tmp".into(),
10612            workspace_original: Some("/original".into()),
10613            created_at: Some(1_700_000_000_000),
10614            line_number: Some(7),
10615            match_type: MatchType::Exact,
10616            source_id: "local".into(),
10617            origin_kind: "local".into(),
10618            origin_host: None,
10619            conversation_id: None,
10620        };
10621        let mut lexical_cache = ProgressiveLexicalCache::default();
10622        lexical_cache.hits_by_message.insert(
10623            42,
10624            ProgressiveLexicalHit::from_search_hit(&lexical_hit, field_mask),
10625        );
10626
10627        let hash_hex = "00".repeat(32);
10628        let results = vec![FsScoredResult {
10629            doc_id: format!("m|42|0|1|1|1|1|1700000000000|{hash_hex}"),
10630            score: 0.91,
10631            source: FsScoreSource::Lexical,
10632            index: None,
10633            fast_score: None,
10634            quality_score: None,
10635            lexical_score: Some(0.91),
10636            rerank_score: None,
10637            explanation: None,
10638            metadata: None,
10639        }];
10640
10641        let result = client.progressive_phase_to_result(
10642            &results,
10643            ProgressivePhaseContext {
10644                query: "merged title",
10645                filters: &SearchFilters::default(),
10646                field_mask,
10647                lexical_cache: Some(&lexical_cache),
10648                limit: 1,
10649                fetch_limit: 1,
10650            },
10651        )?;
10652
10653        assert_eq!(result.hits.len(), 1);
10654        assert_eq!(result.hits[0].title, lexical_hit.title);
10655        assert_eq!(result.hits[0].snippet, lexical_hit.snippet);
10656        assert!(
10657            result.hits[0].content.is_empty(),
10658            "masked lexical cache should still avoid carrying full content"
10659        );
10660        assert_eq!(result.hits[0].source_path, lexical_hit.source_path);
10661        assert_eq!(result.hits[0].score, 0.91);
10662
10663        Ok(())
10664    }
10665
10666    #[test]
10667    fn search_returns_results_with_filters_and_pagination() -> Result<()> {
10668        let dir = TempDir::new()?;
10669        let mut index = TantivyIndex::open_or_create(dir.path())?;
10670        let conv = NormalizedConversation {
10671            agent_slug: "codex".into(),
10672            external_id: None,
10673            title: Some("hello world convo".into()),
10674            workspace: Some(std::path::PathBuf::from("/tmp/workspace")),
10675            source_path: dir.path().join("rollout-1.jsonl"),
10676            started_at: Some(1_700_000_000_000),
10677            ended_at: None,
10678            metadata: serde_json::json!({}),
10679            messages: vec![NormalizedMessage {
10680                idx: 0,
10681                role: "user".into(),
10682                author: Some("me".into()),
10683                created_at: Some(1_700_000_000_000),
10684                content: "hello rust world".into(),
10685                extra: serde_json::json!({}),
10686                snippets: vec![NormalizedSnippet {
10687                    file_path: None,
10688                    start_line: None,
10689                    end_line: None,
10690                    language: None,
10691                    snippet_text: None,
10692                }],
10693                invocations: Vec::new(),
10694            }],
10695        };
10696        index.add_conversation(&conv)?;
10697        index.commit()?;
10698
10699        let client = SearchClient::open(dir.path(), None)?.expect("index present");
10700        let mut filters = SearchFilters::default();
10701        filters.agents.insert("codex".into());
10702
10703        let hits = client.search("hello", filters, 10, 0, FieldMask::FULL)?;
10704        assert_eq!(hits.len(), 1);
10705        assert_eq!(hits[0].agent, "codex");
10706        assert!(hits[0].snippet.contains("hello"));
10707        Ok(())
10708    }
10709
10710    #[test]
10711    fn search_honors_created_range_and_workspace() -> Result<()> {
10712        let dir = TempDir::new()?;
10713        let mut index = TantivyIndex::open_or_create(dir.path())?;
10714
10715        let conv_a = NormalizedConversation {
10716            agent_slug: "codex".into(),
10717            external_id: None,
10718            title: Some("needle one".into()),
10719            workspace: Some(std::path::PathBuf::from("/ws/a")),
10720            source_path: dir.path().join("a.jsonl"),
10721            started_at: Some(10),
10722            ended_at: None,
10723            metadata: serde_json::json!({}),
10724            messages: vec![NormalizedMessage {
10725                idx: 0,
10726                role: "user".into(),
10727                author: None,
10728                created_at: Some(10),
10729                content: "alpha needle".into(),
10730                extra: serde_json::json!({}),
10731                snippets: vec![NormalizedSnippet {
10732                    file_path: None,
10733                    start_line: None,
10734                    end_line: None,
10735                    language: None,
10736                    snippet_text: None,
10737                }],
10738                invocations: Vec::new(),
10739            }],
10740        };
10741        let conv_b = NormalizedConversation {
10742            agent_slug: "codex".into(),
10743            external_id: None,
10744            title: Some("needle two".into()),
10745            workspace: Some(std::path::PathBuf::from("/ws/b")),
10746            source_path: dir.path().join("b.jsonl"),
10747            started_at: Some(20),
10748            ended_at: None,
10749            metadata: serde_json::json!({}),
10750            messages: vec![NormalizedMessage {
10751                idx: 0,
10752                role: "user".into(),
10753                author: None,
10754                created_at: Some(20),
10755                content: "\nneedle second line".into(),
10756                extra: serde_json::json!({}),
10757                snippets: vec![NormalizedSnippet {
10758                    file_path: None,
10759                    start_line: None,
10760                    end_line: None,
10761                    language: None,
10762                    snippet_text: None,
10763                }],
10764                invocations: Vec::new(),
10765            }],
10766        };
10767        index.add_conversation(&conv_a)?;
10768        index.add_conversation(&conv_b)?;
10769        index.commit()?;
10770
10771        let client = SearchClient::open(dir.path(), None)?.expect("index present");
10772        let mut filters = SearchFilters::default();
10773        filters.workspaces.insert("/ws/b".into());
10774        filters.created_from = Some(15);
10775        filters.created_to = Some(25);
10776
10777        let hits = client.search("needle", filters, 10, 0, FieldMask::FULL)?;
10778        assert_eq!(hits.len(), 1);
10779        assert_eq!(hits[0].workspace, "/ws/b");
10780        assert!(hits[0].snippet.contains("second line"));
10781        Ok(())
10782    }
10783
10784    #[test]
10785    fn pagination_skips_results() -> Result<()> {
10786        let dir = TempDir::new()?;
10787        let mut index = TantivyIndex::open_or_create(dir.path())?;
10788        for i in 0..3 {
10789            let conv = NormalizedConversation {
10790                agent_slug: "codex".into(),
10791                external_id: None,
10792                title: Some(format!("doc-{i}")),
10793                workspace: Some(std::path::PathBuf::from("/ws/p")),
10794                source_path: dir.path().join(format!("{i}.jsonl")),
10795                started_at: Some(100 + i),
10796                ended_at: None,
10797                metadata: serde_json::json!({}),
10798                messages: vec![NormalizedMessage {
10799                    idx: 0,
10800                    role: "user".into(),
10801                    author: None,
10802                    created_at: Some(100 + i),
10803                    // Use unique content for each doc to avoid deduplication
10804                    content: format!("pagination needle document number {i}"),
10805                    extra: serde_json::json!({}),
10806                    snippets: vec![NormalizedSnippet {
10807                        file_path: None,
10808                        start_line: None,
10809                        end_line: None,
10810                        language: None,
10811                        snippet_text: None,
10812                    }],
10813                    invocations: Vec::new(),
10814                }],
10815            };
10816            index.add_conversation(&conv)?;
10817        }
10818        index.commit()?;
10819
10820        let client = SearchClient::open(dir.path(), None)?.expect("index present");
10821        let hits = client.search(
10822            "pagination",
10823            SearchFilters::default(),
10824            1,
10825            1,
10826            FieldMask::FULL,
10827        )?;
10828        assert_eq!(hits.len(), 1);
10829        Ok(())
10830    }
10831
10832    #[test]
10833    fn search_matches_hyphenated_term() -> Result<()> {
10834        let dir = TempDir::new()?;
10835        let mut index = TantivyIndex::open_or_create(dir.path())?;
10836        let conv = NormalizedConversation {
10837            agent_slug: "codex".into(),
10838            external_id: None,
10839            title: Some("cma-es notes".into()),
10840            workspace: Some(std::path::PathBuf::from("/tmp/workspace")),
10841            source_path: dir.path().join("rollout-1.jsonl"),
10842            started_at: Some(1_700_000_000_000),
10843            ended_at: None,
10844            metadata: serde_json::json!({}),
10845            messages: vec![NormalizedMessage {
10846                idx: 0,
10847                role: "user".into(),
10848                author: Some("me".into()),
10849                created_at: Some(1_700_000_000_000),
10850                content: "Need CMA-ES strategy and CMA ES variants".into(),
10851                extra: serde_json::json!({}),
10852                snippets: vec![NormalizedSnippet {
10853                    file_path: None,
10854                    start_line: None,
10855                    end_line: None,
10856                    language: None,
10857                    snippet_text: None,
10858                }],
10859                invocations: Vec::new(),
10860            }],
10861        };
10862        index.add_conversation(&conv)?;
10863        index.commit()?;
10864
10865        let client = SearchClient::open(dir.path(), None)?.expect("index present");
10866        let hits = client.search("cma-es", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
10867        assert_eq!(hits.len(), 1);
10868        assert!(hits[0].snippet.to_lowercase().contains("cma"));
10869        Ok(())
10870    }
10871
10872    #[test]
10873    fn search_matches_prefix_edge_ngram() -> Result<()> {
10874        let dir = TempDir::new()?;
10875        let mut index = TantivyIndex::open_or_create(dir.path())?;
10876        let conv = NormalizedConversation {
10877            agent_slug: "codex".into(),
10878            external_id: None,
10879            title: Some("math logic".into()),
10880            workspace: Some(std::path::PathBuf::from("/ws/m")),
10881            source_path: dir.path().join("math.jsonl"),
10882            started_at: Some(1000),
10883            ended_at: None,
10884            metadata: serde_json::json!({}),
10885            messages: vec![NormalizedMessage {
10886                idx: 0,
10887                role: "user".into(),
10888                author: None,
10889                created_at: Some(1000),
10890                content: "please calculate the entropy".into(),
10891                extra: serde_json::json!({}),
10892                snippets: vec![],
10893                invocations: Vec::new(),
10894            }],
10895        };
10896        index.add_conversation(&conv)?;
10897        index.commit()?;
10898
10899        let client = SearchClient::open(dir.path(), None)?.expect("index present");
10900
10901        // "cal" should match "calculate"
10902        let hits = client.search("cal", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
10903        assert_eq!(hits.len(), 1);
10904        assert!(hits[0].content.contains("calculate"));
10905
10906        // "entr" should match "entropy"
10907        let hits = client.search("entr", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
10908        assert_eq!(hits.len(), 1);
10909
10910        Ok(())
10911    }
10912
10913    #[test]
10914    fn search_matches_snake_case() -> Result<()> {
10915        let dir = TempDir::new()?;
10916        let mut index = TantivyIndex::open_or_create(dir.path())?;
10917        let conv = NormalizedConversation {
10918            agent_slug: "codex".into(),
10919            external_id: None,
10920            title: Some("code".into()),
10921            workspace: None,
10922            source_path: dir.path().join("c.jsonl"),
10923            started_at: Some(1),
10924            ended_at: None,
10925            metadata: serde_json::json!({}),
10926            messages: vec![NormalizedMessage {
10927                idx: 0,
10928                role: "user".into(),
10929                author: None,
10930                created_at: Some(1),
10931                content: "check the my_variable_name please".into(),
10932                extra: serde_json::json!({}),
10933                snippets: vec![],
10934                invocations: Vec::new(),
10935            }],
10936        };
10937        index.add_conversation(&conv)?;
10938        index.commit()?;
10939
10940        let client = SearchClient::open(dir.path(), None)?.expect("index present");
10941
10942        // "vari" should match "variable" inside "my_variable_name"
10943        let hits = client.search("vari", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
10944        assert_eq!(hits.len(), 1);
10945
10946        // "my_variable" should match "my_variable_name" (because it splits to "my variable")
10947        let hits = client.search(
10948            "my_variable",
10949            SearchFilters::default(),
10950            10,
10951            0,
10952            FieldMask::FULL,
10953        )?;
10954        assert_eq!(hits.len(), 1);
10955
10956        Ok(())
10957    }
10958
10959    #[test]
10960    fn search_matches_symbols_stripped() -> Result<()> {
10961        let dir = TempDir::new()?;
10962        let mut index = TantivyIndex::open_or_create(dir.path())?;
10963        let conv = NormalizedConversation {
10964            agent_slug: "codex".into(),
10965            external_id: None,
10966            title: Some("symbols".into()),
10967            workspace: None,
10968            source_path: dir.path().join("s.jsonl"),
10969            started_at: Some(1),
10970            ended_at: None,
10971            metadata: serde_json::json!({}),
10972            messages: vec![NormalizedMessage {
10973                idx: 0,
10974                role: "user".into(),
10975                author: None,
10976                created_at: Some(1),
10977                content: "working with c++ and foo.bar today".into(),
10978                extra: serde_json::json!({}),
10979                snippets: vec![],
10980                invocations: Vec::new(),
10981            }],
10982        };
10983        index.add_conversation(&conv)?;
10984        index.commit()?;
10985
10986        let client = SearchClient::open(dir.path(), None)?.expect("index present");
10987
10988        // "c++" -> "c"
10989        let hits = client.search("c++", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
10990        assert_eq!(hits.len(), 1);
10991
10992        // "foo.bar" -> "foo", "bar"
10993        let hits = client.search("foo.bar", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
10994        assert_eq!(hits.len(), 1);
10995
10996        Ok(())
10997    }
10998
10999    #[test]
11000    fn search_sets_match_type_for_wildcards() -> Result<()> {
11001        let dir = TempDir::new()?;
11002        let mut index = TantivyIndex::open_or_create(dir.path())?;
11003
11004        let conv = NormalizedConversation {
11005            agent_slug: "codex".into(),
11006            external_id: None,
11007            title: Some("handlers".into()),
11008            workspace: None,
11009            source_path: dir.path().join("h.jsonl"),
11010            started_at: Some(1),
11011            ended_at: None,
11012            metadata: serde_json::json!({}),
11013            messages: vec![NormalizedMessage {
11014                idx: 0,
11015                role: "user".into(),
11016                author: None,
11017                created_at: Some(1),
11018                content: "the request handler delegates".into(),
11019                extra: serde_json::json!({}),
11020                snippets: vec![],
11021                invocations: Vec::new(),
11022            }],
11023        };
11024        index.add_conversation(&conv)?;
11025        index.commit()?;
11026
11027        let client = SearchClient::open(dir.path(), None)?.expect("index present");
11028
11029        let exact = client.search("handler", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
11030        assert_eq!(exact[0].match_type, MatchType::Exact);
11031
11032        let prefix = client.search("hand*", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
11033        assert_eq!(prefix[0].match_type, MatchType::Prefix);
11034
11035        let suffix = client.search("*handler", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
11036        assert_eq!(suffix[0].match_type, MatchType::Suffix);
11037
11038        let substring =
11039            client.search("*andle*", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
11040        assert_eq!(substring[0].match_type, MatchType::Substring);
11041
11042        Ok(())
11043    }
11044
11045    #[test]
11046    fn search_with_fallback_marks_implicit_wildcard() -> Result<()> {
11047        let dir = TempDir::new()?;
11048        let mut index = TantivyIndex::open_or_create(dir.path())?;
11049
11050        let conv = NormalizedConversation {
11051            agent_slug: "codex".into(),
11052            external_id: None,
11053            title: Some("handlers".into()),
11054            workspace: None,
11055            source_path: dir.path().join("h2.jsonl"),
11056            started_at: Some(1),
11057            ended_at: None,
11058            metadata: serde_json::json!({}),
11059            messages: vec![NormalizedMessage {
11060                idx: 0,
11061                role: "user".into(),
11062                author: None,
11063                created_at: Some(1),
11064                content: "the request handler delegates".into(),
11065                extra: serde_json::json!({}),
11066                snippets: vec![],
11067                invocations: Vec::new(),
11068            }],
11069        };
11070        index.add_conversation(&conv)?;
11071        index.commit()?;
11072
11073        let client = SearchClient::open(dir.path(), None)?.expect("index present");
11074
11075        // Base search for "andle" finds nothing; fallback "*andle*" should hit and mark implicit.
11076        let result = client.search_with_fallback(
11077            "andle",
11078            SearchFilters::default(),
11079            10,
11080            0,
11081            2,
11082            FieldMask::FULL,
11083        )?;
11084        assert!(result.wildcard_fallback);
11085        assert_eq!(result.hits.len(), 1);
11086        assert_eq!(result.hits[0].match_type, MatchType::ImplicitWildcard);
11087
11088        Ok(())
11089    }
11090
11091    #[test]
11092    fn sqlite_backend_skips_wildcard_queries() -> Result<()> {
11093        // Build a client with SQLite only; wildcard queries should short-circuit without errors.
11094        let conn = Connection::open(":memory:")?;
11095        let client = SearchClient {
11096            reader: None,
11097            sqlite: Mutex::new(Some(SendConnection(conn))),
11098            sqlite_path: None,
11099            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
11100            reload_on_search: true,
11101            last_reload: Mutex::new(None),
11102            last_generation: Mutex::new(None),
11103            reload_epoch: Arc::new(AtomicU64::new(0)),
11104            warm_tx: None,
11105            _warm_handle: None,
11106            metrics: Metrics::default(),
11107            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
11108            semantic: Mutex::new(None),
11109            last_tantivy_total_count: Mutex::new(None),
11110        };
11111
11112        let hits = client.search("*handler", SearchFilters::default(), 5, 0, FieldMask::FULL)?;
11113        assert!(
11114            hits.is_empty(),
11115            "wildcard should skip sqlite fallback, not error"
11116        );
11117
11118        Ok(())
11119    }
11120
11121    #[test]
11122    fn sqlite_backend_handles_null_workspace() -> Result<()> {
11123        let conn = Connection::open(":memory:")?;
11124        conn.execute_batch(
11125            "CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
11126             CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL UNIQUE);
11127             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE);
11128             CREATE TABLE conversations (
11129                id INTEGER PRIMARY KEY,
11130                agent_id INTEGER,
11131                workspace_id INTEGER,
11132                source_id TEXT,
11133                origin_host TEXT,
11134                title TEXT,
11135                source_path TEXT
11136             );
11137             CREATE TABLE messages (
11138                id INTEGER PRIMARY KEY,
11139                conversation_id INTEGER,
11140                idx INTEGER,
11141                content TEXT,
11142                created_at INTEGER
11143             );
11144             CREATE VIRTUAL TABLE fts_messages USING fts5(
11145                content,
11146                title,
11147                agent,
11148                workspace,
11149                source_path,
11150                created_at UNINDEXED,
11151                content='',
11152                tokenize='porter'
11153             );",
11154        )?;
11155        conn.execute("INSERT INTO sources(id, kind) VALUES('local', 'local')")?;
11156        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
11157        conn.execute(
11158            "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')",
11159        )?;
11160        conn.execute("INSERT INTO messages(id, conversation_id, idx, content, created_at) VALUES(1, 1, 0, 'auth token failure', 42)")?;
11161        conn.execute_compat(
11162            "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at)
11163             VALUES(?1, ?2, ?3, ?4, NULL, ?5, ?6)",
11164            params![
11165                1_i64,
11166                "auth token failure",
11167                "t",
11168                "codex",
11169                "/tmp/session.jsonl",
11170                42_i64
11171            ],
11172        )?;
11173
11174        let client = SearchClient {
11175            reader: None,
11176            sqlite: Mutex::new(Some(SendConnection(conn))),
11177            sqlite_path: None,
11178            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
11179            reload_on_search: true,
11180            last_reload: Mutex::new(None),
11181            last_generation: Mutex::new(None),
11182            reload_epoch: Arc::new(AtomicU64::new(0)),
11183            warm_tx: None,
11184            _warm_handle: None,
11185            metrics: Metrics::default(),
11186            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
11187            semantic: Mutex::new(None),
11188            last_tantivy_total_count: Mutex::new(None),
11189        };
11190
11191        let hits = client.search("auth", SearchFilters::default(), 5, 0, FieldMask::FULL)?;
11192        assert_eq!(hits.len(), 1);
11193        assert_eq!(hits[0].workspace, "");
11194        assert_eq!(hits[0].line_number, Some(1));
11195        assert_eq!(hits[0].source_id, "local");
11196        assert_eq!(hits[0].origin_kind, "local");
11197        Ok(())
11198    }
11199
11200    #[test]
11201    fn sqlite_backend_supports_legacy_fts_message_id_schema() -> Result<()> {
11202        let conn = Connection::open(":memory:")?;
11203        conn.execute_batch(
11204            "CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
11205             CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL UNIQUE);
11206             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE);
11207             CREATE TABLE conversations (
11208                id INTEGER PRIMARY KEY,
11209                agent_id INTEGER,
11210                workspace_id INTEGER,
11211                source_id TEXT,
11212                origin_host TEXT,
11213                title TEXT,
11214                source_path TEXT
11215             );
11216             CREATE TABLE messages (
11217                id INTEGER PRIMARY KEY,
11218                conversation_id INTEGER,
11219                idx INTEGER,
11220                content TEXT,
11221                created_at INTEGER
11222             );
11223             CREATE VIRTUAL TABLE fts_messages USING fts5(
11224                content,
11225                title,
11226                agent,
11227                workspace,
11228                source_path,
11229                created_at UNINDEXED,
11230                message_id UNINDEXED,
11231                tokenize='porter'
11232             );",
11233        )?;
11234        conn.execute("INSERT INTO sources(id, kind) VALUES('local', 'local')")?;
11235        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
11236        conn.execute("INSERT INTO workspaces(id, path) VALUES(1, '/legacy')")?;
11237        conn.execute(
11238            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
11239             VALUES(1, 1, 1, 'local', NULL, 'legacy title', '/tmp/legacy.jsonl')",
11240        )?;
11241        conn.execute(
11242            "INSERT INTO messages(id, conversation_id, idx, content, created_at)
11243             VALUES(42, 1, 4, 'legacy auth token failure', 99)",
11244        )?;
11245        conn.execute_compat(
11246            "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at, message_id)
11247             VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
11248            params![
11249                1_i64,
11250                "legacy auth token failure",
11251                "legacy title",
11252                "codex",
11253                "/legacy",
11254                "/tmp/legacy.jsonl",
11255                99_i64,
11256                42_i64
11257            ],
11258        )?;
11259
11260        let client = SearchClient {
11261            reader: None,
11262            sqlite: Mutex::new(Some(SendConnection(conn))),
11263            sqlite_path: None,
11264            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
11265            reload_on_search: true,
11266            last_reload: Mutex::new(None),
11267            last_generation: Mutex::new(None),
11268            reload_epoch: Arc::new(AtomicU64::new(0)),
11269            warm_tx: None,
11270            _warm_handle: None,
11271            metrics: Metrics::default(),
11272            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
11273            semantic: Mutex::new(None),
11274            last_tantivy_total_count: Mutex::new(None),
11275        };
11276
11277        let hits = client.search("auth", SearchFilters::default(), 5, 0, FieldMask::FULL)?;
11278        assert_eq!(hits.len(), 1);
11279        assert_eq!(hits[0].title, "legacy title");
11280        assert_eq!(hits[0].source_path, "/tmp/legacy.jsonl");
11281        assert_eq!(hits[0].workspace, "/legacy");
11282        assert_eq!(hits[0].line_number, Some(5));
11283        assert_eq!(hits[0].content, "legacy auth token failure");
11284        Ok(())
11285    }
11286
11287    #[test]
11288    fn tantivy_reader_skips_sqlite_fallback_on_empty_lexical_results() -> Result<()> {
11289        let dir = TempDir::new()?;
11290        let mut index = TantivyIndex::open_or_create(dir.path())?;
11291        index.commit()?;
11292        let reader = fs_cass_open_search_reader(dir.path(), ReloadPolicy::Manual).ok();
11293        assert!(
11294            reader.is_some(),
11295            "test fixture should open a Tantivy reader even with an empty index"
11296        );
11297
11298        let conn = Connection::open(":memory:")?;
11299        conn.execute_batch(
11300            "CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
11301             CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL UNIQUE);
11302             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE);
11303             CREATE TABLE conversations (
11304                id INTEGER PRIMARY KEY,
11305                agent_id INTEGER,
11306                workspace_id INTEGER,
11307                source_id TEXT,
11308                origin_host TEXT,
11309                title TEXT,
11310                source_path TEXT
11311             );
11312             CREATE TABLE messages (
11313                id INTEGER PRIMARY KEY,
11314                conversation_id INTEGER,
11315                idx INTEGER,
11316                content TEXT,
11317                created_at INTEGER
11318             );
11319             CREATE VIRTUAL TABLE fts_messages USING fts5(
11320                content,
11321                title,
11322                agent,
11323                workspace,
11324                source_path,
11325                created_at UNINDEXED,
11326                content='',
11327                tokenize='porter'
11328             );",
11329        )?;
11330        conn.execute("INSERT INTO sources(id, kind) VALUES('local', 'local')")?;
11331        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
11332        conn.execute("INSERT INTO workspaces(id, path) VALUES(1, '/sqlite-only')")?;
11333        conn.execute(
11334            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
11335             VALUES(1, 1, 1, 'local', NULL, 'sqlite fallback only', '/tmp/sqlite-only.jsonl')",
11336        )?;
11337        conn.execute(
11338            "INSERT INTO messages(id, conversation_id, idx, content, created_at)
11339             VALUES(1, 1, 0, 'sqliteonlytoken overflow candidate', 42)",
11340        )?;
11341        conn.execute_compat(
11342            "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at)
11343             VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)",
11344            params![
11345                1_i64,
11346                "sqliteonlytoken overflow candidate",
11347                "sqlite fallback only",
11348                "codex",
11349                "/sqlite-only",
11350                "/tmp/sqlite-only.jsonl",
11351                42_i64
11352            ],
11353        )?;
11354
11355        let client = SearchClient {
11356            reader,
11357            sqlite: Mutex::new(Some(SendConnection(conn))),
11358            sqlite_path: None,
11359            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
11360            reload_on_search: true,
11361            last_reload: Mutex::new(None),
11362            last_generation: Mutex::new(None),
11363            reload_epoch: Arc::new(AtomicU64::new(0)),
11364            warm_tx: None,
11365            _warm_handle: None,
11366            metrics: Metrics::default(),
11367            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
11368            semantic: Mutex::new(None),
11369            last_tantivy_total_count: Mutex::new(None),
11370        };
11371
11372        let sqlite_hits = client.search_sqlite_fts5(
11373            Path::new(":memory:"),
11374            "sqliteonlytoken",
11375            SearchFilters::default(),
11376            5,
11377            0,
11378            FieldMask::FULL,
11379        )?;
11380        assert_eq!(
11381            sqlite_hits.len(),
11382            1,
11383            "fixture should prove sqlite fallback would have produced a hit"
11384        );
11385
11386        let tantivy_authoritative_hits = client.search(
11387            "sqliteonlytoken",
11388            SearchFilters::default(),
11389            5,
11390            0,
11391            FieldMask::FULL,
11392        )?;
11393        assert!(
11394            tantivy_authoritative_hits.is_empty(),
11395            "a live Tantivy reader should prevent sqlite fallback from populating empty lexical results"
11396        );
11397        Ok(())
11398    }
11399
11400    #[test]
11401    fn sqlite_guard_does_not_repair_fts_when_generation_key_stale() -> Result<()> {
11402        let temp_dir = TempDir::new()?;
11403        let db_path = temp_dir.path().join("stale-gen-fts.db");
11404
11405        // Seed a DB with a conversation and indexed FTS content.
11406        {
11407            let storage = FrankenStorage::open(&db_path)?;
11408            let agent = Agent {
11409                id: None,
11410                slug: "codex".into(),
11411                name: "Codex".into(),
11412                version: None,
11413                kind: AgentKind::Cli,
11414            };
11415            let agent_id = storage.ensure_agent(&agent)?;
11416            let conversation = Conversation {
11417                id: None,
11418                agent_slug: "codex".into(),
11419                workspace: Some(PathBuf::from("/tmp/workspace")),
11420                external_id: Some("stale-gen-fts".into()),
11421                title: Some("Stale FTS generation".into()),
11422                source_path: PathBuf::from("/tmp/stale-gen-fts.jsonl"),
11423                started_at: Some(1_700_000_000_000),
11424                ended_at: Some(1_700_000_000_100),
11425                approx_tokens: Some(42),
11426                metadata_json: serde_json::Value::Null,
11427                messages: vec![Message {
11428                    id: None,
11429                    idx: 0,
11430                    role: MessageRole::User,
11431                    author: Some("user".into()),
11432                    created_at: Some(1_700_000_000_050),
11433                    content: "message that should remain queryable".into(),
11434                    extra_json: serde_json::Value::Null,
11435                    snippets: Vec::new(),
11436                }],
11437                source_id: "local".into(),
11438                origin_host: None,
11439            };
11440            storage.insert_conversation_tree(agent_id, None, &conversation)?;
11441        }
11442
11443        let count_before = sqlite_master_name_count(&db_path, "fts_messages")
11444            .context("count schema rows before generation key deletion")?;
11445
11446        // Simulate a stale generation by deleting the rebuild marker.
11447        // This is the condition ensure_fts_consistency_via_frankensqlite
11448        // detects to trigger a full FTS rebuild.
11449        {
11450            let conn = FrankenConnection::open(db_path.to_string_lossy().into_owned())?;
11451            conn.execute_compat(
11452                "DELETE FROM meta WHERE key = ?1",
11453                &[ParamValue::from("fts_frankensqlite_rebuild_generation")],
11454            )?;
11455        }
11456
11457        // Opening via sqlite_guard() must remain read-only. A search path
11458        // should not trigger heavyweight derived-index repair.
11459        let client = SearchClient {
11460            reader: None,
11461            sqlite: Mutex::new(None),
11462            sqlite_path: Some(db_path.clone()),
11463            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
11464            reload_on_search: true,
11465            last_reload: Mutex::new(None),
11466            last_generation: Mutex::new(None),
11467            reload_epoch: Arc::new(AtomicU64::new(0)),
11468            warm_tx: None,
11469            _warm_handle: None,
11470            metrics: Metrics::default(),
11471            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
11472            semantic: Mutex::new(None),
11473            last_tantivy_total_count: Mutex::new(None),
11474        };
11475
11476        let guard = client
11477            .sqlite_guard()
11478            .context("open sqlite guard for stale generation fixture")?;
11479        assert!(guard.is_some(), "sqlite guard should open the db");
11480        let conn = guard
11481            .as_ref()
11482            .expect("sqlite guard should hold a connection");
11483        let no_params: [ParamValue; 0] = [];
11484        let cache_size: i64 =
11485            conn.query_row_map("PRAGMA cache_size;", &no_params, |row| row.get_typed(0))?;
11486        assert_eq!(
11487            cache_size, -SEARCH_SQLITE_HYDRATION_CACHE_KIB,
11488            "search hydration should not inherit the general storage cache profile"
11489        );
11490        drop(guard);
11491
11492        // The read-only open must not rewrite the rebuild-generation marker.
11493        let conn = FrankenConnection::open(db_path.to_string_lossy().into_owned())?;
11494        let generation_after: Option<String> = conn
11495            .query_row_map(
11496                "SELECT value FROM meta WHERE key = ?1",
11497                &[ParamValue::from("fts_frankensqlite_rebuild_generation")],
11498                |row| row.get_typed(0),
11499            )
11500            .optional()?;
11501        assert!(
11502            generation_after.is_none(),
11503            "search sqlite guard must not mutate FTS rebuild metadata"
11504        );
11505
11506        // Schema rows remain unchanged by the read-only open.
11507        let count_after = sqlite_master_name_count(&db_path, "fts_messages")
11508            .context("count schema rows after sqlite guard reopen")?;
11509        assert_eq!(
11510            count_after, count_before,
11511            "read-only reopen must leave FTS schema state unchanged"
11512        );
11513
11514        Ok(())
11515    }
11516
11517    #[test]
11518    fn sqlite_path_rusqlite_fallback_matches_hyphenated_ids_with_workspace_filter() -> Result<()> {
11519        fn fts_match_count(conn: &FrankenConnection, fts_query: &str) -> Result<Option<usize>> {
11520            let match_mode = SearchClient::sqlite_fts_match_mode(conn)?;
11521            let sql = format!(
11522                "SELECT COUNT(*) FROM fts_messages WHERE {}",
11523                SearchClient::sqlite_fts5_match_clause(match_mode)
11524            );
11525            let mut params = Vec::new();
11526            SearchClient::push_sqlite_fts5_match_params(&mut params, fts_query, match_mode);
11527            match franken_query_map_collect_retry(conn, &sql, &params, |row| row.get_typed(0)) {
11528                Ok(rows) => {
11529                    let count: i64 = rows.into_iter().next().unwrap_or(0);
11530                    Ok(Some(usize::try_from(count.max(0)).unwrap_or(usize::MAX)))
11531                }
11532                Err(err) if err.to_string().contains("no such function: MATCH/2") => Ok(None),
11533                Err(err) => Err(err.into()),
11534            }
11535        }
11536
11537        let temp_dir = TempDir::new()?;
11538        let db_path = temp_dir.path().join("hyphenated-rusqlite-fallback.db");
11539
11540        {
11541            let storage = FrankenStorage::open(&db_path)?;
11542            // V14 drops fts_messages during migration — run the lazy repair
11543            // so the direct INSERT INTO fts_messages below can land.
11544            storage.ensure_search_fallback_fts_consistency()?;
11545            let conn = storage.raw();
11546            conn.execute(
11547                "INSERT INTO agents(id, slug, name, kind, created_at, updated_at)
11548                 VALUES(1, 'codex', 'Codex', 'codex', 1, 1)",
11549            )?;
11550            conn.execute("INSERT INTO workspaces(id, path) VALUES(1, '/ws/alpha')")?;
11551            conn.execute("INSERT INTO workspaces(id, path) VALUES(2, '/ws/beta')")?;
11552            conn.execute(
11553                "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
11554                 VALUES(1, 1, 1, 'local', NULL, 'alpha bead', '/tmp/alpha.jsonl')",
11555            )?;
11556            conn.execute(
11557                "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
11558                 VALUES(2, 1, 2, 'local', NULL, 'beta bead', '/tmp/beta.jsonl')",
11559            )?;
11560            conn.execute(
11561                "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
11562                 VALUES(11, 1, 0, 'user', 'Need follow-up on br-123 root cause', 100)",
11563            )?;
11564            conn.execute(
11565                "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
11566                 VALUES(12, 2, 0, 'user', 'Need follow-up on br-123 user report', 101)",
11567            )?;
11568            conn.execute_compat(
11569                "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at)
11570                 VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)",
11571                &[
11572                    ParamValue::from(11_i64),
11573                    ParamValue::from("Need follow-up on br-123 root cause"),
11574                    ParamValue::from("alpha bead"),
11575                    ParamValue::from("codex"),
11576                    ParamValue::from("/ws/alpha"),
11577                    ParamValue::from("/tmp/alpha.jsonl"),
11578                    ParamValue::from(100_i64),
11579                ],
11580            )?;
11581            conn.execute_compat(
11582                "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at)
11583                 VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)",
11584                &[
11585                    ParamValue::from(12_i64),
11586                    ParamValue::from("Need follow-up on br-123 user report"),
11587                    ParamValue::from("beta bead"),
11588                    ParamValue::from("codex"),
11589                    ParamValue::from("/ws/beta"),
11590                    ParamValue::from("/tmp/beta.jsonl"),
11591                    ParamValue::from(101_i64),
11592                ],
11593            )?;
11594            let preclose_total_rows: i64 =
11595                conn.query_row_map("SELECT COUNT(*) FROM fts_messages", params![], |row| {
11596                    row.get_typed(0)
11597                })?;
11598            assert_eq!(
11599                preclose_total_rows, 2,
11600                "freshly seeded file-backed FTS should retain the inserted rows"
11601            );
11602            let transpiled = transpile_to_fts5("br-123").expect("transpiled fallback query");
11603            if let Some(match_count) = fts_match_count(conn, transpiled.as_str())? {
11604                assert_eq!(
11605                    match_count, 2,
11606                    "freshly seeded file-backed FTS should match the transpiled hyphenated query before reopen"
11607                );
11608            }
11609        }
11610
11611        let client = SearchClient {
11612            reader: None,
11613            sqlite: Mutex::new(None),
11614            sqlite_path: Some(db_path),
11615            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
11616            reload_on_search: true,
11617            last_reload: Mutex::new(None),
11618            last_generation: Mutex::new(None),
11619            reload_epoch: Arc::new(AtomicU64::new(0)),
11620            warm_tx: None,
11621            _warm_handle: None,
11622            metrics: Metrics::default(),
11623            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
11624            semantic: Mutex::new(None),
11625            last_tantivy_total_count: Mutex::new(None),
11626        };
11627
11628        let guard = client.sqlite_guard()?;
11629        let conn = guard.as_ref().expect("sqlite guard should reopen file db");
11630        let reopened_total_rows: i64 =
11631            conn.query_row_map("SELECT COUNT(*) FROM fts_messages", params![], |row| {
11632                row.get_typed(0)
11633            })?;
11634        assert_eq!(
11635            reopened_total_rows, 2,
11636            "reopened file-backed FTS should still contain the seeded rows"
11637        );
11638        let transpiled = transpile_to_fts5("br-123").expect("transpiled fallback query");
11639        if let Some(match_count) = fts_match_count(conn, transpiled.as_str())? {
11640            assert_eq!(
11641                match_count, 2,
11642                "reopened file-backed FTS should still match the transpiled hyphenated query"
11643            );
11644        }
11645        drop(guard);
11646
11647        let all_hits = client.search("br-123", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
11648        assert_eq!(all_hits.len(), 2);
11649        assert!(
11650            all_hits.iter().all(|hit| hit.content.contains("br-123")),
11651            "hyphenated bead IDs should survive the file-backed sqlite fallback path"
11652        );
11653
11654        let leading_or_hits = client.search(
11655            "OR br-123",
11656            SearchFilters::default(),
11657            10,
11658            0,
11659            FieldMask::FULL,
11660        )?;
11661        assert_eq!(leading_or_hits.len(), 2);
11662
11663        let dotted_hits = client.search(
11664            "br-123.jsonl",
11665            SearchFilters::default(),
11666            10,
11667            0,
11668            FieldMask::FULL,
11669        )?;
11670        assert_eq!(dotted_hits.len(), 2);
11671
11672        let dotted_prefix_hits = client.search(
11673            "br-123.json*",
11674            SearchFilters::default(),
11675            10,
11676            0,
11677            FieldMask::FULL,
11678        )?;
11679        assert_eq!(dotted_prefix_hits.len(), 2);
11680
11681        let prefix_hits =
11682            client.search("br-12*", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
11683        assert_eq!(prefix_hits.len(), 2);
11684
11685        let filtered_hits = client.search(
11686            "br-123",
11687            SearchFilters {
11688                workspaces: HashSet::from_iter(["/ws/beta".to_string()]),
11689                ..SearchFilters::default()
11690            },
11691            10,
11692            0,
11693            FieldMask::FULL,
11694        )?;
11695        assert_eq!(filtered_hits.len(), 1);
11696        assert_eq!(filtered_hits[0].workspace, "/ws/beta");
11697        assert_eq!(filtered_hits[0].source_path, "/tmp/beta.jsonl");
11698        assert!(filtered_hits[0].content.contains("br-123"));
11699
11700        Ok(())
11701    }
11702
11703    #[test]
11704    fn sqlite_backend_orders_hits_by_bm25_score() -> Result<()> {
11705        let conn = Connection::open(":memory:")?;
11706        conn.execute_batch(
11707            "CREATE TABLE conversations (
11708                id INTEGER PRIMARY KEY,
11709                agent_id INTEGER,
11710                workspace_id INTEGER,
11711                source_id TEXT,
11712                origin_host TEXT,
11713                title TEXT,
11714                source_path TEXT
11715             );
11716             CREATE TABLE messages (
11717                id INTEGER PRIMARY KEY,
11718                conversation_id INTEGER,
11719                idx INTEGER,
11720                content TEXT,
11721                created_at INTEGER
11722             );
11723             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
11724             CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL UNIQUE);
11725             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE);
11726             CREATE VIRTUAL TABLE fts_messages USING fts5(
11727                content,
11728                title,
11729                agent,
11730                workspace,
11731                source_path,
11732                created_at UNINDEXED,
11733                content='',
11734                tokenize='porter'
11735             );",
11736        )?;
11737        conn.execute("INSERT INTO sources(id, kind) VALUES('local', 'local')")?;
11738        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
11739        conn.execute("INSERT INTO workspaces(id, path) VALUES(1, '/ws')")?;
11740        conn.execute(
11741            "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')",
11742        )?;
11743        conn.execute(
11744            "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')",
11745        )?;
11746        conn.execute("INSERT INTO messages(id, conversation_id, idx, content, created_at) VALUES(7, 1, 0, 'auth auth auth failure', 42)")?;
11747        conn.execute("INSERT INTO messages(id, conversation_id, idx, content, created_at) VALUES(8, 2, 0, 'auth failure', 43)")?;
11748        conn.execute_compat(
11749            "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at)
11750             VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)",
11751            params![
11752                7_i64,
11753                "auth auth auth failure",
11754                "best",
11755                "codex",
11756                "/ws",
11757                "/tmp/best.jsonl",
11758                42_i64
11759            ],
11760        )?;
11761        conn.execute_compat(
11762            "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at)
11763             VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)",
11764            params![
11765                8_i64,
11766                "auth failure",
11767                "worse",
11768                "codex",
11769                "/ws",
11770                "/tmp/worse.jsonl",
11771                43_i64
11772            ],
11773        )?;
11774        let client = SearchClient {
11775            reader: None,
11776            sqlite: Mutex::new(Some(SendConnection(conn))),
11777            sqlite_path: None,
11778            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
11779            reload_on_search: true,
11780            last_reload: Mutex::new(None),
11781            last_generation: Mutex::new(None),
11782            reload_epoch: Arc::new(AtomicU64::new(0)),
11783            warm_tx: None,
11784            _warm_handle: None,
11785            metrics: Metrics::default(),
11786            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
11787            semantic: Mutex::new(None),
11788            last_tantivy_total_count: Mutex::new(None),
11789        };
11790        let direct_hits = client.search_sqlite_fts5(
11791            Path::new(":memory:"),
11792            "auth",
11793            SearchFilters::default(),
11794            5,
11795            0,
11796            FieldMask::FULL,
11797        )?;
11798        assert_eq!(direct_hits.len(), 2);
11799
11800        let hits = client.search("auth", SearchFilters::default(), 5, 0, FieldMask::FULL)?;
11801        assert_eq!(hits.len(), 2);
11802        assert_eq!(hits[0].title, "best");
11803        assert_eq!(hits[1].title, "worse");
11804        assert!(hits[0].score > hits[1].score);
11805
11806        Ok(())
11807    }
11808
11809    #[test]
11810    fn sqlite_fts5_ranked_phase_defers_content_decode_until_after_limit() {
11811        let (rank_sql, params) = SearchClient::sqlite_fts5_rank_query(
11812            "auth",
11813            &SearchFilters::default(),
11814            50,
11815            0,
11816            false,
11817            SqliteFtsMatchMode::Table,
11818        );
11819        let hydrate_sql = SearchClient::sqlite_fts5_hydrate_query(
11820            2,
11821            FieldMask::new(true, true, true, true),
11822            false,
11823        );
11824
11825        assert!(
11826            !rank_sql.contains("fts_messages.content"),
11827            "rank query must not decode large content rows before LIMIT"
11828        );
11829        assert!(
11830            hydrate_sql.contains("fts_messages.content"),
11831            "hydration query should still provide requested content"
11832        );
11833        assert!(
11834            rank_sql.contains("LIMIT ? OFFSET ?"),
11835            "rank query must apply page bounds before hydration"
11836        );
11837        assert_eq!(params.len(), 3, "fts query plus limit and offset params");
11838    }
11839
11840    #[test]
11841    fn sqlite_fts5_hydration_chunks_stay_below_bind_variable_limit() {
11842        let oversized_row_count = SQLITE_MAX_VARIABLE_NUMBER + 1;
11843        let unchunked_sql = SearchClient::sqlite_fts5_hydrate_query(
11844            oversized_row_count,
11845            FieldMask::new(true, true, true, true),
11846            false,
11847        );
11848        assert!(
11849            unchunked_sql.matches('?').count() > SQLITE_MAX_VARIABLE_NUMBER,
11850            "the pre-fix one-shot hydration query would exceed frankensqlite's bind limit"
11851        );
11852
11853        let ranked_rows: Vec<(i64, f64)> = (0..(SQLITE_FTS5_HYDRATE_PARAM_CHUNK + 17))
11854            .map(|idx| (idx as i64, idx as f64))
11855            .collect();
11856        let chunk_sizes: Vec<usize> = SearchClient::sqlite_fts5_hydrate_row_chunks(&ranked_rows)
11857            .map(<[(i64, f64)]>::len)
11858            .collect();
11859
11860        assert_eq!(
11861            chunk_sizes,
11862            vec![SQLITE_FTS5_HYDRATE_PARAM_CHUNK, 17],
11863            "large fallback pages must hydrate in bounded chunks while preserving rank windows"
11864        );
11865        assert!(
11866            chunk_sizes
11867                .iter()
11868                .all(|chunk_size| *chunk_size <= SQLITE_MAX_VARIABLE_NUMBER),
11869            "every hydration chunk must fit under frankensqlite's bind-variable ceiling"
11870        );
11871    }
11872
11873    #[test]
11874    fn tantivy_fallback_hydration_narrows_by_normalized_source_before_message_lookup() -> Result<()>
11875    {
11876        let conn = Connection::open(":memory:")?;
11877        conn.execute_batch(
11878            "CREATE TABLE conversations (
11879                id INTEGER PRIMARY KEY,
11880                source_id TEXT,
11881                origin_host TEXT,
11882                source_path TEXT NOT NULL
11883             );
11884             CREATE TABLE messages (
11885                id INTEGER PRIMARY KEY,
11886                conversation_id INTEGER NOT NULL,
11887                idx INTEGER NOT NULL,
11888                content TEXT NOT NULL,
11889                UNIQUE(conversation_id, idx)
11890             );
11891             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);",
11892        )?;
11893        conn.execute(
11894            "INSERT INTO conversations(id, source_id, origin_host, source_path)
11895             VALUES(1, '', 'devbox', '/tmp/shared-fallback.jsonl')",
11896        )?;
11897        conn.execute(
11898            "INSERT INTO conversations(id, source_id, origin_host, source_path)
11899             VALUES(2, 'local', NULL, '/tmp/shared-fallback.jsonl')",
11900        )?;
11901        conn.execute(
11902            "INSERT INTO messages(id, conversation_id, idx, content)
11903             VALUES(10, 1, 2, 'remote fallback content')",
11904        )?;
11905        conn.execute(
11906            "INSERT INTO messages(id, conversation_id, idx, content)
11907             VALUES(20, 2, 2, 'local content must not win')",
11908        )?;
11909
11910        let client = SearchClient {
11911            reader: None,
11912            sqlite: Mutex::new(Some(SendConnection(conn))),
11913            sqlite_path: None,
11914            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
11915            reload_on_search: true,
11916            last_reload: Mutex::new(None),
11917            last_generation: Mutex::new(None),
11918            reload_epoch: Arc::new(AtomicU64::new(0)),
11919            warm_tx: None,
11920            _warm_handle: None,
11921            metrics: Metrics::default(),
11922            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
11923            semantic: Mutex::new(None),
11924            last_tantivy_total_count: Mutex::new(None),
11925        };
11926
11927        let fallback_key = (
11928            "devbox".to_string(),
11929            "/tmp/shared-fallback.jsonl".to_string(),
11930            2,
11931        );
11932        let (_, hydrated_fallback) =
11933            client.hydrate_tantivy_hit_contents(&[], std::slice::from_ref(&fallback_key))?;
11934
11935        assert_eq!(
11936            hydrated_fallback.get(&fallback_key).map(String::as_str),
11937            Some("remote fallback content")
11938        );
11939
11940        Ok(())
11941    }
11942
11943    #[test]
11944    fn exact_content_hydration_returns_only_requested_message_indices() -> Result<()> {
11945        let conn = Connection::open(":memory:")?;
11946        conn.execute_batch(
11947            "CREATE TABLE messages (
11948                id INTEGER PRIMARY KEY,
11949                conversation_id INTEGER NOT NULL,
11950                idx INTEGER NOT NULL,
11951                content TEXT NOT NULL,
11952                UNIQUE(conversation_id, idx)
11953             );",
11954        )?;
11955
11956        for idx in 0..8 {
11957            conn.execute(&format!(
11958                "INSERT INTO messages(conversation_id, idx, content)
11959                 VALUES(1, {idx}, 'conversation one row {idx}')"
11960            ))?;
11961        }
11962        conn.execute(
11963            "INSERT INTO messages(conversation_id, idx, content)
11964             VALUES(2, 0, 'conversation two row 0')",
11965        )?;
11966
11967        let hydrated =
11968            hydrate_message_content_by_conversation(&conn, &[(1, 6), (1, 2), (2, 0), (1, 99)])?;
11969
11970        assert_eq!(hydrated.len(), 3);
11971        assert_eq!(
11972            hydrated.get(&(1, 2)).map(String::as_str),
11973            Some("conversation one row 2")
11974        );
11975        assert_eq!(
11976            hydrated.get(&(1, 6)).map(String::as_str),
11977            Some("conversation one row 6")
11978        );
11979        assert_eq!(
11980            hydrated.get(&(2, 0)).map(String::as_str),
11981            Some("conversation two row 0")
11982        );
11983        assert!(!hydrated.contains_key(&(1, 99)));
11984
11985        Ok(())
11986    }
11987
11988    #[test]
11989    fn sqlite_backend_generates_snippet_from_content() -> Result<()> {
11990        let conn = Connection::open(":memory:")?;
11991        conn.execute_batch(
11992            "CREATE TABLE conversations (
11993                id INTEGER PRIMARY KEY,
11994                agent_id INTEGER,
11995                workspace_id INTEGER,
11996                source_id TEXT,
11997                origin_host TEXT,
11998                title TEXT,
11999                source_path TEXT
12000             );
12001             CREATE TABLE messages (
12002                id INTEGER PRIMARY KEY,
12003                conversation_id INTEGER,
12004                idx INTEGER,
12005                content TEXT,
12006                created_at INTEGER
12007             );
12008             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
12009             CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL UNIQUE);
12010             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE);
12011             CREATE VIRTUAL TABLE fts_messages USING fts5(
12012                content,
12013                title,
12014                agent,
12015                workspace,
12016                source_path,
12017                created_at UNINDEXED,
12018                content='',
12019                tokenize='porter'
12020             );",
12021        )?;
12022        conn.execute("INSERT INTO sources(id, kind) VALUES('local', 'local')")?;
12023        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
12024        conn.execute("INSERT INTO workspaces(id, path) VALUES(1, '/ws')")?;
12025        conn.execute(
12026            "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')",
12027        )?;
12028        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)")?;
12029        conn.execute_compat(
12030            "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at)
12031             VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)",
12032            params![
12033                1_i64,
12034                "alpha beta gamma delta epsilon zeta eta theta",
12035                "snippet title",
12036                "codex",
12037                "/ws",
12038                "/tmp/snippet.jsonl",
12039                42_i64
12040            ],
12041        )?;
12042
12043        let client = SearchClient {
12044            reader: None,
12045            sqlite: Mutex::new(Some(SendConnection(conn))),
12046            sqlite_path: None,
12047            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
12048            reload_on_search: true,
12049            last_reload: Mutex::new(None),
12050            last_generation: Mutex::new(None),
12051            reload_epoch: Arc::new(AtomicU64::new(0)),
12052            warm_tx: None,
12053            _warm_handle: None,
12054            metrics: Metrics::default(),
12055            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
12056            semantic: Mutex::new(None),
12057            last_tantivy_total_count: Mutex::new(None),
12058        };
12059
12060        let hits = client.search("delta", SearchFilters::default(), 5, 0, FieldMask::FULL)?;
12061        assert_eq!(hits.len(), 1);
12062        // With contentless FTS5, snippet is generated from content via snippet_from_content()
12063        assert_eq!(hits[0].snippet, snippet_from_content(&hits[0].content));
12064        assert!(hits[0].snippet.contains("delta"));
12065
12066        Ok(())
12067    }
12068
12069    #[test]
12070    fn sqlite_backend_respects_source_filter() -> Result<()> {
12071        let conn = Connection::open(":memory:")?;
12072        conn.execute_batch(
12073            "CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
12074             CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL UNIQUE);
12075             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE);
12076             CREATE TABLE conversations (
12077                id INTEGER PRIMARY KEY,
12078                agent_id INTEGER,
12079                workspace_id INTEGER,
12080                source_id TEXT,
12081                origin_host TEXT,
12082                title TEXT,
12083                source_path TEXT
12084             );
12085             CREATE TABLE messages (
12086                id INTEGER PRIMARY KEY,
12087                conversation_id INTEGER,
12088                idx INTEGER,
12089                content TEXT,
12090                created_at INTEGER
12091             );
12092             CREATE VIRTUAL TABLE fts_messages USING fts5(
12093                content,
12094                title,
12095                agent,
12096                workspace,
12097                source_path,
12098                created_at UNINDEXED,
12099                content='',
12100                tokenize='porter'
12101             );",
12102        )?;
12103        conn.execute("INSERT INTO sources(id, kind) VALUES('local', 'local')")?;
12104        conn.execute("INSERT INTO sources(id, kind) VALUES('laptop', 'ssh')")?;
12105        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
12106        conn.execute("INSERT INTO workspaces(id, path) VALUES(1, '/local')")?;
12107        conn.execute("INSERT INTO workspaces(id, path) VALUES(2, '/remote')")?;
12108        conn.execute(
12109            "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')",
12110        )?;
12111        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')")?;
12112        conn.execute("INSERT INTO messages(id, conversation_id, idx, content, created_at) VALUES(1, 1, 0, 'auth token failure', 42)")?;
12113        conn.execute("INSERT INTO messages(id, conversation_id, idx, content, created_at) VALUES(2, 2, 0, 'auth token failure', 43)")?;
12114        conn.execute_compat(
12115            "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at)
12116             VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)",
12117            params![
12118                1_i64,
12119                "auth token failure",
12120                "local title",
12121                "codex",
12122                "/local",
12123                "/tmp/local.jsonl",
12124                42_i64
12125            ],
12126        )?;
12127        conn.execute_compat(
12128            "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at)
12129             VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)",
12130            params![
12131                2_i64,
12132                "auth token failure",
12133                "remote title",
12134                "codex",
12135                "/remote",
12136                "/tmp/remote.jsonl",
12137                43_i64
12138            ],
12139        )?;
12140
12141        let client = SearchClient {
12142            reader: None,
12143            sqlite: Mutex::new(Some(SendConnection(conn))),
12144            sqlite_path: None,
12145            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
12146            reload_on_search: true,
12147            last_reload: Mutex::new(None),
12148            last_generation: Mutex::new(None),
12149            reload_epoch: Arc::new(AtomicU64::new(0)),
12150            warm_tx: None,
12151            _warm_handle: None,
12152            metrics: Metrics::default(),
12153            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
12154            semantic: Mutex::new(None),
12155            last_tantivy_total_count: Mutex::new(None),
12156        };
12157
12158        let local_hits = client.browse_by_date(
12159            SearchFilters {
12160                source_filter: SourceFilter::Local,
12161                ..SearchFilters::default()
12162            },
12163            5,
12164            0,
12165            true,
12166            FieldMask::FULL,
12167        )?;
12168        assert_eq!(local_hits.len(), 1);
12169        assert_eq!(local_hits[0].source_id, "local");
12170
12171        let remote_hits = client.browse_by_date(
12172            SearchFilters {
12173                source_filter: SourceFilter::SourceId("  LOCAL  ".to_string()),
12174                ..SearchFilters::default()
12175            },
12176            5,
12177            0,
12178            true,
12179            FieldMask::FULL,
12180        )?;
12181        assert_eq!(remote_hits.len(), 1);
12182        assert_eq!(remote_hits[0].source_id, "local");
12183        assert_eq!(remote_hits[0].origin_kind, "local");
12184
12185        Ok(())
12186    }
12187
12188    #[test]
12189    fn sqlite_backend_remote_source_filter_matches_blank_source_id_with_origin_host() -> Result<()>
12190    {
12191        let conn = Connection::open(":memory:")?;
12192        conn.execute_batch(
12193            "CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
12194             CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL UNIQUE);
12195             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE);
12196             CREATE TABLE conversations (
12197                id INTEGER PRIMARY KEY,
12198                agent_id INTEGER,
12199                workspace_id INTEGER,
12200                source_id TEXT,
12201                origin_host TEXT,
12202                title TEXT,
12203                source_path TEXT
12204             );
12205             CREATE TABLE messages (
12206                id INTEGER PRIMARY KEY,
12207                conversation_id INTEGER,
12208                idx INTEGER,
12209                content TEXT,
12210                created_at INTEGER
12211             );
12212             CREATE VIRTUAL TABLE fts_messages USING fts5(
12213                content,
12214                title,
12215                agent,
12216                workspace,
12217                source_path,
12218                created_at UNINDEXED,
12219                content='',
12220                tokenize='porter'
12221             );",
12222        )?;
12223        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
12224        conn.execute(
12225            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
12226             VALUES(1, 1, NULL, '   ', 'dev@laptop', 'remote title', '/tmp/remote-filter.jsonl')",
12227        )?;
12228        conn.execute(
12229            "INSERT INTO messages(id, conversation_id, idx, content, created_at)
12230             VALUES(1, 1, 0, 'remote filter proof', 42)",
12231        )?;
12232        conn.execute_compat(
12233            "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at)
12234             VALUES(?1, ?2, ?3, ?4, NULL, ?5, ?6)",
12235            params![
12236                1_i64,
12237                "remote filter proof",
12238                "remote title",
12239                "codex",
12240                "/tmp/remote-filter.jsonl",
12241                42_i64
12242            ],
12243        )?;
12244
12245        let client = SearchClient {
12246            reader: None,
12247            sqlite: Mutex::new(Some(SendConnection(conn))),
12248            sqlite_path: None,
12249            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
12250            reload_on_search: true,
12251            last_reload: Mutex::new(None),
12252            last_generation: Mutex::new(None),
12253            reload_epoch: Arc::new(AtomicU64::new(0)),
12254            warm_tx: None,
12255            _warm_handle: None,
12256            metrics: Metrics::default(),
12257            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
12258            semantic: Mutex::new(None),
12259            last_tantivy_total_count: Mutex::new(None),
12260        };
12261
12262        let remote_hits = client.search(
12263            "remote",
12264            SearchFilters {
12265                source_filter: SourceFilter::Remote,
12266                ..Default::default()
12267            },
12268            5,
12269            0,
12270            FieldMask::FULL,
12271        )?;
12272        assert_eq!(remote_hits.len(), 1);
12273        assert_eq!(remote_hits[0].source_id, "dev@laptop");
12274        assert_eq!(remote_hits[0].origin_kind, "remote");
12275        assert_eq!(remote_hits[0].origin_host.as_deref(), Some("dev@laptop"));
12276
12277        let source_hits = client.search(
12278            "remote",
12279            SearchFilters {
12280                source_filter: SourceFilter::SourceId("dev@laptop".into()),
12281                ..Default::default()
12282            },
12283            5,
12284            0,
12285            FieldMask::FULL,
12286        )?;
12287        assert_eq!(source_hits.len(), 1);
12288        assert_eq!(source_hits[0].source_id, "dev@laptop");
12289        assert_eq!(source_hits[0].origin_kind, "remote");
12290
12291        Ok(())
12292    }
12293
12294    #[test]
12295    fn sqlite_backend_workspace_filter_matches_null_workspace_as_empty_string() -> Result<()> {
12296        let conn = Connection::open(":memory:")?;
12297        conn.execute_batch(
12298            "CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
12299             CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL UNIQUE);
12300             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE);
12301             CREATE TABLE conversations (
12302                id INTEGER PRIMARY KEY,
12303                agent_id INTEGER,
12304                workspace_id INTEGER,
12305                source_id TEXT,
12306                origin_host TEXT,
12307                title TEXT,
12308                source_path TEXT
12309             );
12310             CREATE TABLE messages (
12311                id INTEGER PRIMARY KEY,
12312                conversation_id INTEGER,
12313                idx INTEGER,
12314                content TEXT,
12315                created_at INTEGER
12316             );
12317             CREATE VIRTUAL TABLE fts_messages USING fts5(
12318                content,
12319                title,
12320                agent,
12321                workspace,
12322                source_path,
12323                created_at UNINDEXED,
12324                content='',
12325                tokenize='porter'
12326             );",
12327        )?;
12328        conn.execute("INSERT INTO sources(id, kind) VALUES('local', 'local')")?;
12329        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
12330        conn.execute("INSERT INTO workspaces(id, path) VALUES(1, '/named')")?;
12331        // Conversation 1: no workspace (workspace_id=NULL)
12332        conn.execute(
12333            "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')",
12334        )?;
12335        // Conversation 2: with workspace
12336        conn.execute(
12337            "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')",
12338        )?;
12339        conn.execute("INSERT INTO messages(id, conversation_id, idx, content, created_at) VALUES(1, 1, 0, 'auth token failure', 42)")?;
12340        conn.execute("INSERT INTO messages(id, conversation_id, idx, content, created_at) VALUES(2, 2, 0, 'auth token failure', 43)")?;
12341        conn.execute_compat(
12342            "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at)
12343             VALUES(?1, ?2, ?3, ?4, NULL, ?5, ?6)",
12344            params![
12345                1_i64,
12346                "auth token failure",
12347                "null workspace",
12348                "codex",
12349                "/tmp/null-workspace.jsonl",
12350                42_i64
12351            ],
12352        )?;
12353        conn.execute_compat(
12354            "INSERT INTO fts_messages(rowid, content, title, agent, workspace, source_path, created_at)
12355             VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)",
12356            params![
12357                2_i64,
12358                "auth token failure",
12359                "named workspace",
12360                "codex",
12361                "/named",
12362                "/tmp/named-workspace.jsonl",
12363                43_i64
12364            ],
12365        )?;
12366
12367        let client = SearchClient {
12368            reader: None,
12369            sqlite: Mutex::new(Some(SendConnection(conn))),
12370            sqlite_path: None,
12371            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
12372            reload_on_search: true,
12373            last_reload: Mutex::new(None),
12374            last_generation: Mutex::new(None),
12375            reload_epoch: Arc::new(AtomicU64::new(0)),
12376            warm_tx: None,
12377            _warm_handle: None,
12378            metrics: Metrics::default(),
12379            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
12380            semantic: Mutex::new(None),
12381            last_tantivy_total_count: Mutex::new(None),
12382        };
12383
12384        let hits = client.search(
12385            "auth",
12386            SearchFilters {
12387                workspaces: HashSet::from_iter([String::new()]),
12388                ..SearchFilters::default()
12389            },
12390            5,
12391            0,
12392            FieldMask::FULL,
12393        )?;
12394        assert_eq!(hits.len(), 1);
12395        assert_eq!(hits[0].workspace, "");
12396        assert_eq!(hits[0].source_path, "/tmp/null-workspace.jsonl");
12397
12398        Ok(())
12399    }
12400
12401    #[test]
12402    fn sqlite_message_scan_preserves_boolean_or_precedence() {
12403        let simple_or =
12404            SearchClient::sqlite_message_scan_query("alpha OR beta").expect("simple OR scan query");
12405        assert!(SearchClient::sqlite_message_scan_score("alpha", &simple_or) > 0.0);
12406        assert!(SearchClient::sqlite_message_scan_score("beta", &simple_or) > 0.0);
12407        assert_eq!(
12408            SearchClient::sqlite_message_scan_score("gamma", &simple_or),
12409            0.0
12410        );
12411
12412        let and_then_or = SearchClient::sqlite_message_scan_query("alpha AND beta OR gamma")
12413            .expect("AND followed by OR scan query");
12414        assert!(
12415            SearchClient::sqlite_message_scan_score("alpha gamma", &and_then_or) > 0.0,
12416            "alpha AND (beta OR gamma) should accept the gamma branch"
12417        );
12418        assert_eq!(
12419            SearchClient::sqlite_message_scan_score("alpha", &and_then_or),
12420            0.0
12421        );
12422        assert_eq!(
12423            SearchClient::sqlite_message_scan_score("beta gamma", &and_then_or),
12424            0.0
12425        );
12426
12427        let or_then_and = SearchClient::sqlite_message_scan_query("alpha OR beta AND gamma")
12428            .expect("OR followed by AND scan query");
12429        assert!(
12430            SearchClient::sqlite_message_scan_score("alpha gamma", &or_then_and) > 0.0,
12431            "(alpha OR beta) AND gamma should accept the alpha branch"
12432        );
12433        assert!(
12434            SearchClient::sqlite_message_scan_score("beta gamma", &or_then_and) > 0.0,
12435            "(alpha OR beta) AND gamma should accept the beta branch"
12436        );
12437        assert_eq!(
12438            SearchClient::sqlite_message_scan_score("alpha", &or_then_and),
12439            0.0
12440        );
12441
12442        let binary_not =
12443            SearchClient::sqlite_message_scan_query("alpha NOT beta").expect("NOT scan query");
12444        assert!(SearchClient::sqlite_message_scan_score("alpha", &binary_not) > 0.0);
12445        assert_eq!(
12446            SearchClient::sqlite_message_scan_score("alpha beta", &binary_not),
12447            0.0
12448        );
12449    }
12450
12451    #[test]
12452    fn browse_by_date_treats_null_workspace_and_source_as_local() -> Result<()> {
12453        let conn = Connection::open(":memory:")?;
12454        conn.execute_batch(
12455            "CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL);
12456             CREATE TABLE conversations (
12457                id INTEGER PRIMARY KEY,
12458                agent_id INTEGER NOT NULL,
12459                workspace_id INTEGER,
12460                source_id TEXT,
12461                origin_host TEXT,
12462                title TEXT,
12463                source_path TEXT NOT NULL
12464             );
12465             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL);
12466             CREATE TABLE messages (
12467                id INTEGER PRIMARY KEY,
12468                conversation_id INTEGER NOT NULL,
12469                idx INTEGER,
12470                content TEXT NOT NULL,
12471                created_at INTEGER
12472             );
12473             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);",
12474        )?;
12475        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
12476        conn.execute(
12477            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
12478             VALUES(1, 1, NULL, NULL, NULL, 'browse title', '/tmp/browse.jsonl')",
12479        )?;
12480        conn.execute(
12481            "INSERT INTO messages(id, conversation_id, idx, content, created_at)
12482             VALUES(1, 1, 0, 'browse auth token failure', 123)",
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.browse_by_date(
12503            SearchFilters {
12504                workspaces: HashSet::from_iter([String::new()]),
12505                source_filter: SourceFilter::Local,
12506                ..SearchFilters::default()
12507            },
12508            5,
12509            0,
12510            true,
12511            FieldMask::FULL,
12512        )?;
12513        assert_eq!(hits.len(), 1);
12514        assert_eq!(hits[0].workspace, "");
12515        assert_eq!(hits[0].source_id, "local");
12516        assert_eq!(hits[0].origin_kind, "local");
12517
12518        Ok(())
12519    }
12520
12521    #[test]
12522    fn hydrate_semantic_hits_with_ids_snippet_only_uses_full_content_for_snippets_and_identity()
12523    -> Result<()> {
12524        let conn = Connection::open(":memory:")?;
12525        conn.execute_batch(
12526            "CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL);
12527             CREATE TABLE conversations (
12528                id INTEGER PRIMARY KEY,
12529                agent_id INTEGER NOT NULL,
12530                workspace_id INTEGER,
12531                source_id TEXT,
12532                origin_host TEXT,
12533                title TEXT,
12534                source_path TEXT NOT NULL,
12535                started_at INTEGER
12536             );
12537             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL);
12538             CREATE TABLE messages (
12539                id INTEGER PRIMARY KEY,
12540                conversation_id INTEGER NOT NULL,
12541                idx INTEGER,
12542                role TEXT,
12543                content TEXT NOT NULL,
12544                created_at INTEGER
12545             );
12546             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);",
12547        )?;
12548        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
12549        conn.execute(
12550            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path, started_at)
12551             VALUES(1, 1, NULL, 'local', NULL, 'semantic title', '/tmp/semantic.jsonl', 100)",
12552        )?;
12553        let shared_prefix = "shared-prefix ".repeat(32);
12554        let first = format!("{shared_prefix}first unique semantic tail");
12555        let second = format!("{shared_prefix}second unique semantic tail");
12556        conn.execute_with_params(
12557            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
12558             VALUES(?1, 1, ?2, 'assistant', ?3, ?4)",
12559            &[
12560                fsqlite_types::value::SqliteValue::Integer(1),
12561                fsqlite_types::value::SqliteValue::Integer(0),
12562                fsqlite_types::value::SqliteValue::Text(first.clone().into()),
12563                fsqlite_types::value::SqliteValue::Integer(101),
12564            ],
12565        )?;
12566        conn.execute_with_params(
12567            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
12568             VALUES(?1, 1, ?2, 'assistant', ?3, ?4)",
12569            &[
12570                fsqlite_types::value::SqliteValue::Integer(2),
12571                fsqlite_types::value::SqliteValue::Integer(1),
12572                fsqlite_types::value::SqliteValue::Text(second.clone().into()),
12573                fsqlite_types::value::SqliteValue::Integer(102),
12574            ],
12575        )?;
12576
12577        let client = SearchClient {
12578            reader: None,
12579            sqlite: Mutex::new(Some(SendConnection(conn))),
12580            sqlite_path: None,
12581            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
12582            reload_on_search: true,
12583            last_reload: Mutex::new(None),
12584            last_generation: Mutex::new(None),
12585            reload_epoch: Arc::new(AtomicU64::new(0)),
12586            warm_tx: None,
12587            _warm_handle: None,
12588            metrics: Metrics::default(),
12589            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
12590            semantic: Mutex::new(None),
12591            last_tantivy_total_count: Mutex::new(None),
12592        };
12593
12594        let hits = client.hydrate_semantic_hits_with_ids(
12595            &[
12596                VectorSearchResult {
12597                    message_id: 1,
12598                    chunk_idx: 0,
12599                    score: 0.9,
12600                },
12601                VectorSearchResult {
12602                    message_id: 2,
12603                    chunk_idx: 0,
12604                    score: 0.8,
12605                },
12606            ],
12607            FieldMask::new(false, true, true, true),
12608        )?;
12609        assert_eq!(hits.len(), 2);
12610        assert!(hits.iter().all(|(_, hit)| hit.content.is_empty()));
12611        assert!(hits.iter().all(|(_, hit)| !hit.snippet.is_empty()));
12612        assert_ne!(hits[0].1.content_hash, hits[1].1.content_hash);
12613
12614        Ok(())
12615    }
12616
12617    #[test]
12618    fn hydrate_semantic_hits_with_ids_normalizes_trimmed_local_source_metadata() -> Result<()> {
12619        let conn = Connection::open(":memory:")?;
12620        conn.execute_batch(
12621            "CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL);
12622             CREATE TABLE conversations (
12623                id INTEGER PRIMARY KEY,
12624                agent_id INTEGER NOT NULL,
12625                workspace_id INTEGER,
12626                source_id TEXT,
12627                origin_host TEXT,
12628                title TEXT,
12629                source_path TEXT NOT NULL,
12630                started_at INTEGER
12631             );
12632             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL);
12633             CREATE TABLE messages (
12634                id INTEGER PRIMARY KEY,
12635                conversation_id INTEGER NOT NULL,
12636                idx INTEGER,
12637                role TEXT,
12638                content TEXT NOT NULL,
12639                created_at INTEGER
12640             );
12641             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);",
12642        )?;
12643        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
12644        conn.execute(
12645            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path, started_at)
12646             VALUES(1, 1, NULL, '  local  ', NULL, 'trimmed local semantic', '/tmp/trimmed-local-semantic.jsonl', 100)",
12647        )?;
12648        conn.execute_with_params(
12649            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
12650             VALUES(?1, 1, 0, 'assistant', ?2, 101)",
12651            &[
12652                fsqlite_types::value::SqliteValue::Integer(1),
12653                fsqlite_types::value::SqliteValue::Text("trimmed local semantic body".into()),
12654            ],
12655        )?;
12656
12657        let client = SearchClient {
12658            reader: None,
12659            sqlite: Mutex::new(Some(SendConnection(conn))),
12660            sqlite_path: None,
12661            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
12662            reload_on_search: true,
12663            last_reload: Mutex::new(None),
12664            last_generation: Mutex::new(None),
12665            reload_epoch: Arc::new(AtomicU64::new(0)),
12666            warm_tx: None,
12667            _warm_handle: None,
12668            metrics: Metrics::default(),
12669            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
12670            semantic: Mutex::new(None),
12671            last_tantivy_total_count: Mutex::new(None),
12672        };
12673
12674        let hits = client.hydrate_semantic_hits_with_ids(
12675            &[VectorSearchResult {
12676                message_id: 1,
12677                chunk_idx: 0,
12678                score: 0.9,
12679            }],
12680            FieldMask::new(false, true, true, true),
12681        )?;
12682        assert_eq!(hits.len(), 1);
12683        assert_eq!(hits[0].1.source_id, "local");
12684        assert_eq!(hits[0].1.origin_kind, "local");
12685
12686        Ok(())
12687    }
12688
12689    #[test]
12690    fn hydrate_semantic_hits_with_ids_preserves_remote_origin_without_source_row() -> Result<()> {
12691        let conn = Connection::open(":memory:")?;
12692        conn.execute_batch(
12693            "CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL);
12694             CREATE TABLE conversations (
12695                id INTEGER PRIMARY KEY,
12696                agent_id INTEGER NOT NULL,
12697                workspace_id INTEGER,
12698                source_id TEXT,
12699                origin_host TEXT,
12700                title TEXT,
12701                source_path TEXT NOT NULL,
12702                started_at INTEGER
12703             );
12704             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL);
12705             CREATE TABLE messages (
12706                id INTEGER PRIMARY KEY,
12707                conversation_id INTEGER NOT NULL,
12708                idx INTEGER,
12709                role TEXT,
12710                content TEXT NOT NULL,
12711                created_at INTEGER
12712             );
12713             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);",
12714        )?;
12715        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
12716        conn.execute(
12717            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path, started_at)
12718             VALUES(1, 1, NULL, 'laptop', 'dev@laptop', 'remote semantic', '/tmp/remote-semantic.jsonl', 100)",
12719        )?;
12720        conn.execute_with_params(
12721            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
12722             VALUES(?1, 1, 0, 'assistant', ?2, 101)",
12723            &[
12724                fsqlite_types::value::SqliteValue::Integer(1),
12725                fsqlite_types::value::SqliteValue::Text("remote semantic body".into()),
12726            ],
12727        )?;
12728
12729        let client = SearchClient {
12730            reader: None,
12731            sqlite: Mutex::new(Some(SendConnection(conn))),
12732            sqlite_path: None,
12733            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
12734            reload_on_search: true,
12735            last_reload: Mutex::new(None),
12736            last_generation: Mutex::new(None),
12737            reload_epoch: Arc::new(AtomicU64::new(0)),
12738            warm_tx: None,
12739            _warm_handle: None,
12740            metrics: Metrics::default(),
12741            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
12742            semantic: Mutex::new(None),
12743            last_tantivy_total_count: Mutex::new(None),
12744        };
12745
12746        let hits = client.hydrate_semantic_hits_with_ids(
12747            &[VectorSearchResult {
12748                message_id: 1,
12749                chunk_idx: 0,
12750                score: 0.9,
12751            }],
12752            FieldMask::new(false, true, true, true),
12753        )?;
12754        assert_eq!(hits.len(), 1);
12755        assert_eq!(hits[0].1.source_id, "laptop");
12756        assert_eq!(hits[0].1.origin_kind, "remote");
12757        assert_eq!(hits[0].1.origin_host.as_deref(), Some("dev@laptop"));
12758
12759        Ok(())
12760    }
12761
12762    #[test]
12763    fn resolve_semantic_doc_ids_for_hits_distinguishes_same_source_path_line_by_content_hash()
12764    -> Result<()> {
12765        let conn = Connection::open(":memory:")?;
12766        conn.execute_batch(
12767            "CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL);
12768             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
12769             CREATE TABLE conversations (
12770                id INTEGER PRIMARY KEY,
12771                agent_id INTEGER NOT NULL,
12772                workspace_id INTEGER,
12773                source_id TEXT,
12774                origin_host TEXT,
12775                title TEXT,
12776                source_path TEXT NOT NULL
12777             );
12778             CREATE TABLE messages (
12779                id INTEGER PRIMARY KEY,
12780                conversation_id INTEGER NOT NULL,
12781                idx INTEGER,
12782                role TEXT,
12783                content TEXT NOT NULL,
12784                created_at INTEGER
12785             );",
12786        )?;
12787        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
12788        conn.execute(
12789            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
12790             VALUES(1, 1, NULL, 'local', NULL, 'Shared Session', '/tmp/progressive-shared.jsonl')",
12791        )?;
12792        conn.execute(
12793            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
12794             VALUES(2, 1, NULL, 'local', NULL, 'Shared Session', '/tmp/progressive-shared.jsonl')",
12795        )?;
12796        let first = "same prefix first tail".to_string();
12797        let second = "same prefix second tail".to_string();
12798        conn.execute_with_params(
12799            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
12800             VALUES(?1, ?2, 0, 'assistant', ?3, 100)",
12801            &[
12802                fsqlite_types::value::SqliteValue::Integer(11),
12803                fsqlite_types::value::SqliteValue::Integer(1),
12804                fsqlite_types::value::SqliteValue::Text(first.clone().into()),
12805            ],
12806        )?;
12807        conn.execute_with_params(
12808            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
12809             VALUES(?1, ?2, 0, 'assistant', ?3, 100)",
12810            &[
12811                fsqlite_types::value::SqliteValue::Integer(22),
12812                fsqlite_types::value::SqliteValue::Integer(2),
12813                fsqlite_types::value::SqliteValue::Text(second.clone().into()),
12814            ],
12815        )?;
12816
12817        let client = SearchClient {
12818            reader: None,
12819            sqlite: Mutex::new(Some(SendConnection(conn))),
12820            sqlite_path: None,
12821            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
12822            reload_on_search: true,
12823            last_reload: Mutex::new(None),
12824            last_generation: Mutex::new(None),
12825            reload_epoch: Arc::new(AtomicU64::new(0)),
12826            warm_tx: None,
12827            _warm_handle: None,
12828            metrics: Metrics::default(),
12829            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
12830            semantic: Mutex::new(None),
12831            last_tantivy_total_count: Mutex::new(None),
12832        };
12833
12834        let first_hit = SearchHit {
12835            title: "Shared Session".into(),
12836            snippet: String::new(),
12837            content: String::new(),
12838            content_hash: stable_hit_hash(
12839                &first,
12840                "/tmp/progressive-shared.jsonl",
12841                Some(1),
12842                Some(100),
12843            ),
12844            score: 0.0,
12845            source_path: "/tmp/progressive-shared.jsonl".into(),
12846            agent: "codex".into(),
12847            workspace: String::new(),
12848            workspace_original: None,
12849            created_at: Some(100),
12850            line_number: Some(1),
12851            match_type: MatchType::Exact,
12852            source_id: "local".into(),
12853            origin_kind: "local".into(),
12854            origin_host: None,
12855            conversation_id: None,
12856        };
12857        let second_hit = SearchHit {
12858            title: "Shared Session".into(),
12859            snippet: String::new(),
12860            content: String::new(),
12861            content_hash: stable_hit_hash(
12862                &second,
12863                "/tmp/progressive-shared.jsonl",
12864                Some(1),
12865                Some(100),
12866            ),
12867            score: 0.0,
12868            source_path: "/tmp/progressive-shared.jsonl".into(),
12869            agent: "codex".into(),
12870            workspace: String::new(),
12871            workspace_original: None,
12872            created_at: Some(100),
12873            line_number: Some(1),
12874            match_type: MatchType::Exact,
12875            source_id: "local".into(),
12876            origin_kind: "local".into(),
12877            origin_host: None,
12878            conversation_id: None,
12879        };
12880
12881        let resolved = client.resolve_semantic_doc_ids_for_hits(&[first_hit, second_hit])?;
12882        assert_eq!(resolved.len(), 2);
12883        assert_eq!(resolved[0].as_ref().map(|hit| hit.message_id), Some(11));
12884        assert_eq!(resolved[1].as_ref().map(|hit| hit.message_id), Some(22));
12885        assert_ne!(
12886            resolved[0].as_ref().map(|hit| hit.doc_id.as_str()),
12887            resolved[1].as_ref().map(|hit| hit.doc_id.as_str())
12888        );
12889
12890        Ok(())
12891    }
12892
12893    #[test]
12894    fn hydrate_semantic_hits_with_ids_keeps_missing_title_empty() -> Result<()> {
12895        let conn = Connection::open(":memory:")?;
12896        conn.execute_batch(
12897            "CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL);
12898             CREATE TABLE conversations (
12899                id INTEGER PRIMARY KEY,
12900                agent_id INTEGER NOT NULL,
12901                workspace_id INTEGER,
12902                source_id TEXT,
12903                origin_host TEXT,
12904                title TEXT,
12905                source_path TEXT NOT NULL,
12906                started_at INTEGER
12907             );
12908             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL);
12909             CREATE TABLE messages (
12910                id INTEGER PRIMARY KEY,
12911                conversation_id INTEGER NOT NULL,
12912                idx INTEGER,
12913                role TEXT,
12914                content TEXT NOT NULL,
12915                created_at INTEGER
12916             );
12917             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);",
12918        )?;
12919        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
12920        conn.execute(
12921            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path, started_at)
12922             VALUES(1, 1, NULL, 'local', NULL, NULL, '/tmp/untitled-semantic.jsonl', 100)",
12923        )?;
12924        conn.execute_with_params(
12925            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
12926             VALUES(?1, 1, 0, 'assistant', ?2, 101)",
12927            &[
12928                fsqlite_types::value::SqliteValue::Integer(1),
12929                fsqlite_types::value::SqliteValue::Text("untitled semantic body".into()),
12930            ],
12931        )?;
12932
12933        let client = SearchClient {
12934            reader: None,
12935            sqlite: Mutex::new(Some(SendConnection(conn))),
12936            sqlite_path: None,
12937            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
12938            reload_on_search: true,
12939            last_reload: Mutex::new(None),
12940            last_generation: Mutex::new(None),
12941            reload_epoch: Arc::new(AtomicU64::new(0)),
12942            warm_tx: None,
12943            _warm_handle: None,
12944            metrics: Metrics::default(),
12945            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
12946            semantic: Mutex::new(None),
12947            last_tantivy_total_count: Mutex::new(None),
12948        };
12949
12950        let hits = client.hydrate_semantic_hits_with_ids(
12951            &[VectorSearchResult {
12952                message_id: 1,
12953                chunk_idx: 0,
12954                score: 0.9,
12955            }],
12956            FieldMask::new(false, true, true, true),
12957        )?;
12958        assert_eq!(hits.len(), 1);
12959        assert_eq!(hits[0].1.title, "");
12960
12961        Ok(())
12962    }
12963
12964    #[test]
12965    fn resolve_semantic_doc_ids_for_hits_prefers_conversation_id_over_ambiguous_provenance()
12966    -> Result<()> {
12967        let conn = Connection::open(":memory:")?;
12968        conn.execute_batch(
12969            "CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL);
12970             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
12971             CREATE TABLE conversations (
12972                id INTEGER PRIMARY KEY,
12973                agent_id INTEGER NOT NULL,
12974                workspace_id INTEGER,
12975                source_id TEXT,
12976                origin_host TEXT,
12977                title TEXT,
12978                source_path TEXT NOT NULL
12979             );
12980             CREATE TABLE messages (
12981                id INTEGER PRIMARY KEY,
12982                conversation_id INTEGER NOT NULL,
12983                idx INTEGER,
12984                role TEXT,
12985                content TEXT NOT NULL,
12986                created_at INTEGER
12987             );",
12988        )?;
12989        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
12990        conn.execute(
12991            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
12992             VALUES(1, 1, NULL, 'local', NULL, 'Shared Session', '/tmp/progressive-conversation-id.jsonl')",
12993        )?;
12994        conn.execute(
12995            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
12996             VALUES(2, 1, NULL, 'local', NULL, 'Shared Session', '/tmp/progressive-conversation-id.jsonl')",
12997        )?;
12998        let content = "same ambiguous content".to_string();
12999        conn.execute_with_params(
13000            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
13001             VALUES(?1, ?2, 0, 'assistant', ?3, 100)",
13002            &[
13003                fsqlite_types::value::SqliteValue::Integer(11),
13004                fsqlite_types::value::SqliteValue::Integer(1),
13005                fsqlite_types::value::SqliteValue::Text(content.clone().into()),
13006            ],
13007        )?;
13008        conn.execute_with_params(
13009            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
13010             VALUES(?1, ?2, 0, 'assistant', ?3, 100)",
13011            &[
13012                fsqlite_types::value::SqliteValue::Integer(22),
13013                fsqlite_types::value::SqliteValue::Integer(2),
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 first_hit = SearchHit {
13036            title: "Shared Session".into(),
13037            snippet: String::new(),
13038            content: String::new(),
13039            content_hash: stable_hit_hash(
13040                &content,
13041                "/tmp/progressive-conversation-id.jsonl",
13042                Some(1),
13043                Some(100),
13044            ),
13045            score: 0.0,
13046            source_path: "/tmp/progressive-conversation-id.jsonl".into(),
13047            agent: "codex".into(),
13048            workspace: String::new(),
13049            workspace_original: None,
13050            created_at: Some(100),
13051            line_number: Some(1),
13052            match_type: MatchType::Exact,
13053            source_id: "local".into(),
13054            origin_kind: "local".into(),
13055            origin_host: None,
13056            conversation_id: Some(1),
13057        };
13058        let second_hit = SearchHit {
13059            conversation_id: Some(2),
13060            ..first_hit.clone()
13061        };
13062
13063        let resolved = client.resolve_semantic_doc_ids_for_hits(&[first_hit, second_hit])?;
13064        assert_eq!(resolved.len(), 2);
13065        assert_eq!(resolved[0].as_ref().map(|hit| hit.message_id), Some(11));
13066        assert_eq!(resolved[1].as_ref().map(|hit| hit.message_id), Some(22));
13067
13068        Ok(())
13069    }
13070
13071    #[test]
13072    fn resolve_semantic_doc_ids_for_hits_treats_null_source_as_local() -> Result<()> {
13073        let conn = Connection::open(":memory:")?;
13074        conn.execute_batch(
13075            "CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL);
13076             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
13077             CREATE TABLE conversations (
13078                id INTEGER PRIMARY KEY,
13079                agent_id INTEGER NOT NULL,
13080                workspace_id INTEGER,
13081                source_id TEXT,
13082                origin_host TEXT,
13083                title TEXT,
13084                source_path TEXT NOT NULL
13085             );
13086             CREATE TABLE messages (
13087                id INTEGER PRIMARY KEY,
13088                conversation_id INTEGER NOT NULL,
13089                idx INTEGER,
13090                role TEXT,
13091                content TEXT NOT NULL,
13092                created_at INTEGER
13093             );",
13094        )?;
13095        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
13096        conn.execute(
13097            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
13098             VALUES(1, 1, NULL, NULL, NULL, 'Legacy Local', '/tmp/legacy-local.jsonl')",
13099        )?;
13100        let content = "legacy local semantic message".to_string();
13101        conn.execute_with_params(
13102            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
13103             VALUES(?1, 1, 0, 'assistant', ?2, 100)",
13104            &[
13105                fsqlite_types::value::SqliteValue::Integer(11),
13106                fsqlite_types::value::SqliteValue::Text(content.clone().into()),
13107            ],
13108        )?;
13109
13110        let client = SearchClient {
13111            reader: None,
13112            sqlite: Mutex::new(Some(SendConnection(conn))),
13113            sqlite_path: None,
13114            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
13115            reload_on_search: true,
13116            last_reload: Mutex::new(None),
13117            last_generation: Mutex::new(None),
13118            reload_epoch: Arc::new(AtomicU64::new(0)),
13119            warm_tx: None,
13120            _warm_handle: None,
13121            metrics: Metrics::default(),
13122            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
13123            semantic: Mutex::new(None),
13124            last_tantivy_total_count: Mutex::new(None),
13125        };
13126
13127        let hit = SearchHit {
13128            title: "Legacy Local".into(),
13129            snippet: String::new(),
13130            content: String::new(),
13131            content_hash: stable_hit_hash(&content, "/tmp/legacy-local.jsonl", Some(1), Some(100)),
13132            score: 0.0,
13133            source_path: "/tmp/legacy-local.jsonl".into(),
13134            agent: "codex".into(),
13135            workspace: String::new(),
13136            workspace_original: None,
13137            created_at: Some(100),
13138            line_number: Some(1),
13139            match_type: MatchType::Exact,
13140            source_id: "local".into(),
13141            origin_kind: "local".into(),
13142            origin_host: None,
13143            conversation_id: None,
13144        };
13145
13146        let resolved = client.resolve_semantic_doc_ids_for_hits(&[hit])?;
13147        assert_eq!(resolved.len(), 1);
13148        assert_eq!(resolved[0].as_ref().map(|hit| hit.message_id), Some(11));
13149
13150        Ok(())
13151    }
13152
13153    #[test]
13154    fn resolve_semantic_doc_ids_for_hits_matches_trimmed_local_source_id() -> Result<()> {
13155        let conn = Connection::open(":memory:")?;
13156        conn.execute_batch(
13157            "CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL);
13158             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
13159             CREATE TABLE conversations (
13160                id INTEGER PRIMARY KEY,
13161                agent_id INTEGER NOT NULL,
13162                workspace_id INTEGER,
13163                source_id TEXT,
13164                origin_host TEXT,
13165                title TEXT,
13166                source_path TEXT NOT NULL
13167             );
13168             CREATE TABLE messages (
13169                id INTEGER PRIMARY KEY,
13170                conversation_id INTEGER NOT NULL,
13171                idx INTEGER,
13172                role TEXT,
13173                content TEXT NOT NULL,
13174                created_at INTEGER
13175             );",
13176        )?;
13177        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
13178        conn.execute(
13179            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
13180             VALUES(1, 1, NULL, '  local  ', NULL, 'Trimmed Local', '/tmp/trimmed-local.jsonl')",
13181        )?;
13182        let content = "trimmed local semantic message".to_string();
13183        conn.execute_with_params(
13184            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
13185             VALUES(?1, 1, 0, 'assistant', ?2, 100)",
13186            &[
13187                fsqlite_types::value::SqliteValue::Integer(11),
13188                fsqlite_types::value::SqliteValue::Text(content.clone().into()),
13189            ],
13190        )?;
13191
13192        let client = SearchClient {
13193            reader: None,
13194            sqlite: Mutex::new(Some(SendConnection(conn))),
13195            sqlite_path: None,
13196            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
13197            reload_on_search: true,
13198            last_reload: Mutex::new(None),
13199            last_generation: Mutex::new(None),
13200            reload_epoch: Arc::new(AtomicU64::new(0)),
13201            warm_tx: None,
13202            _warm_handle: None,
13203            metrics: Metrics::default(),
13204            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
13205            semantic: Mutex::new(None),
13206            last_tantivy_total_count: Mutex::new(None),
13207        };
13208
13209        let hit = SearchHit {
13210            title: "Trimmed Local".into(),
13211            snippet: String::new(),
13212            content: String::new(),
13213            content_hash: stable_hit_hash(&content, "/tmp/trimmed-local.jsonl", Some(1), Some(100)),
13214            score: 0.0,
13215            source_path: "/tmp/trimmed-local.jsonl".into(),
13216            agent: "codex".into(),
13217            workspace: String::new(),
13218            workspace_original: None,
13219            created_at: Some(100),
13220            line_number: Some(1),
13221            match_type: MatchType::Exact,
13222            source_id: "local".into(),
13223            origin_kind: "local".into(),
13224            origin_host: None,
13225            conversation_id: None,
13226        };
13227
13228        let resolved = client.resolve_semantic_doc_ids_for_hits(&[hit])?;
13229        assert_eq!(resolved.len(), 1);
13230        assert_eq!(resolved[0].as_ref().map(|doc| doc.message_id), Some(11));
13231
13232        Ok(())
13233    }
13234
13235    #[test]
13236    fn resolve_semantic_doc_ids_for_hits_normalizes_blank_local_source_id() -> Result<()> {
13237        let conn = Connection::open(":memory:")?;
13238        conn.execute_batch(
13239            "CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL);
13240             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
13241             CREATE TABLE conversations (
13242                id INTEGER PRIMARY KEY,
13243                agent_id INTEGER NOT NULL,
13244                workspace_id INTEGER,
13245                source_id TEXT,
13246                origin_host TEXT,
13247                title TEXT,
13248                source_path TEXT NOT NULL
13249             );
13250             CREATE TABLE messages (
13251                id INTEGER PRIMARY KEY,
13252                conversation_id INTEGER NOT NULL,
13253                idx INTEGER,
13254                role TEXT,
13255                content TEXT NOT NULL,
13256                created_at INTEGER
13257             );",
13258        )?;
13259        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
13260        conn.execute(
13261            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
13262             VALUES(1, 1, NULL, 'local', NULL, 'Blank Local', '/tmp/blank-local.jsonl')",
13263        )?;
13264        let content = "blank local semantic message".to_string();
13265        conn.execute_with_params(
13266            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
13267             VALUES(?1, 1, 0, 'assistant', ?2, 100)",
13268            &[
13269                fsqlite_types::value::SqliteValue::Integer(11),
13270                fsqlite_types::value::SqliteValue::Text(content.clone().into()),
13271            ],
13272        )?;
13273
13274        let client = SearchClient {
13275            reader: None,
13276            sqlite: Mutex::new(Some(SendConnection(conn))),
13277            sqlite_path: None,
13278            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
13279            reload_on_search: true,
13280            last_reload: Mutex::new(None),
13281            last_generation: Mutex::new(None),
13282            reload_epoch: Arc::new(AtomicU64::new(0)),
13283            warm_tx: None,
13284            _warm_handle: None,
13285            metrics: Metrics::default(),
13286            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
13287            semantic: Mutex::new(None),
13288            last_tantivy_total_count: Mutex::new(None),
13289        };
13290
13291        let hit = SearchHit {
13292            title: "Blank Local".into(),
13293            snippet: String::new(),
13294            content: String::new(),
13295            content_hash: stable_hit_hash(&content, "/tmp/blank-local.jsonl", Some(1), Some(100)),
13296            score: 0.0,
13297            source_path: "/tmp/blank-local.jsonl".into(),
13298            agent: "codex".into(),
13299            workspace: String::new(),
13300            workspace_original: None,
13301            created_at: Some(100),
13302            line_number: Some(1),
13303            match_type: MatchType::Exact,
13304            source_id: "   ".into(),
13305            origin_kind: "local".into(),
13306            origin_host: None,
13307            conversation_id: None,
13308        };
13309
13310        let resolved = client.resolve_semantic_doc_ids_for_hits(&[hit])?;
13311        assert_eq!(resolved.len(), 1);
13312        assert_eq!(resolved[0].as_ref().map(|doc| doc.message_id), Some(11));
13313
13314        Ok(())
13315    }
13316
13317    #[test]
13318    fn resolve_semantic_doc_ids_for_hits_infers_remote_source_from_origin_host_when_source_id_blank()
13319    -> Result<()> {
13320        let conn = Connection::open(":memory:")?;
13321        conn.execute_batch(
13322            "CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL);
13323             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
13324             CREATE TABLE conversations (
13325                id INTEGER PRIMARY KEY,
13326                agent_id INTEGER NOT NULL,
13327                workspace_id INTEGER,
13328                source_id TEXT,
13329                origin_host TEXT,
13330                title TEXT,
13331                source_path TEXT NOT NULL
13332             );
13333             CREATE TABLE messages (
13334                id INTEGER PRIMARY KEY,
13335                conversation_id INTEGER NOT NULL,
13336                idx INTEGER,
13337                role TEXT,
13338                content TEXT NOT NULL,
13339                created_at INTEGER
13340             );",
13341        )?;
13342        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
13343        conn.execute(
13344            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
13345             VALUES(1, 1, NULL, '   ', 'dev@laptop', 'Legacy Remote', '/tmp/legacy-remote.jsonl')",
13346        )?;
13347        let content = "legacy remote semantic message".to_string();
13348        conn.execute_with_params(
13349            "INSERT INTO messages(id, conversation_id, idx, role, content, created_at)
13350             VALUES(?1, 1, 0, 'assistant', ?2, 100)",
13351            &[
13352                fsqlite_types::value::SqliteValue::Integer(11),
13353                fsqlite_types::value::SqliteValue::Text(content.clone().into()),
13354            ],
13355        )?;
13356
13357        let client = SearchClient {
13358            reader: None,
13359            sqlite: Mutex::new(Some(SendConnection(conn))),
13360            sqlite_path: None,
13361            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
13362            reload_on_search: true,
13363            last_reload: Mutex::new(None),
13364            last_generation: Mutex::new(None),
13365            reload_epoch: Arc::new(AtomicU64::new(0)),
13366            warm_tx: None,
13367            _warm_handle: None,
13368            metrics: Metrics::default(),
13369            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
13370            semantic: Mutex::new(None),
13371            last_tantivy_total_count: Mutex::new(None),
13372        };
13373
13374        let hit = SearchHit {
13375            title: "Legacy Remote".into(),
13376            snippet: String::new(),
13377            content: String::new(),
13378            content_hash: stable_hit_hash(&content, "/tmp/legacy-remote.jsonl", Some(1), Some(100)),
13379            score: 0.0,
13380            source_path: "/tmp/legacy-remote.jsonl".into(),
13381            agent: "codex".into(),
13382            workspace: String::new(),
13383            workspace_original: None,
13384            created_at: Some(100),
13385            line_number: Some(1),
13386            match_type: MatchType::Exact,
13387            source_id: "dev@laptop".into(),
13388            origin_kind: "remote".into(),
13389            origin_host: Some("dev@laptop".into()),
13390            conversation_id: None,
13391        };
13392
13393        let resolved = client.resolve_semantic_doc_ids_for_hits(&[hit])?;
13394        assert_eq!(resolved.len(), 1);
13395        assert_eq!(resolved[0].as_ref().map(|doc| doc.message_id), Some(11));
13396
13397        Ok(())
13398    }
13399
13400    #[test]
13401    fn browse_by_date_snippet_only_uses_full_content_for_hit_identity() -> Result<()> {
13402        let conn = Connection::open(":memory:")?;
13403        conn.execute_batch(
13404            "CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL);
13405             CREATE TABLE conversations (
13406                id INTEGER PRIMARY KEY,
13407                agent_id INTEGER NOT NULL,
13408                workspace_id INTEGER,
13409                source_id TEXT,
13410                origin_host TEXT,
13411                title TEXT,
13412                source_path TEXT NOT NULL
13413             );
13414             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL);
13415             CREATE TABLE messages (
13416                id INTEGER PRIMARY KEY,
13417                conversation_id INTEGER NOT NULL,
13418                idx INTEGER,
13419                content TEXT NOT NULL,
13420                created_at INTEGER
13421             );
13422             CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);",
13423        )?;
13424        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
13425        conn.execute(
13426            "INSERT INTO conversations(id, agent_id, workspace_id, source_id, origin_host, title, source_path)
13427             VALUES(1, 1, NULL, 'local', NULL, 'browse title', '/tmp/browse-shared.jsonl')",
13428        )?;
13429        let shared_prefix = "shared-prefix ".repeat(48);
13430        let first = format!("{shared_prefix}first browse-only tail");
13431        let second = format!("{shared_prefix}second browse-only tail");
13432        conn.execute_with_params(
13433            "INSERT INTO messages(id, conversation_id, idx, content, created_at)
13434             VALUES(?1, 1, ?2, ?3, ?4)",
13435            &[
13436                fsqlite_types::value::SqliteValue::Integer(1),
13437                fsqlite_types::value::SqliteValue::Integer(0),
13438                fsqlite_types::value::SqliteValue::Text(first.clone().into()),
13439                fsqlite_types::value::SqliteValue::Integer(101),
13440            ],
13441        )?;
13442        conn.execute_with_params(
13443            "INSERT INTO messages(id, conversation_id, idx, content, created_at)
13444             VALUES(?1, 1, ?2, ?3, ?4)",
13445            &[
13446                fsqlite_types::value::SqliteValue::Integer(2),
13447                fsqlite_types::value::SqliteValue::Integer(1),
13448                fsqlite_types::value::SqliteValue::Text(second.clone().into()),
13449                fsqlite_types::value::SqliteValue::Integer(102),
13450            ],
13451        )?;
13452
13453        let client = SearchClient {
13454            reader: None,
13455            sqlite: Mutex::new(Some(SendConnection(conn))),
13456            sqlite_path: None,
13457            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
13458            reload_on_search: true,
13459            last_reload: Mutex::new(None),
13460            last_generation: Mutex::new(None),
13461            reload_epoch: Arc::new(AtomicU64::new(0)),
13462            warm_tx: None,
13463            _warm_handle: None,
13464            metrics: Metrics::default(),
13465            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
13466            semantic: Mutex::new(None),
13467            last_tantivy_total_count: Mutex::new(None),
13468        };
13469
13470        let hits = client.browse_by_date(
13471            SearchFilters::default(),
13472            10,
13473            0,
13474            true,
13475            FieldMask::new(false, true, true, true),
13476        )?;
13477        assert_eq!(hits.len(), 2);
13478        assert!(hits.iter().all(|hit| hit.content.is_empty()));
13479        assert!(hits.iter().all(|hit| !hit.snippet.is_empty()));
13480        assert_ne!(hits[0].content_hash, hits[1].content_hash);
13481
13482        Ok(())
13483    }
13484
13485    #[test]
13486    fn cache_invalidates_on_new_data() -> Result<()> {
13487        let dir = TempDir::new()?;
13488        let mut index = TantivyIndex::open_or_create(dir.path())?;
13489
13490        // 1. Add initial doc
13491        let conv1 = NormalizedConversation {
13492            agent_slug: "codex".into(),
13493            external_id: None,
13494            title: Some("first".into()),
13495            workspace: None,
13496            source_path: dir.path().join("1.jsonl"),
13497            started_at: Some(1),
13498            ended_at: None,
13499            metadata: serde_json::json!({}),
13500            messages: vec![NormalizedMessage {
13501                idx: 0,
13502                role: "user".into(),
13503                author: None,
13504                created_at: Some(1),
13505                content: "apple banana".into(),
13506                extra: serde_json::json!({}),
13507                snippets: vec![],
13508                invocations: Vec::new(),
13509            }],
13510        };
13511        index.add_conversation(&conv1)?;
13512        index.commit()?;
13513
13514        let client = SearchClient::open(dir.path(), None)?.expect("index present");
13515
13516        // 2. Search "app" -> should hit "apple"
13517        let hits = client.search("app", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
13518        assert_eq!(hits.len(), 1);
13519        assert_eq!(hits[0].content, "apple banana");
13520
13521        // 3. Verify it's cached (peek internal state)
13522        {
13523            let cache = client.prefix_cache.lock().unwrap();
13524            let shard = cache.shard_opt("global").unwrap();
13525            // "app" should be in cache
13526            assert!(shard.contains(&client.cache_key("app", &SearchFilters::default())));
13527        }
13528
13529        // 4. Add new doc with "apricot"
13530        let conv2 = NormalizedConversation {
13531            agent_slug: "codex".into(),
13532            external_id: None,
13533            title: Some("second".into()),
13534            workspace: None,
13535            source_path: dir.path().join("2.jsonl"),
13536            started_at: Some(2),
13537            ended_at: None,
13538            metadata: serde_json::json!({}),
13539            messages: vec![NormalizedMessage {
13540                idx: 0,
13541                role: "user".into(),
13542                author: None,
13543                created_at: Some(2),
13544                content: "apricot".into(),
13545                extra: serde_json::json!({}),
13546                snippets: vec![],
13547                invocations: Vec::new(),
13548            }],
13549        };
13550        index.add_conversation(&conv2)?;
13551        index.commit()?;
13552
13553        // 5. Force reload (mocking time passing or just ensuring reload triggers)
13554        // In test, maybe_reload_reader uses 300ms debounce.
13555        // We can rely on opstamp check logic which runs AFTER reload.
13556        // We need to sleep briefly to bypass debounce or just modify test to not rely on time?
13557        // Actually SearchClient::maybe_reload_reader checks duration.
13558        std::thread::sleep(std::time::Duration::from_millis(350));
13559
13560        // 6. Search "ap" (prefix of apricot and apple)
13561        // The cache for "app" should be cleared if opstamp changed.
13562        let _hits = client.search("app", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
13563        // Should now find 1 doc still ("apple"), but cache should have been cleared first
13564
13565        // Search "apr" -> should find "apricot"
13566        let hits = client.search("apr", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
13567        assert_eq!(hits.len(), 1);
13568        assert_eq!(hits[0].content, "apricot");
13569
13570        // Check that cache was cleared by verifying a stale key is gone?
13571        // Or rely on correctness of results if we searched a common prefix?
13572
13573        Ok(())
13574    }
13575
13576    #[test]
13577    fn track_generation_clears_cache_on_change() {
13578        let client = SearchClient {
13579            reader: None,
13580            sqlite: Mutex::new(None),
13581            sqlite_path: None,
13582            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
13583            reload_on_search: true,
13584            last_reload: Mutex::new(None),
13585            last_generation: Mutex::new(None),
13586            reload_epoch: Arc::new(AtomicU64::new(0)),
13587            warm_tx: None,
13588            _warm_handle: None,
13589            metrics: Metrics::default(),
13590            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
13591            semantic: Mutex::new(None),
13592            last_tantivy_total_count: Mutex::new(None),
13593        };
13594
13595        let hit = SearchHit {
13596            title: "hello world".into(),
13597            snippet: "hello".into(),
13598            content: "hello world".into(),
13599            content_hash: stable_content_hash("hello world"),
13600            score: 1.0,
13601            source_path: "p".into(),
13602            agent: "a".into(),
13603            workspace: "w".into(),
13604            workspace_original: None,
13605            created_at: None,
13606            line_number: None,
13607            match_type: MatchType::Exact,
13608            source_id: "local".into(),
13609            origin_kind: "local".into(),
13610            origin_host: None,
13611            conversation_id: None,
13612        };
13613        let hits = vec![hit];
13614
13615        client.put_cache("hello", &SearchFilters::default(), &hits);
13616        {
13617            let cache = client.prefix_cache.lock().unwrap();
13618            assert!(!cache.shards.is_empty());
13619        }
13620
13621        client.track_generation(1);
13622        {
13623            let cache = client.prefix_cache.lock().unwrap();
13624            assert!(!cache.shards.is_empty());
13625        }
13626
13627        client.track_generation(2);
13628        {
13629            let cache = client.prefix_cache.lock().unwrap();
13630            assert!(cache.shards.is_empty());
13631        }
13632    }
13633
13634    #[test]
13635    fn cache_total_cap_evicts_across_shards() {
13636        let client = SearchClient {
13637            reader: None,
13638            sqlite: Mutex::new(None),
13639            sqlite_path: None,
13640            prefix_cache: Mutex::new(CacheShards::new(2, 0)), // tiny entry cap, no byte cap
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: None,
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
13653        let hit = SearchHit {
13654            title: "a".into(),
13655            snippet: "a".into(),
13656            content: "a".into(),
13657            content_hash: stable_content_hash("a"),
13658            score: 1.0,
13659            source_path: "p".into(),
13660            agent: "agent1".into(),
13661            workspace: "w".into(),
13662            workspace_original: None,
13663            created_at: None,
13664            line_number: None,
13665            match_type: MatchType::Exact,
13666            source_id: "local".into(),
13667            origin_kind: "local".into(),
13668            origin_host: None,
13669            conversation_id: None,
13670        };
13671        let hits = vec![hit.clone()];
13672
13673        let mut filters = SearchFilters::default();
13674        filters.agents.insert("agent1".into());
13675        client.put_cache("a", &filters, &hits);
13676        filters.agents.clear();
13677        filters.agents.insert("agent2".into());
13678        client.put_cache("b", &filters, &hits);
13679        filters.agents.clear();
13680        filters.agents.insert("agent3".into());
13681        client.put_cache("c", &filters, &hits);
13682
13683        let stats = client.cache_stats();
13684        assert!(stats.total_cost <= stats.total_cap);
13685        assert_eq!(stats.total_cap, 2);
13686    }
13687
13688    #[test]
13689    fn cache_stats_reflect_metrics() {
13690        let client = SearchClient {
13691            reader: None,
13692            sqlite: Mutex::new(None),
13693            sqlite_path: None,
13694            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
13695            reload_on_search: true,
13696            last_reload: Mutex::new(None),
13697            last_generation: Mutex::new(None),
13698            reload_epoch: Arc::new(AtomicU64::new(0)),
13699            warm_tx: None,
13700            _warm_handle: None,
13701            metrics: Metrics::default(),
13702            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
13703            semantic: Mutex::new(None),
13704            last_tantivy_total_count: Mutex::new(None),
13705        };
13706
13707        client.metrics.inc_cache_hits();
13708        client.metrics.inc_cache_miss();
13709        client.metrics.inc_cache_shortfall();
13710        client.metrics.record_reload(Duration::from_millis(10));
13711
13712        let stats = client.cache_stats();
13713        assert_eq!(stats.cache_hits, 1);
13714        assert_eq!(stats.cache_miss, 1);
13715        assert_eq!(stats.cache_shortfall, 1);
13716        assert_eq!(stats.reloads, 1);
13717        assert_eq!(stats.reload_ms_total, 10);
13718        assert_eq!(stats.total_cap, *CACHE_TOTAL_CAP);
13719        assert_eq!(stats.eviction_policy, "lru");
13720        assert_eq!(stats.prewarm_scheduled, 0);
13721        assert_eq!(stats.prewarm_skipped_pressure, 0);
13722        assert_eq!(CacheStats::default().eviction_policy, "unknown");
13723    }
13724
13725    #[test]
13726    fn adaptive_query_prewarm_schedules_only_after_hot_prefix_cache_entry() {
13727        let (tx, rx) = mpsc::unbounded();
13728        let client = SearchClient {
13729            reader: None,
13730            sqlite: Mutex::new(None),
13731            sqlite_path: None,
13732            prefix_cache: Mutex::new(CacheShards::new(10, 0)),
13733            reload_on_search: true,
13734            last_reload: Mutex::new(None),
13735            last_generation: Mutex::new(None),
13736            reload_epoch: Arc::new(AtomicU64::new(0)),
13737            warm_tx: Some(tx),
13738            _warm_handle: None,
13739            metrics: Metrics::default(),
13740            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
13741            semantic: Mutex::new(None),
13742            last_tantivy_total_count: Mutex::new(None),
13743        };
13744        let mut filters = SearchFilters::default();
13745        filters.workspaces.insert("/tmp/cass-workspace".into());
13746
13747        client.maybe_schedule_adaptive_query_prewarm("hel", &filters);
13748        assert!(
13749            rx.try_recv().is_err(),
13750            "cold prefixes should not schedule adaptive prewarm"
13751        );
13752
13753        let mut hit = projected_minimal_fields_search_hit("hello title", "p");
13754        hit.snippet = "hello".into();
13755        hit.content = "hello world".into();
13756        hit.content_hash = stable_content_hash(&hit.content);
13757        client.put_cache("hel", &filters, std::slice::from_ref(&hit));
13758
13759        let total_cost_before = client.cache_stats().total_cost;
13760        client.maybe_schedule_adaptive_query_prewarm("hel", &filters);
13761        assert!(
13762            rx.try_recv().is_err(),
13763            "an exact cached query should not schedule redundant prewarm"
13764        );
13765        client.maybe_schedule_adaptive_query_prewarm("hello", &filters);
13766
13767        let job = rx
13768            .try_recv()
13769            .expect("hot prefix should schedule adaptive prewarm");
13770        assert_eq!(job.query, "hello");
13771        assert_eq!(job.shard_name, "workspace:/tmp/cass-workspace");
13772        assert_eq!(job.filters_fingerprint, filters_fingerprint(&filters));
13773        let stats = client.cache_stats();
13774        assert_eq!(stats.prewarm_scheduled, 1);
13775        assert_eq!(stats.prewarm_skipped_pressure, 0);
13776        assert_eq!(
13777            stats.total_cost, total_cost_before,
13778            "prewarm scheduling should not mutate result-cache contents"
13779        );
13780    }
13781
13782    #[test]
13783    fn adaptive_query_prewarm_skips_when_cache_byte_cap_is_under_pressure() {
13784        let mut hit = projected_minimal_fields_search_hit("hello title", "p");
13785        hit.snippet = "hello".into();
13786        hit.content = "hello world with enough content to consume the small byte budget".into();
13787        hit.content_hash = stable_content_hash(&hit.content);
13788        let byte_cap = cached_hit_from(&hit).approx_bytes();
13789
13790        let (tx, rx) = mpsc::unbounded();
13791        let client = SearchClient {
13792            reader: None,
13793            sqlite: Mutex::new(None),
13794            sqlite_path: None,
13795            prefix_cache: Mutex::new(CacheShards::new(10, byte_cap)),
13796            reload_on_search: true,
13797            last_reload: Mutex::new(None),
13798            last_generation: Mutex::new(None),
13799            reload_epoch: Arc::new(AtomicU64::new(0)),
13800            warm_tx: Some(tx),
13801            _warm_handle: None,
13802            metrics: Metrics::default(),
13803            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
13804            semantic: Mutex::new(None),
13805            last_tantivy_total_count: Mutex::new(None),
13806        };
13807        let filters = SearchFilters::default();
13808
13809        client.put_cache("hel", &filters, std::slice::from_ref(&hit));
13810        client.maybe_schedule_adaptive_query_prewarm("zebra", &filters);
13811        assert_eq!(
13812            client.cache_stats().prewarm_skipped_pressure,
13813            0,
13814            "cold queries should not be counted as pressure-skipped prewarm jobs"
13815        );
13816
13817        client.maybe_schedule_adaptive_query_prewarm("hello", &filters);
13818
13819        assert!(
13820            rx.try_recv().is_err(),
13821            "prewarm should be disabled while cache byte pressure is high"
13822        );
13823        let stats = client.cache_stats();
13824        assert_eq!(stats.prewarm_scheduled, 0);
13825        assert_eq!(stats.prewarm_skipped_pressure, 1);
13826        assert!(stats.approx_bytes <= stats.byte_cap);
13827    }
13828
13829    #[test]
13830    fn cache_eviction_count_tracks_evictions() {
13831        // tiny entry cap (2 entries), no byte cap - forces evictions
13832        let client = SearchClient {
13833            reader: None,
13834            sqlite: Mutex::new(None),
13835            sqlite_path: None,
13836            prefix_cache: Mutex::new(CacheShards::new(2, 0)),
13837            reload_on_search: true,
13838            last_reload: Mutex::new(None),
13839            last_generation: Mutex::new(None),
13840            reload_epoch: Arc::new(AtomicU64::new(0)),
13841            warm_tx: None,
13842            _warm_handle: None,
13843            metrics: Metrics::default(),
13844            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
13845            semantic: Mutex::new(None),
13846            last_tantivy_total_count: Mutex::new(None),
13847        };
13848
13849        let hit = SearchHit {
13850            title: "test".into(),
13851            snippet: "snippet".into(),
13852            content: "content".into(),
13853            content_hash: stable_content_hash("content"),
13854            score: 1.0,
13855            source_path: "p".into(),
13856            agent: "a".into(),
13857            workspace: "w".into(),
13858            workspace_original: None,
13859            created_at: None,
13860            line_number: None,
13861            match_type: MatchType::Exact,
13862            source_id: "local".into(),
13863            origin_kind: "local".into(),
13864            origin_host: None,
13865            conversation_id: None,
13866        };
13867
13868        // Put 3 entries - should trigger 1 eviction (cap is 2)
13869        client.put_cache(
13870            "query1",
13871            &SearchFilters::default(),
13872            std::slice::from_ref(&hit),
13873        );
13874        client.put_cache(
13875            "query2",
13876            &SearchFilters::default(),
13877            std::slice::from_ref(&hit),
13878        );
13879        client.put_cache(
13880            "query3",
13881            &SearchFilters::default(),
13882            std::slice::from_ref(&hit),
13883        );
13884
13885        let stats = client.cache_stats();
13886        assert!(
13887            stats.eviction_count >= 1,
13888            "should have evicted at least 1 entry"
13889        );
13890        assert!(stats.total_cost <= 2, "should be at or below cap");
13891        assert!(stats.approx_bytes > 0, "should track bytes used");
13892    }
13893
13894    #[test]
13895    fn default_cache_byte_cap_scales_with_available_memory() {
13896        let gib = 1024_u64 * 1024 * 1024;
13897
13898        assert_eq!(
13899            default_cache_byte_cap_for_available(None),
13900            DEFAULT_CACHE_BYTE_CAP_FALLBACK
13901        );
13902        assert_eq!(
13903            default_cache_byte_cap_for_available(Some(2 * gib)),
13904            DEFAULT_CACHE_BYTE_CAP_FALLBACK,
13905            "small hosts keep a conservative cache byte budget"
13906        );
13907        assert_eq!(
13908            default_cache_byte_cap_for_available(Some(64 * gib)),
13909            512 * 1024 * 1024,
13910            "larger hosts get a proportionally larger cache byte budget"
13911        );
13912        assert_eq!(
13913            default_cache_byte_cap_for_available(Some(256 * gib)),
13914            usize::try_from(DEFAULT_CACHE_BYTE_CAP_CEILING).unwrap_or(usize::MAX),
13915            "large swarm hosts still have a bounded default cache budget"
13916        );
13917    }
13918
13919    #[test]
13920    fn malformed_cache_byte_cap_env_uses_default_instead_of_disabling_guard() {
13921        let gib = 1024_u64 * 1024 * 1024;
13922
13923        assert_eq!(cache_byte_cap_from_env_value(Some("0"), Some(64 * gib)), 0);
13924        assert_eq!(
13925            cache_byte_cap_from_env_value(Some("not-a-number"), Some(64 * gib)),
13926            default_cache_byte_cap_for_available(Some(64 * gib)),
13927            "malformed env should keep the default memory guard active"
13928        );
13929        assert_eq!(
13930            cache_byte_cap_from_env_value(None, Some(64 * gib)),
13931            default_cache_byte_cap_for_available(Some(64 * gib))
13932        );
13933    }
13934
13935    #[test]
13936    fn cache_eviction_policy_env_defaults_to_lru_and_accepts_s3_fifo() {
13937        assert_eq!(
13938            cache_eviction_policy_from_env_value(None),
13939            CacheEvictionPolicy::Lru
13940        );
13941        assert_eq!(
13942            cache_eviction_policy_from_env_value(Some("not-a-policy")),
13943            CacheEvictionPolicy::Lru,
13944            "malformed env keeps the current LRU behavior"
13945        );
13946        assert_eq!(
13947            cache_eviction_policy_from_env_value(Some("s3-fifo")),
13948            CacheEvictionPolicy::S3Fifo
13949        );
13950        assert_eq!(
13951            cache_eviction_policy_from_env_value(Some("s3_fifo")),
13952            CacheEvictionPolicy::S3Fifo
13953        );
13954    }
13955
13956    #[test]
13957    fn s3_fifo_admission_rejects_one_off_byte_heavy_entries_then_admits_ghost_replay() {
13958        let content = "large".repeat(1_000);
13959        let hit = SearchHit {
13960            title: "large".into(),
13961            snippet: "large".into(),
13962            content: content.clone(),
13963            content_hash: stable_content_hash(&content),
13964            score: 1.0,
13965            source_path: "large-path".into(),
13966            agent: "a".into(),
13967            workspace: "w".into(),
13968            workspace_original: None,
13969            created_at: None,
13970            line_number: None,
13971            match_type: MatchType::Exact,
13972            source_id: "local".into(),
13973            origin_kind: "local".into(),
13974            origin_host: None,
13975            conversation_id: None,
13976        };
13977        let cached = cached_hit_from(&hit);
13978        let byte_cap = cached.approx_bytes() + 1_024;
13979        assert!(
13980            cached.approx_bytes() > byte_cap.div_ceil(S3_FIFO_LARGE_ENTRY_FRACTION_DENOMINATOR)
13981        );
13982
13983        let mut cache = CacheShards::new_with_policy(100, byte_cap, CacheEvictionPolicy::S3Fifo);
13984        let key = Arc::<str>::from("large-query");
13985
13986        cache.put("global", key.clone(), vec![cached.clone()]);
13987        assert_eq!(
13988            cache.total_cost(),
13989            0,
13990            "first one-off large entry is not admitted"
13991        );
13992        assert_eq!(cache.ghost_entries(), 1);
13993        assert_eq!(cache.admission_rejects(), 1);
13994
13995        cache.put("global", key, vec![cached]);
13996        assert_eq!(
13997            cache.total_cost(),
13998            1,
13999            "ghost replay admits the repeated query"
14000        );
14001        assert_eq!(cache.ghost_entries(), 0);
14002        assert!(cache.ghost_keys.is_empty());
14003        assert_eq!(cache.admission_rejects(), 1);
14004        assert!(cache.total_bytes() <= cache.byte_cap());
14005    }
14006
14007    #[test]
14008    fn lru_policy_keeps_admitting_large_entries_under_existing_caps() {
14009        let content = "large".repeat(1_000);
14010        let hit = SearchHit {
14011            title: "large".into(),
14012            snippet: "large".into(),
14013            content: content.clone(),
14014            content_hash: stable_content_hash(&content),
14015            score: 1.0,
14016            source_path: "large-path".into(),
14017            agent: "a".into(),
14018            workspace: "w".into(),
14019            workspace_original: None,
14020            created_at: None,
14021            line_number: None,
14022            match_type: MatchType::Exact,
14023            source_id: "local".into(),
14024            origin_kind: "local".into(),
14025            origin_host: None,
14026            conversation_id: None,
14027        };
14028        let cached = cached_hit_from(&hit);
14029        let byte_cap = cached.approx_bytes() + 1_024;
14030        let mut cache = CacheShards::new_with_policy(100, byte_cap, CacheEvictionPolicy::Lru);
14031
14032        cache.put("global", Arc::<str>::from("large-query"), vec![cached]);
14033
14034        assert_eq!(cache.total_cost(), 1);
14035        assert_eq!(cache.ghost_entries(), 0);
14036        assert_eq!(cache.admission_rejects(), 0);
14037        assert_eq!(cache.policy_label(), "lru");
14038    }
14039
14040    #[test]
14041    fn cache_byte_cap_triggers_eviction() {
14042        // Large entry cap (1000), tiny byte cap (100 bytes) - forces byte-based evictions
14043        let client = SearchClient {
14044            reader: None,
14045            sqlite: Mutex::new(None),
14046            sqlite_path: None,
14047            prefix_cache: Mutex::new(CacheShards::new(1000, 100)), // byte cap of 100
14048            reload_on_search: true,
14049            last_reload: Mutex::new(None),
14050            last_generation: Mutex::new(None),
14051            reload_epoch: Arc::new(AtomicU64::new(0)),
14052            warm_tx: None,
14053            _warm_handle: None,
14054            metrics: Metrics::default(),
14055            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
14056            semantic: Mutex::new(None),
14057            last_tantivy_total_count: Mutex::new(None),
14058        };
14059
14060        // Large content to exceed byte cap quickly
14061        let content = "c".repeat(100);
14062        let hit = SearchHit {
14063            title: "a".repeat(50),
14064            snippet: "b".repeat(50),
14065            content: content.clone(), // 200+ bytes per hit
14066            content_hash: stable_content_hash(&content),
14067            score: 1.0,
14068            source_path: "p".into(),
14069            agent: "a".into(),
14070            workspace: "w".into(),
14071            workspace_original: None,
14072            created_at: None,
14073            line_number: None,
14074            match_type: MatchType::Exact,
14075            source_id: "local".into(),
14076            origin_kind: "local".into(),
14077            origin_host: None,
14078            conversation_id: None,
14079        };
14080
14081        // Put 3 large entries - should trigger byte-based evictions
14082        client.put_cache("q1", &SearchFilters::default(), std::slice::from_ref(&hit));
14083        client.put_cache("q2", &SearchFilters::default(), std::slice::from_ref(&hit));
14084        client.put_cache("q3", &SearchFilters::default(), std::slice::from_ref(&hit));
14085
14086        let stats = client.cache_stats();
14087        assert!(
14088            stats.eviction_count >= 1,
14089            "byte cap should trigger evictions"
14090        );
14091        assert_eq!(stats.byte_cap, 100, "byte cap should be reported");
14092        // Note: approx_bytes may briefly exceed cap during put, but eviction brings it down
14093    }
14094
14095    #[test]
14096    fn cache_byte_pressure_evicts_byte_heavy_shard_before_small_entries() {
14097        let small_hit = SearchHit {
14098            title: "small".into(),
14099            snippet: "small".into(),
14100            content: "small".into(),
14101            content_hash: stable_content_hash("small"),
14102            score: 1.0,
14103            source_path: "small-path".into(),
14104            agent: "a".into(),
14105            workspace: "w".into(),
14106            workspace_original: None,
14107            created_at: None,
14108            line_number: None,
14109            match_type: MatchType::Exact,
14110            source_id: "local".into(),
14111            origin_kind: "local".into(),
14112            origin_host: None,
14113            conversation_id: None,
14114        };
14115        let large_content = "large".repeat(2_000);
14116        let large_hit = SearchHit {
14117            title: "large".into(),
14118            snippet: "large".into(),
14119            content: large_content.clone(),
14120            content_hash: stable_content_hash(&large_content),
14121            score: 1.0,
14122            source_path: "large-path".into(),
14123            agent: "b".into(),
14124            workspace: "w".into(),
14125            workspace_original: None,
14126            created_at: None,
14127            line_number: None,
14128            match_type: MatchType::Exact,
14129            source_id: "local".into(),
14130            origin_kind: "local".into(),
14131            origin_host: None,
14132            conversation_id: None,
14133        };
14134
14135        let mut cache = CacheShards::new(100, 1_024);
14136        cache.put(
14137            "small",
14138            Arc::<str>::from("small-1"),
14139            vec![cached_hit_from(&small_hit)],
14140        );
14141        cache.put(
14142            "small",
14143            Arc::<str>::from("small-2"),
14144            vec![cached_hit_from(&small_hit)],
14145        );
14146        cache.put(
14147            "large",
14148            Arc::<str>::from("large-1"),
14149            vec![cached_hit_from(&large_hit)],
14150        );
14151
14152        assert_eq!(
14153            cache.shard_opt("small").map(LruCache::len),
14154            Some(2),
14155            "byte pressure should preserve the small shard"
14156        );
14157        assert!(
14158            cache.shard_opt("large").is_none_or(LruCache::is_empty),
14159            "oversized shard should be evicted first under byte pressure"
14160        );
14161        assert!(cache.total_bytes() <= cache.byte_cap());
14162    }
14163
14164    // ============================================================
14165    // Phase 7 Tests: WildcardPattern, escape_regex, fallback, dedup
14166    // ============================================================
14167
14168    #[test]
14169    fn wildcard_pattern_parse_exact() {
14170        // No wildcards - exact match
14171        assert_eq!(
14172            FsCassWildcardPattern::parse("hello"),
14173            FsCassWildcardPattern::Exact("hello".into())
14174        );
14175        assert_eq!(
14176            FsCassWildcardPattern::parse("HELLO"),
14177            FsCassWildcardPattern::Exact("hello".into()) // lowercased
14178        );
14179        assert_eq!(
14180            FsCassWildcardPattern::parse("FooBar123"),
14181            FsCassWildcardPattern::Exact("foobar123".into())
14182        );
14183    }
14184
14185    #[test]
14186    fn wildcard_pattern_parse_prefix() {
14187        // Trailing wildcard: foo*
14188        assert_eq!(
14189            FsCassWildcardPattern::parse("foo*"),
14190            FsCassWildcardPattern::Prefix("foo".into())
14191        );
14192        assert_eq!(
14193            FsCassWildcardPattern::parse("CONFIG*"),
14194            FsCassWildcardPattern::Prefix("config".into())
14195        );
14196        assert_eq!(
14197            FsCassWildcardPattern::parse("test*"),
14198            FsCassWildcardPattern::Prefix("test".into())
14199        );
14200    }
14201
14202    #[test]
14203    fn wildcard_pattern_parse_suffix() {
14204        // Leading wildcard: *foo
14205        assert_eq!(
14206            FsCassWildcardPattern::parse("*foo"),
14207            FsCassWildcardPattern::Suffix("foo".into())
14208        );
14209        assert_eq!(
14210            FsCassWildcardPattern::parse("*Error"),
14211            FsCassWildcardPattern::Suffix("error".into())
14212        );
14213        assert_eq!(
14214            FsCassWildcardPattern::parse("*Handler"),
14215            FsCassWildcardPattern::Suffix("handler".into())
14216        );
14217    }
14218
14219    #[test]
14220    fn wildcard_pattern_parse_substring() {
14221        // Both wildcards: *foo*
14222        assert_eq!(
14223            FsCassWildcardPattern::parse("*foo*"),
14224            FsCassWildcardPattern::Substring("foo".into())
14225        );
14226        assert_eq!(
14227            FsCassWildcardPattern::parse("*CONFIG*"),
14228            FsCassWildcardPattern::Substring("config".into())
14229        );
14230        assert_eq!(
14231            FsCassWildcardPattern::parse("*test*"),
14232            FsCassWildcardPattern::Substring("test".into())
14233        );
14234    }
14235
14236    #[test]
14237    fn wildcard_pattern_parse_edge_cases() {
14238        // Empty after trimming wildcards
14239        assert_eq!(
14240            FsCassWildcardPattern::parse("*"),
14241            FsCassWildcardPattern::Exact(String::new())
14242        );
14243        assert_eq!(
14244            FsCassWildcardPattern::parse("**"),
14245            FsCassWildcardPattern::Exact(String::new())
14246        );
14247        assert_eq!(
14248            FsCassWildcardPattern::parse("***"),
14249            FsCassWildcardPattern::Exact(String::new())
14250        );
14251
14252        // Single char with wildcards
14253        assert_eq!(
14254            FsCassWildcardPattern::parse("*a*"),
14255            FsCassWildcardPattern::Substring("a".into())
14256        );
14257        assert_eq!(
14258            FsCassWildcardPattern::parse("a*"),
14259            FsCassWildcardPattern::Prefix("a".into())
14260        );
14261        assert_eq!(
14262            FsCassWildcardPattern::parse("*a"),
14263            FsCassWildcardPattern::Suffix("a".into())
14264        );
14265
14266        // Multiple asterisks get trimmed
14267        assert_eq!(
14268            FsCassWildcardPattern::parse("***foo***"),
14269            FsCassWildcardPattern::Substring("foo".into())
14270        );
14271    }
14272
14273    #[test]
14274    fn wildcard_pattern_to_regex_suffix() {
14275        let pattern = FsCassWildcardPattern::Suffix("foo".into());
14276        // Suffix patterns need $ anchor to ensure "ends with" semantics
14277        assert_eq!(pattern.to_regex(), Some(".*foo$".into()));
14278    }
14279
14280    #[test]
14281    fn wildcard_pattern_to_regex_substring() {
14282        let pattern = FsCassWildcardPattern::Substring("bar".into());
14283        assert_eq!(pattern.to_regex(), Some(".*bar.*".into()));
14284    }
14285
14286    #[test]
14287    fn wildcard_pattern_to_regex_exact_prefix_none() {
14288        // Exact and Prefix patterns don't need regex
14289        let exact = FsCassWildcardPattern::Exact("foo".into());
14290        assert_eq!(exact.to_regex(), None);
14291
14292        let prefix = FsCassWildcardPattern::Prefix("bar".into());
14293        assert_eq!(prefix.to_regex(), None);
14294    }
14295
14296    #[test]
14297    fn match_type_quality_factors() {
14298        // Exact match has highest quality
14299        assert_eq!(MatchType::Exact.quality_factor(), 1.0);
14300        // Prefix is slightly lower
14301        assert_eq!(MatchType::Prefix.quality_factor(), 0.9);
14302        // Suffix is lower than prefix
14303        assert_eq!(MatchType::Suffix.quality_factor(), 0.8);
14304        // Substring is lower still
14305        assert_eq!(MatchType::Substring.quality_factor(), 0.7);
14306        // Implicit wildcard is lowest
14307        assert_eq!(MatchType::ImplicitWildcard.quality_factor(), 0.6);
14308    }
14309
14310    #[test]
14311    fn dominant_match_type_single_terms() {
14312        // Single terms return their pattern's match type
14313        assert_eq!(dominant_match_type("hello"), MatchType::Exact);
14314        assert_eq!(dominant_match_type("hello*"), MatchType::Prefix);
14315        assert_eq!(dominant_match_type("*hello"), MatchType::Suffix);
14316        assert_eq!(dominant_match_type("*hello*"), MatchType::Substring);
14317    }
14318
14319    #[test]
14320    fn dominant_match_type_multiple_terms() {
14321        // Multiple terms: returns the "loosest" (lowest quality factor)
14322        assert_eq!(dominant_match_type("foo bar"), MatchType::Exact);
14323        assert_eq!(dominant_match_type("foo bar*"), MatchType::Prefix);
14324        assert_eq!(dominant_match_type("foo *bar"), MatchType::Suffix);
14325        assert_eq!(dominant_match_type("foo* *bar*"), MatchType::Substring);
14326        // Substring is loosest even if other terms are exact
14327        assert_eq!(dominant_match_type("foo *bar* baz"), MatchType::Substring);
14328    }
14329
14330    #[test]
14331    fn dominant_match_type_empty_query() {
14332        assert_eq!(dominant_match_type(""), MatchType::Exact);
14333        assert_eq!(dominant_match_type("   "), MatchType::Exact);
14334    }
14335
14336    #[test]
14337    fn wildcard_pattern_to_regex_escapes_special_chars() {
14338        assert_eq!(
14339            FsCassWildcardPattern::Suffix("foo.bar".into()).to_regex(),
14340            Some(".*foo\\.bar$".into())
14341        );
14342        assert_eq!(
14343            FsCassWildcardPattern::Substring("a+b*c?".into()).to_regex(),
14344            Some(".*a\\+b\\*c\\?.*".into())
14345        );
14346    }
14347
14348    #[test]
14349    fn wildcard_pattern_to_regex_escapes_complex_patterns() {
14350        assert_eq!(
14351            FsCassWildcardPattern::Suffix("test[0-9]+".into()).to_regex(),
14352            Some(".*test\\[0-9\\]\\+$".into())
14353        );
14354        assert_eq!(
14355            FsCassWildcardPattern::Substring("(a|b)".into()).to_regex(),
14356            Some(".*\\(a\\|b\\).*".into())
14357        );
14358        assert_eq!(
14359            FsCassWildcardPattern::Substring("end$".into()).to_regex(),
14360            Some(".*end\\$.*".into())
14361        );
14362        assert_eq!(
14363            FsCassWildcardPattern::Substring("^start".into()).to_regex(),
14364            Some(".*\\^start.*".into())
14365        );
14366    }
14367
14368    #[test]
14369    fn is_tool_invocation_noise_detects_noise() {
14370        // "[Tool: Name]" is now kept (users search for tool usage)
14371        assert!(!is_tool_invocation_noise("[Tool: Bash]"));
14372        assert!(!is_tool_invocation_noise("[Tool: Read]"));
14373
14374        // Empty tool names are noise
14375        assert!(is_tool_invocation_noise("[Tool:]"));
14376        assert!(is_tool_invocation_noise("[Tool: ]"));
14377
14378        // Useful content should NOT be filtered
14379        assert!(!is_tool_invocation_noise("[Tool: Bash - Check status]"));
14380        assert!(!is_tool_invocation_noise("  [Tool: Grep - Search files]  "));
14381
14382        // Very short tool markers (< 20 chars with "tool" prefix)
14383        assert!(is_tool_invocation_noise("[tool]"));
14384        assert!(is_tool_invocation_noise("tool: Bash"));
14385    }
14386
14387    #[test]
14388    fn is_tool_invocation_noise_allows_useful_content() {
14389        // This should NOT be considered noise
14390        assert!(!is_tool_invocation_noise("[Tool: Read - src/main.rs]"));
14391        assert!(!is_tool_invocation_noise("[Tool: Bash - cargo test --lib]"));
14392    }
14393
14394    #[test]
14395    fn is_tool_invocation_noise_detects_tool_markers() {
14396        // "[Tool: Name]" is now kept (searchable tool usage)
14397        assert!(!is_tool_invocation_noise("[Tool: Bash]"));
14398        assert!(!is_tool_invocation_noise("[Tool: Read]"));
14399
14400        // Empty names are still noise
14401        assert!(is_tool_invocation_noise("[Tool:]"));
14402
14403        // Useful content allowed
14404        assert!(!is_tool_invocation_noise("[Tool: Bash - Check status]"));
14405        assert!(!is_tool_invocation_noise("  [Tool: Write - description]  "));
14406    }
14407
14408    #[test]
14409    fn deduplicate_hits_removes_exact_dupes() {
14410        let hits = vec![
14411            SearchHit {
14412                title: "title1".into(),
14413                snippet: "snip1".into(),
14414                content: "hello world".into(),
14415                content_hash: stable_content_hash("hello world"),
14416                score: 1.0,
14417                source_path: "a.jsonl".into(),
14418                agent: "agent".into(),
14419                workspace: "ws".into(),
14420                workspace_original: None,
14421                created_at: Some(100),
14422                line_number: None,
14423                match_type: MatchType::Exact,
14424                source_id: "local".into(),
14425                origin_kind: "local".into(),
14426                origin_host: None,
14427                conversation_id: None,
14428            },
14429            SearchHit {
14430                title: "title1".into(),
14431                snippet: "snip2".into(),
14432                content: "hello world".into(), // same content
14433                content_hash: stable_content_hash("hello world"),
14434                score: 0.5, // lower score
14435                source_path: "a.jsonl".into(),
14436                agent: "agent".into(),
14437                workspace: "ws".into(),
14438                workspace_original: None,
14439                created_at: Some(100),
14440                line_number: None,
14441                match_type: MatchType::Exact,
14442                source_id: "local".into(), // same source_id = will dedupe
14443                origin_kind: "local".into(),
14444                origin_host: None,
14445                conversation_id: None,
14446            },
14447        ];
14448
14449        let deduped = deduplicate_hits(hits);
14450        assert_eq!(deduped.len(), 1);
14451        assert_eq!(deduped[0].score, 1.0); // kept higher score
14452        assert_eq!(deduped[0].title, "title1");
14453    }
14454
14455    #[test]
14456    fn deduplicate_hits_keeps_higher_score() {
14457        let hits = vec![
14458            SearchHit {
14459                title: "title1".into(),
14460                snippet: "snip1".into(),
14461                content: "hello world".into(),
14462                content_hash: stable_content_hash("hello world"),
14463                score: 0.3, // lower score first
14464                source_path: "a.jsonl".into(),
14465                agent: "agent".into(),
14466                workspace: "ws".into(),
14467                workspace_original: None,
14468                created_at: Some(100),
14469                line_number: None,
14470                match_type: MatchType::Exact,
14471                source_id: "local".into(),
14472                origin_kind: "local".into(),
14473                origin_host: None,
14474                conversation_id: None,
14475            },
14476            SearchHit {
14477                title: "title1".into(),
14478                snippet: "snip2".into(),
14479                content: "hello world".into(),
14480                content_hash: stable_content_hash("hello world"),
14481                score: 0.9, // higher score second
14482                source_path: "a.jsonl".into(),
14483                agent: "agent".into(),
14484                workspace: "ws".into(),
14485                workspace_original: None,
14486                created_at: Some(100),
14487                line_number: None,
14488                match_type: MatchType::Exact,
14489                source_id: "local".into(),
14490                origin_kind: "local".into(),
14491                origin_host: None,
14492                conversation_id: None,
14493            },
14494        ];
14495
14496        let deduped = deduplicate_hits(hits);
14497        assert_eq!(deduped.len(), 1);
14498        assert_eq!(deduped[0].score, 0.9); // kept higher score
14499        assert_eq!(deduped[0].title, "title1");
14500    }
14501
14502    #[test]
14503    fn deduplicate_hits_keeps_repeated_same_content_at_different_lines() {
14504        let first = SearchHit {
14505            title: "Shared Session".into(),
14506            snippet: String::new(),
14507            content: "repeat me".into(),
14508            content_hash: stable_content_hash("repeat me"),
14509            score: 10.0,
14510            source_path: "/shared/session.jsonl".into(),
14511            agent: "codex".into(),
14512            workspace: "/ws".into(),
14513            workspace_original: None,
14514            created_at: Some(100),
14515            line_number: Some(1),
14516            match_type: MatchType::Exact,
14517            source_id: "local".into(),
14518            origin_kind: "local".into(),
14519            origin_host: None,
14520            conversation_id: None,
14521        };
14522        let mut second = first.clone();
14523        second.line_number = Some(2);
14524        second.created_at = Some(200);
14525        second.score = 9.0;
14526
14527        let deduped = deduplicate_hits(vec![first, second]);
14528        assert_eq!(deduped.len(), 2);
14529    }
14530
14531    #[test]
14532    fn deduplicate_hits_keeps_distinct_conversation_ids_with_same_title_path_and_content() {
14533        let mut first = make_test_hit("same", 1.0);
14534        first.title = "Shared Session".into();
14535        first.source_path = "/shared/session.jsonl".into();
14536        first.content = "identical body".into();
14537        first.content_hash = stable_content_hash("identical body");
14538        first.conversation_id = Some(1);
14539
14540        let mut second = first.clone();
14541        second.conversation_id = Some(2);
14542        second.score = 0.9;
14543
14544        let deduped = deduplicate_hits(vec![first, second]);
14545        assert_eq!(deduped.len(), 2);
14546        assert!(deduped.iter().any(|hit| hit.conversation_id == Some(1)));
14547        assert!(deduped.iter().any(|hit| hit.conversation_id == Some(2)));
14548    }
14549
14550    #[test]
14551    fn deduplicate_hits_coalesces_same_conversation_id_despite_title_drift() {
14552        let mut first = make_test_hit("same", 1.0);
14553        first.title = "Morning Session".into();
14554        first.source_path = "/shared/session.jsonl".into();
14555        first.content = "identical body".into();
14556        first.content_hash = stable_content_hash("identical body");
14557        first.conversation_id = Some(7);
14558
14559        let mut second = first.clone();
14560        second.title = "Evening Session".into();
14561        second.score = 0.9;
14562
14563        let deduped = deduplicate_hits(vec![first, second]);
14564        assert_eq!(deduped.len(), 1);
14565        assert_eq!(deduped[0].conversation_id, Some(7));
14566    }
14567
14568    #[test]
14569    fn deduplicate_hits_keeps_distinct_titles_with_same_source_path_and_content() {
14570        let hits = vec![
14571            SearchHit {
14572                title: "Morning Session".into(),
14573                snippet: "snip1".into(),
14574                content: "hello world".into(),
14575                content_hash: stable_content_hash("hello world"),
14576                score: 0.9,
14577                source_path: "shared.jsonl".into(),
14578                agent: "agent".into(),
14579                workspace: "ws".into(),
14580                workspace_original: None,
14581                created_at: None,
14582                line_number: Some(1),
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: "Evening Session".into(),
14591                snippet: "snip2".into(),
14592                content: "hello world".into(),
14593                content_hash: stable_content_hash("hello world"),
14594                score: 0.8,
14595                source_path: "shared.jsonl".into(),
14596                agent: "agent".into(),
14597                workspace: "ws".into(),
14598                workspace_original: None,
14599                created_at: None,
14600                line_number: Some(1),
14601                match_type: MatchType::Exact,
14602                source_id: "local".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(), 2);
14611        assert!(deduped.iter().any(|hit| hit.title == "Morning Session"));
14612        assert!(deduped.iter().any(|hit| hit.title == "Evening Session"));
14613    }
14614
14615    #[test]
14616    fn deduplicate_hits_normalizes_whitespace() {
14617        let hits = vec![
14618            SearchHit {
14619                title: "title1".into(),
14620                snippet: "snip1".into(),
14621                content: "hello    world".into(), // extra spaces
14622                content_hash: stable_content_hash("hello    world"),
14623                score: 1.0,
14624                source_path: "a.jsonl".into(),
14625                agent: "agent".into(),
14626                workspace: "ws".into(),
14627                workspace_original: None,
14628                created_at: Some(100),
14629                line_number: None,
14630                match_type: MatchType::Exact,
14631                source_id: "local".into(),
14632                origin_kind: "local".into(),
14633                origin_host: None,
14634                conversation_id: None,
14635            },
14636            SearchHit {
14637                title: "title1".into(),
14638                snippet: "snip2".into(),
14639                content: "hello world".into(), // normal spacing
14640                content_hash: stable_content_hash("hello world"),
14641                score: 0.5,
14642                source_path: "a.jsonl".into(),
14643                agent: "agent".into(),
14644                workspace: "ws".into(),
14645                workspace_original: None,
14646                created_at: Some(100),
14647                line_number: None,
14648                match_type: MatchType::Exact,
14649                source_id: "local".into(),
14650                origin_kind: "local".into(),
14651                origin_host: None,
14652                conversation_id: None,
14653            },
14654        ];
14655
14656        let deduped = deduplicate_hits(hits);
14657        assert_eq!(deduped.len(), 1); // normalized to same content
14658    }
14659
14660    #[test]
14661    fn deduplicate_hits_normalizes_blank_local_source_id() {
14662        let hits = vec![
14663            SearchHit {
14664                title: "title1".into(),
14665                snippet: "snip1".into(),
14666                content: "hello world".into(),
14667                content_hash: stable_content_hash("hello world"),
14668                score: 1.0,
14669                source_path: "a.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: "title1".into(),
14683                snippet: "snip2".into(),
14684                content: "hello world".into(),
14685                content_hash: stable_content_hash("hello world"),
14686                score: 0.5,
14687                source_path: "a.jsonl".into(),
14688                agent: "agent".into(),
14689                workspace: "ws".into(),
14690                workspace_original: None,
14691                created_at: Some(100),
14692                line_number: None,
14693                match_type: MatchType::Exact,
14694                source_id: "   ".into(),
14695                origin_kind: "local".into(),
14696                origin_host: None,
14697                conversation_id: None,
14698            },
14699        ];
14700
14701        let deduped = deduplicate_hits(hits);
14702        assert_eq!(deduped.len(), 1);
14703        assert_eq!(deduped[0].source_id, "local");
14704    }
14705
14706    #[test]
14707    fn deduplicate_hits_filters_tool_noise() {
14708        let hits = vec![
14709            SearchHit {
14710                title: "title1".into(),
14711                snippet: "snip1".into(),
14712                content: "[Tool:]".into(), // noise (empty tool name)
14713                content_hash: stable_content_hash("[Tool:]"),
14714                score: 1.0,
14715                source_path: "a.jsonl".into(),
14716                agent: "agent".into(),
14717                workspace: "ws".into(),
14718                workspace_original: None,
14719                created_at: Some(100),
14720                line_number: None,
14721                match_type: MatchType::Exact,
14722                source_id: "local".into(),
14723                origin_kind: "local".into(),
14724                origin_host: None,
14725                conversation_id: None,
14726            },
14727            SearchHit {
14728                title: "title2".into(),
14729                snippet: "snip2".into(),
14730                content: "This is real content about testing".into(),
14731                content_hash: stable_content_hash("This is real content about testing"),
14732                score: 0.5,
14733                source_path: "b.jsonl".into(),
14734                agent: "agent".into(),
14735                workspace: "ws".into(),
14736                workspace_original: None,
14737                created_at: Some(200),
14738                line_number: None,
14739                match_type: MatchType::Exact,
14740                source_id: "local".into(),
14741                origin_kind: "local".into(),
14742                origin_host: None,
14743                conversation_id: None,
14744            },
14745        ];
14746
14747        let deduped = deduplicate_hits(hits);
14748        assert_eq!(deduped.len(), 1);
14749        assert!(deduped[0].content.contains("real content"));
14750    }
14751
14752    #[test]
14753    fn deduplicate_hits_filters_acknowledgement_noise() {
14754        let hits = vec![
14755            SearchHit {
14756                title: "ack".into(),
14757                snippet: "ack".into(),
14758                content: "Acknowledged.".into(),
14759                content_hash: stable_content_hash("Acknowledged."),
14760                score: 1.0,
14761                source_path: "ack.jsonl".into(),
14762                agent: "agent".into(),
14763                workspace: "ws".into(),
14764                workspace_original: None,
14765                created_at: Some(100),
14766                line_number: None,
14767                match_type: MatchType::Exact,
14768                source_id: "local".into(),
14769                origin_kind: "local".into(),
14770                origin_host: None,
14771                conversation_id: None,
14772            },
14773            SearchHit {
14774                title: "real".into(),
14775                snippet: "real".into(),
14776                content: "Authentication refresh logic changed".into(),
14777                content_hash: stable_content_hash("Authentication refresh logic changed"),
14778                score: 0.5,
14779                source_path: "real.jsonl".into(),
14780                agent: "agent".into(),
14781                workspace: "ws".into(),
14782                workspace_original: None,
14783                created_at: Some(200),
14784                line_number: None,
14785                match_type: MatchType::Exact,
14786                source_id: "local".into(),
14787                origin_kind: "local".into(),
14788                origin_host: None,
14789                conversation_id: None,
14790            },
14791        ];
14792
14793        let deduped = deduplicate_hits_with_query(hits, "authentication");
14794        assert_eq!(deduped.len(), 1);
14795        assert_eq!(deduped[0].title, "real");
14796    }
14797
14798    #[test]
14799    fn deduplicate_hits_hides_system_prompts_unless_query_requests_them() {
14800        let prompt_hit = SearchHit {
14801            title: "prompt".into(),
14802            snippet: "prompt".into(),
14803            content:
14804                "# AGENTS.md instructions for /repo\n\nYou are a coding assistant. Follow the instructions exactly."
14805                    .into(),
14806            content_hash: stable_content_hash(
14807                "# AGENTS.md instructions for /repo\n\nYou are a coding assistant. Follow the instructions exactly.",
14808            ),
14809            score: 1.0,
14810            source_path: "prompt.jsonl".into(),
14811            agent: "agent".into(),
14812            workspace: "ws".into(),
14813            workspace_original: None,
14814            created_at: Some(100),
14815            line_number: None,
14816            match_type: MatchType::Exact,
14817            source_id: "local".into(),
14818            origin_kind: "local".into(),
14819            origin_host: None,
14820            conversation_id: None,
14821        };
14822
14823        assert!(
14824            deduplicate_hits_with_query(vec![prompt_hit.clone()], "coding assistant").is_empty()
14825        );
14826
14827        let kept = deduplicate_hits_with_query(vec![prompt_hit], "AGENTS.md instructions");
14828        assert_eq!(kept.len(), 1);
14829        assert_eq!(kept[0].title, "prompt");
14830    }
14831
14832    #[test]
14833    fn deduplicate_hits_preserves_unique_content() {
14834        let hits = vec![
14835            SearchHit {
14836                title: "title1".into(),
14837                snippet: "snip1".into(),
14838                content: "first message".into(),
14839                content_hash: stable_content_hash("first message"),
14840                score: 1.0,
14841                source_path: "a.jsonl".into(),
14842                agent: "agent".into(),
14843                workspace: "ws".into(),
14844                workspace_original: None,
14845                created_at: Some(100),
14846                line_number: None,
14847                match_type: MatchType::Exact,
14848                source_id: "local".into(),
14849                origin_kind: "local".into(),
14850                origin_host: None,
14851                conversation_id: None,
14852            },
14853            SearchHit {
14854                title: "title2".into(),
14855                snippet: "snip2".into(),
14856                content: "second message".into(),
14857                content_hash: stable_content_hash("second message"),
14858                score: 0.8,
14859                source_path: "b.jsonl".into(),
14860                agent: "agent".into(),
14861                workspace: "ws".into(),
14862                workspace_original: None,
14863                created_at: Some(200),
14864                line_number: None,
14865                match_type: MatchType::Exact,
14866                source_id: "local".into(),
14867                origin_kind: "local".into(),
14868                origin_host: None,
14869                conversation_id: None,
14870            },
14871            SearchHit {
14872                title: "title3".into(),
14873                snippet: "snip3".into(),
14874                content: "third message".into(),
14875                content_hash: stable_content_hash("third message"),
14876                score: 0.6,
14877                source_path: "c.jsonl".into(),
14878                agent: "agent".into(),
14879                workspace: "ws".into(),
14880                workspace_original: None,
14881                created_at: Some(300),
14882                line_number: None,
14883                match_type: MatchType::Exact,
14884                source_id: "local".into(),
14885                origin_kind: "local".into(),
14886                origin_host: None,
14887                conversation_id: None,
14888            },
14889        ];
14890
14891        let deduped = deduplicate_hits(hits);
14892        assert_eq!(deduped.len(), 3); // all unique
14893    }
14894
14895    /// P2.3: Deduplication respects source boundaries - same content from different sources
14896    /// should appear as separate results.
14897    #[test]
14898    fn deduplicate_hits_respects_source_boundaries() {
14899        let hits = vec![
14900            SearchHit {
14901                title: "local title".into(),
14902                snippet: "snip".into(),
14903                content: "hello world".into(),
14904                content_hash: stable_content_hash("hello world"),
14905                score: 1.0,
14906                source_path: "a.jsonl".into(),
14907                agent: "agent".into(),
14908                workspace: "ws".into(),
14909                workspace_original: None,
14910                created_at: Some(100),
14911                line_number: None,
14912                match_type: MatchType::Exact,
14913                source_id: "local".into(),
14914                origin_kind: "local".into(),
14915                origin_host: None,
14916                conversation_id: None,
14917            },
14918            SearchHit {
14919                title: "remote title".into(),
14920                snippet: "snip".into(),
14921                content: "hello world".into(), // same content
14922                content_hash: stable_content_hash("hello world"),
14923                score: 0.9,
14924                source_path: "b.jsonl".into(),
14925                agent: "agent".into(),
14926                workspace: "ws".into(),
14927                workspace_original: None,
14928                created_at: Some(200),
14929                line_number: None,
14930                match_type: MatchType::Exact,
14931                source_id: "work-laptop".into(), // different source = no dedupe
14932                origin_kind: "ssh".into(),
14933                origin_host: Some("work-laptop.local".into()),
14934                conversation_id: None,
14935            },
14936        ];
14937
14938        let deduped = deduplicate_hits(hits);
14939        assert_eq!(
14940            deduped.len(),
14941            2,
14942            "same content from different sources should not dedupe"
14943        );
14944        assert!(deduped.iter().any(|h| h.source_id == "local"));
14945        assert!(deduped.iter().any(|h| h.source_id == "work-laptop"));
14946    }
14947
14948    #[test]
14949    fn wildcard_fallback_sparse_check_uses_effective_limit() {
14950        assert!(
14951            !should_try_wildcard_fallback(1, 1, 0, 3),
14952            "a filled one-result page is not sparse for fallback purposes"
14953        );
14954        assert!(
14955            !should_try_wildcard_fallback(2, 2, 0, 3),
14956            "a filled two-result page is not sparse for fallback purposes"
14957        );
14958        assert!(
14959            should_try_wildcard_fallback(0, 1, 0, 3),
14960            "zero hits should still trigger fallback even for tiny pages"
14961        );
14962        assert!(
14963            should_try_wildcard_fallback(1, 2, 0, 3),
14964            "a partially filled page should still trigger fallback"
14965        );
14966        assert!(
14967            !should_try_wildcard_fallback(0, 5, 10, 3),
14968            "pagination should not trigger wildcard fallback"
14969        );
14970        assert!(
14971            should_try_wildcard_fallback(1, 0, 0, 3),
14972            "limit zero preserves the legacy sparse-threshold semantics"
14973        );
14974    }
14975
14976    #[test]
14977    fn snippet_preview_fast_path_requires_snippet_only_match() {
14978        let snippet_only = FieldMask::new(false, true, false, false);
14979        let snippet = snippet_from_preview_without_full_content(
14980            snippet_only,
14981            "migration checks the database constraint before writing",
14982            "database",
14983        )
14984        .expect("preview should satisfy a snippet-only request when it contains the query");
14985        assert!(snippet.contains("**database**"));
14986
14987        assert!(
14988            snippet_from_preview_without_full_content(
14989                FieldMask::FULL,
14990                "migration checks the database constraint before writing",
14991                "database",
14992            )
14993            .is_none(),
14994            "full-content requests must keep the sqlite hydration path"
14995        );
14996        assert!(
14997            snippet_from_preview_without_full_content(
14998                snippet_only,
14999                "migration checks constraints before writing",
15000                "database",
15001            )
15002            .is_none(),
15003            "snippet-only requests hydrate when the preview cannot show the match"
15004        );
15005    }
15006
15007    #[test]
15008    fn search_with_fallback_returns_exact_when_sufficient() -> Result<()> {
15009        let dir = TempDir::new()?;
15010        let mut index = TantivyIndex::open_or_create(dir.path())?;
15011
15012        // Add enough docs to exceed threshold - each with UNIQUE content to avoid dedup
15013        for i in 0..5 {
15014            let conv = NormalizedConversation {
15015                agent_slug: "codex".into(),
15016                external_id: None,
15017                title: Some(format!("doc-{i}")),
15018                workspace: Some(std::path::PathBuf::from("/ws")),
15019                source_path: dir.path().join(format!("{i}.jsonl")),
15020                started_at: Some(100 + i),
15021                ended_at: None,
15022                metadata: serde_json::json!({}),
15023                messages: vec![NormalizedMessage {
15024                    idx: 0,
15025                    role: "user".into(),
15026                    author: None,
15027                    created_at: Some(100 + i),
15028                    // Each doc has unique content but shares "apple" keyword
15029                    content: format!("apple fruit number {i} is delicious and healthy"),
15030                    extra: serde_json::json!({}),
15031                    snippets: vec![],
15032                    invocations: Vec::new(),
15033                }],
15034            };
15035            index.add_conversation(&conv)?;
15036        }
15037        index.commit()?;
15038
15039        let client = SearchClient::open(dir.path(), None)?.expect("index present");
15040
15041        // Search with low threshold - should not trigger fallback
15042        let result = client.search_with_fallback(
15043            "apple",
15044            SearchFilters::default(),
15045            10,
15046            0,
15047            3, // threshold of 3
15048            FieldMask::FULL,
15049        )?;
15050
15051        assert!(!result.wildcard_fallback);
15052        assert!(result.hits.len() >= 3); // has enough results
15053        assert_eq!(result.total_count, Some(5));
15054
15055        Ok(())
15056    }
15057
15058    #[test]
15059    fn search_with_fallback_triggers_on_sparse_results() -> Result<()> {
15060        let dir = TempDir::new()?;
15061        let mut index = TantivyIndex::open_or_create(dir.path())?;
15062
15063        // Add docs with substring that won't match exact prefix
15064        let conv = NormalizedConversation {
15065            agent_slug: "codex".into(),
15066            external_id: None,
15067            title: Some("substring test".into()),
15068            workspace: Some(std::path::PathBuf::from("/ws")),
15069            source_path: dir.path().join("test.jsonl"),
15070            started_at: Some(100),
15071            ended_at: None,
15072            metadata: serde_json::json!({}),
15073            messages: vec![NormalizedMessage {
15074                idx: 0,
15075                role: "user".into(),
15076                author: None,
15077                created_at: Some(100),
15078                content: "configuration management system".into(),
15079                extra: serde_json::json!({}),
15080                snippets: vec![],
15081                invocations: Vec::new(),
15082            }],
15083        };
15084        index.add_conversation(&conv)?;
15085        index.commit()?;
15086
15087        let client = SearchClient::open(dir.path(), None)?.expect("index present");
15088
15089        // Search for "config" which should match "configuration" via prefix
15090        let result = client.search_with_fallback(
15091            "config",
15092            SearchFilters::default(),
15093            10,
15094            0,
15095            5, // high threshold
15096            FieldMask::FULL,
15097        )?;
15098
15099        // Since we have only 1 result and threshold is 5, it may trigger fallback
15100        // but *config* would still match "configuration"
15101        assert!(!result.hits.is_empty());
15102
15103        Ok(())
15104    }
15105
15106    #[test]
15107    fn search_with_fallback_skips_when_query_has_wildcards() -> Result<()> {
15108        let dir = TempDir::new()?;
15109        let mut index = TantivyIndex::open_or_create(dir.path())?;
15110
15111        let conv = NormalizedConversation {
15112            agent_slug: "codex".into(),
15113            external_id: None,
15114            title: Some("test".into()),
15115            workspace: None,
15116            source_path: dir.path().join("test.jsonl"),
15117            started_at: Some(100),
15118            ended_at: None,
15119            metadata: serde_json::json!({}),
15120            messages: vec![NormalizedMessage {
15121                idx: 0,
15122                role: "user".into(),
15123                author: None,
15124                created_at: Some(100),
15125                content: "testing data".into(),
15126                extra: serde_json::json!({}),
15127                snippets: vec![],
15128                invocations: Vec::new(),
15129            }],
15130        };
15131        index.add_conversation(&conv)?;
15132        index.commit()?;
15133
15134        let client = SearchClient::open(dir.path(), None)?.expect("index present");
15135
15136        // Query already has wildcards - should not trigger fallback
15137        let result = client.search_with_fallback(
15138            "*test*",
15139            SearchFilters::default(),
15140            10,
15141            0,
15142            10, // high threshold
15143            FieldMask::FULL,
15144        )?;
15145
15146        assert!(!result.wildcard_fallback); // shouldn't trigger fallback for wildcard queries
15147        Ok(())
15148    }
15149
15150    #[test]
15151    fn search_with_fallback_prefers_wildcards_when_they_add_hits() -> Result<()> {
15152        let dir = TempDir::new()?;
15153        let mut index = TantivyIndex::open_or_create(dir.path())?;
15154
15155        // None of these documents contain the exact token "bet",
15156        // but they do contain it as a substring ("alphabet").
15157        for (i, body) in [
15158            "alphabet soup for coders",
15159            "mapping the alphabet city blocks",
15160        ]
15161        .iter()
15162        .enumerate()
15163        {
15164            let conv = NormalizedConversation {
15165                agent_slug: "codex".into(),
15166                external_id: None,
15167                title: Some(format!("alpha-{i}")),
15168                workspace: Some(std::path::PathBuf::from("/ws")),
15169                source_path: dir.path().join(format!("alpha-{i}.jsonl")),
15170                started_at: Some(100 + i as i64),
15171                ended_at: None,
15172                metadata: serde_json::json!({}),
15173                messages: vec![NormalizedMessage {
15174                    idx: 0,
15175                    role: "user".into(),
15176                    author: None,
15177                    created_at: Some(100 + i as i64),
15178                    content: body.to_string(),
15179                    extra: serde_json::json!({}),
15180                    snippets: vec![],
15181                    invocations: Vec::new(),
15182                }],
15183            };
15184            index.add_conversation(&conv)?;
15185        }
15186        index.commit()?;
15187
15188        let client = SearchClient::open(dir.path(), None)?.expect("index present");
15189
15190        let result = client.search_with_fallback(
15191            "bet",
15192            SearchFilters::default(),
15193            10,
15194            0,
15195            2,
15196            FieldMask::FULL,
15197        )?;
15198
15199        assert!(
15200            result.wildcard_fallback,
15201            "should switch to wildcard fallback when it yields more hits"
15202        );
15203        assert_eq!(
15204            result.hits.len(),
15205            2,
15206            "fallback should surface all alphabet docs"
15207        );
15208        assert!(
15209            result
15210                .hits
15211                .iter()
15212                .all(|h| h.match_type == MatchType::ImplicitWildcard)
15213        );
15214        assert!(result.hits.iter().all(|h| h.content.contains("alphabet")));
15215
15216        Ok(())
15217    }
15218
15219    #[test]
15220    fn automatic_wildcard_fallback_skips_long_zero_hit_token() -> Result<()> {
15221        let dir = TempDir::new()?;
15222        let mut index = TantivyIndex::open_or_create(dir.path())?;
15223
15224        let conv = NormalizedConversation {
15225            agent_slug: "codex".into(),
15226            external_id: None,
15227            title: Some("fruit".into()),
15228            workspace: Some(std::path::PathBuf::from("/ws")),
15229            source_path: dir.path().join("fruit.jsonl"),
15230            started_at: Some(100),
15231            ended_at: None,
15232            metadata: serde_json::json!({}),
15233            messages: vec![NormalizedMessage {
15234                idx: 0,
15235                role: "user".into(),
15236                author: None,
15237                created_at: Some(100),
15238                content: "apple pear banana".into(),
15239                extra: serde_json::json!({}),
15240                snippets: vec![],
15241                invocations: Vec::new(),
15242            }],
15243        };
15244        index.add_conversation(&conv)?;
15245        index.commit()?;
15246
15247        let client = SearchClient::open(dir.path(), None)?.expect("index present");
15248
15249        let result = client.search_with_fallback(
15250            "zzzzzzunlikelyterm",
15251            SearchFilters::default(),
15252            10,
15253            0,
15254            1,
15255            FieldMask::FULL,
15256        )?;
15257        assert!(result.hits.is_empty());
15258        assert!(!result.wildcard_fallback);
15259        assert!(
15260            result
15261                .suggestions
15262                .iter()
15263                .any(|s| matches!(s.kind, SuggestionKind::WildcardQuery)),
15264            "manual wildcard suggestion should remain available"
15265        );
15266
15267        let short_result = client.search_with_fallback(
15268            "pple",
15269            SearchFilters::default(),
15270            10,
15271            0,
15272            1,
15273            FieldMask::FULL,
15274        )?;
15275        assert!(short_result.wildcard_fallback);
15276        assert_eq!(short_result.hits.len(), 1);
15277        assert_eq!(short_result.hits[0].match_type, MatchType::ImplicitWildcard);
15278
15279        Ok(())
15280    }
15281
15282    #[test]
15283    fn nohit_suggestions_do_not_lazy_open_sqlite_when_tantivy_is_present() -> Result<()> {
15284        let dir = TempDir::new()?;
15285        let index_path = dir.path().join("index");
15286        let db_path = dir.path().join("cass.db");
15287
15288        let storage = FrankenStorage::open(&db_path)?;
15289        storage.close()?;
15290
15291        let mut index = TantivyIndex::open_or_create(&index_path)?;
15292        let conv = NormalizedConversation {
15293            agent_slug: "codex".into(),
15294            external_id: None,
15295            title: Some("fruit".into()),
15296            workspace: Some(std::path::PathBuf::from("/ws")),
15297            source_path: dir.path().join("fruit.jsonl"),
15298            started_at: Some(100),
15299            ended_at: None,
15300            metadata: serde_json::json!({}),
15301            messages: vec![NormalizedMessage {
15302                idx: 0,
15303                role: "user".into(),
15304                author: None,
15305                created_at: Some(100),
15306                content: "apple pear banana".into(),
15307                extra: serde_json::json!({}),
15308                snippets: vec![],
15309                invocations: Vec::new(),
15310            }],
15311        };
15312        index.add_conversation(&conv)?;
15313        index.commit()?;
15314
15315        let client = SearchClient::open(&index_path, Some(&db_path))?.expect("index present");
15316        assert!(
15317            client
15318                .sqlite
15319                .lock()
15320                .map(|guard| guard.is_none())
15321                .unwrap_or(false),
15322            "sqlite should start closed"
15323        );
15324
15325        let result = client.search_with_fallback(
15326            "zzzzzzunlikelyterm",
15327            SearchFilters::default(),
15328            10,
15329            0,
15330            1,
15331            FieldMask::FULL,
15332        )?;
15333
15334        assert!(result.hits.is_empty());
15335        assert!(
15336            result
15337                .suggestions
15338                .iter()
15339                .any(|s| matches!(s.kind, SuggestionKind::WildcardQuery)),
15340            "manual wildcard suggestion should remain available"
15341        );
15342        assert!(
15343            result
15344                .suggestions
15345                .iter()
15346                .all(|s| !matches!(s.kind, SuggestionKind::AlternateAgent)),
15347            "alternate-agent suggestions should not force a SQLite open"
15348        );
15349        assert!(
15350            client
15351                .sqlite
15352                .lock()
15353                .map(|guard| guard.is_none())
15354                .unwrap_or(false),
15355            "sqlite should stay closed after Tantivy no-hit suggestions"
15356        );
15357
15358        Ok(())
15359    }
15360
15361    #[test]
15362    fn search_with_fallback_emits_wildcard_suggestion_on_zero_hits() -> Result<()> {
15363        let client = SearchClient {
15364            reader: None,
15365            sqlite: Mutex::new(None),
15366            sqlite_path: None,
15367            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
15368            reload_on_search: true,
15369            last_reload: Mutex::new(None),
15370            last_generation: Mutex::new(None),
15371            reload_epoch: Arc::new(AtomicU64::new(0)),
15372            warm_tx: None,
15373            _warm_handle: None,
15374            metrics: Metrics::default(),
15375            cache_namespace: "vtest|schema:none".into(),
15376            semantic: Mutex::new(None),
15377            last_tantivy_total_count: Mutex::new(None),
15378        };
15379
15380        let result = client.search_with_fallback(
15381            "ghost",
15382            SearchFilters::default(),
15383            5,
15384            0,
15385            3,
15386            FieldMask::FULL,
15387        )?;
15388
15389        assert!(
15390            result.hits.is_empty(),
15391            "no index/db means no hits should be returned"
15392        );
15393        assert!(
15394            !result.wildcard_fallback,
15395            "with zero baseline and fallback hits, we should keep baseline and mark fallback=false"
15396        );
15397
15398        let wildcard = result
15399            .suggestions
15400            .iter()
15401            .find(|s| matches!(s.kind, SuggestionKind::WildcardQuery))
15402            .expect("should suggest adding wildcards");
15403        assert_eq!(wildcard.suggested_query.as_deref(), Some("*ghost*"));
15404
15405        Ok(())
15406    }
15407
15408    #[test]
15409    fn search_with_fallback_skips_empty_query() -> Result<()> {
15410        let dir = TempDir::new()?;
15411        let mut index = TantivyIndex::open_or_create(dir.path())?;
15412
15413        let conv = NormalizedConversation {
15414            agent_slug: "codex".into(),
15415            external_id: None,
15416            title: Some("test".into()),
15417            workspace: None,
15418            source_path: dir.path().join("test.jsonl"),
15419            started_at: Some(100),
15420            ended_at: None,
15421            metadata: serde_json::json!({}),
15422            messages: vec![NormalizedMessage {
15423                idx: 0,
15424                role: "user".into(),
15425                author: None,
15426                created_at: Some(100),
15427                content: "testing data".into(),
15428                extra: serde_json::json!({}),
15429                snippets: vec![],
15430                invocations: Vec::new(),
15431            }],
15432        };
15433        index.add_conversation(&conv)?;
15434        index.commit()?;
15435
15436        let client = SearchClient::open(dir.path(), None)?.expect("index present");
15437
15438        // Empty query - should not trigger fallback
15439        let result = client.search_with_fallback(
15440            "  ",
15441            SearchFilters::default(),
15442            10,
15443            0,
15444            10,
15445            FieldMask::FULL,
15446        )?;
15447
15448        assert!(!result.wildcard_fallback);
15449        Ok(())
15450    }
15451
15452    #[test]
15453    fn search_with_fallback_skips_for_nonzero_offset() -> Result<()> {
15454        // Even with zero hits, fallback should not run when paginating (offset > 0)
15455        let client = SearchClient {
15456            reader: None,
15457            sqlite: Mutex::new(None),
15458            sqlite_path: None,
15459            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
15460            reload_on_search: true,
15461            last_reload: Mutex::new(None),
15462            last_generation: Mutex::new(None),
15463            reload_epoch: Arc::new(AtomicU64::new(0)),
15464            warm_tx: None,
15465            _warm_handle: None,
15466            metrics: Metrics::default(),
15467            cache_namespace: "vtest|schema:none".into(),
15468            semantic: Mutex::new(None),
15469            last_tantivy_total_count: Mutex::new(None),
15470        };
15471
15472        let result = client.search_with_fallback(
15473            "ghost",
15474            SearchFilters::default(),
15475            5,
15476            10,
15477            3,
15478            FieldMask::FULL,
15479        )?;
15480
15481        assert!(
15482            !result.wildcard_fallback,
15483            "fallback should not run on paginated searches"
15484        );
15485        // Suggestions still surface (wildcard suggestion expected)
15486        let wildcard = result
15487            .suggestions
15488            .iter()
15489            .find(|s| matches!(s.kind, SuggestionKind::WildcardQuery))
15490            .expect("wildcard suggestion present");
15491        assert_eq!(wildcard.suggested_query.as_deref(), Some("*ghost*"));
15492
15493        Ok(())
15494    }
15495
15496    #[test]
15497    fn generate_suggestions_limits_and_sets_shortcuts() -> Result<()> {
15498        // Build a client without backends; suggestions are purely local heuristics
15499        let client = SearchClient {
15500            reader: None,
15501            sqlite: Mutex::new(None),
15502            sqlite_path: None,
15503            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
15504            reload_on_search: true,
15505            last_reload: Mutex::new(None),
15506            last_generation: Mutex::new(None),
15507            reload_epoch: Arc::new(AtomicU64::new(0)),
15508            warm_tx: None,
15509            _warm_handle: None,
15510            metrics: Metrics::default(),
15511            cache_namespace: "vtest|schema:none".into(),
15512            semantic: Mutex::new(None),
15513            last_tantivy_total_count: Mutex::new(None),
15514        };
15515
15516        let mut filters = SearchFilters::default();
15517        filters.agents.insert("codex".into()); // triggers remove-agent suggestion
15518
15519        let result = client.search_with_fallback("claud", filters, 5, 0, 3, FieldMask::FULL)?;
15520
15521        // Should cap at 3 suggestions with shortcuts 1..=3
15522        assert_eq!(
15523            result.suggestions.len(),
15524            3,
15525            "should truncate to 3 suggestions"
15526        );
15527        for (idx, sugg) in result.suggestions.iter().enumerate() {
15528            assert_eq!(
15529                sugg.shortcut,
15530                Some((idx + 1) as u8),
15531                "shortcut should match position (1-based)"
15532            );
15533        }
15534
15535        // Expect wildcard, remove filter, and spelling fix (claud -> claude)
15536        assert!(
15537            result
15538                .suggestions
15539                .iter()
15540                .any(|s| matches!(s.kind, SuggestionKind::WildcardQuery)),
15541            "should suggest wildcard search"
15542        );
15543        assert!(
15544            result
15545                .suggestions
15546                .iter()
15547                .any(|s| matches!(s.kind, SuggestionKind::RemoveFilter)),
15548            "should suggest removing agent filter"
15549        );
15550        assert!(
15551            result
15552                .suggestions
15553                .iter()
15554                .any(|s| matches!(s.kind, SuggestionKind::SpellingFix)),
15555            "should suggest spelling fix for nearby agent name"
15556        );
15557
15558        Ok(())
15559    }
15560
15561    #[test]
15562    fn generate_suggestions_includes_recent_alternate_agents() -> Result<()> {
15563        let dir = TempDir::new()?;
15564        let db_path = dir.path().join("cass.db");
15565        let storage = FrankenStorage::open(&db_path)?;
15566        let workspace_id = storage.ensure_workspace(dir.path(), None)?;
15567        let base_ts = 1_700_000_010_000_i64;
15568
15569        for (idx, slug) in ["claude_code", "codex"].iter().enumerate() {
15570            let agent = Agent {
15571                id: None,
15572                slug: (*slug).to_string(),
15573                name: (*slug).to_string(),
15574                version: None,
15575                kind: AgentKind::Cli,
15576            };
15577            let agent_id = storage.ensure_agent(&agent)?;
15578            let conversation = Conversation {
15579                id: None,
15580                agent_slug: (*slug).to_string(),
15581                workspace: Some(dir.path().to_path_buf()),
15582                external_id: Some(format!("alt-agent-{idx}")),
15583                title: Some(format!("alternate agent {idx}")),
15584                source_path: dir.path().join(format!("{slug}.jsonl")),
15585                started_at: Some(base_ts + idx as i64),
15586                ended_at: Some(base_ts + idx as i64),
15587                approx_tokens: Some(8),
15588                metadata_json: json!({}),
15589                messages: vec![Message {
15590                    id: None,
15591                    idx: 0,
15592                    role: MessageRole::User,
15593                    author: Some("user".into()),
15594                    created_at: Some(base_ts + idx as i64),
15595                    content: format!("content from {slug}"),
15596                    extra_json: json!({}),
15597                    snippets: Vec::new(),
15598                }],
15599                source_id: crate::sources::provenance::LOCAL_SOURCE_ID.to_string(),
15600                origin_host: None,
15601            };
15602            storage.insert_conversation_tree(agent_id, Some(workspace_id), &conversation)?;
15603        }
15604        drop(storage);
15605
15606        let client = SearchClient::open(dir.path(), Some(&db_path))?.expect("db-backed client");
15607        let result = client.search_with_fallback(
15608            "ghost",
15609            SearchFilters::default(),
15610            5,
15611            0,
15612            3,
15613            FieldMask::FULL,
15614        )?;
15615
15616        let alternate_agents: HashSet<String> = result
15617            .suggestions
15618            .iter()
15619            .filter(|suggestion| matches!(suggestion.kind, SuggestionKind::AlternateAgent))
15620            .filter_map(|suggestion| suggestion.suggested_filters.as_ref())
15621            .flat_map(|filters| filters.agents.iter().cloned())
15622            .collect();
15623
15624        assert!(
15625            alternate_agents.contains("claude_code"),
15626            "should suggest claude_code from normalized conversations schema"
15627        );
15628        assert!(
15629            alternate_agents.contains("codex"),
15630            "should suggest codex from normalized conversations schema"
15631        );
15632
15633        Ok(())
15634    }
15635
15636    #[test]
15637    fn sanitize_query_preserves_wildcards() {
15638        // Wildcards should be preserved
15639        assert_eq!(fs_cass_sanitize_query("*foo*"), "*foo*");
15640        assert_eq!(fs_cass_sanitize_query("foo*"), "foo*");
15641        assert_eq!(fs_cass_sanitize_query("*bar"), "*bar");
15642        assert_eq!(fs_cass_sanitize_query("*config*"), "*config*");
15643    }
15644
15645    #[test]
15646    fn sanitize_query_strips_other_special_chars() {
15647        // Non-wildcard special chars become spaces
15648        assert_eq!(fs_cass_sanitize_query("foo.bar"), "foo bar");
15649        assert_eq!(fs_cass_sanitize_query("c++"), "c  ");
15650        assert_eq!(fs_cass_sanitize_query("foo-bar"), "foo-bar");
15651        assert_eq!(fs_cass_sanitize_query("test_case"), "test case");
15652    }
15653
15654    #[test]
15655    fn sanitize_query_combined() {
15656        // Mix of wildcards and special chars
15657        assert_eq!(fs_cass_sanitize_query("*foo.bar*"), "*foo bar*");
15658        assert_eq!(fs_cass_sanitize_query("test-*"), "test-*");
15659        assert_eq!(fs_cass_sanitize_query("*c++*"), "*c  *");
15660    }
15661
15662    // Boolean query parsing tests
15663    #[test]
15664    fn parse_boolean_query_simple_terms() {
15665        let tokens = fs_cass_parse_boolean_query("foo bar baz");
15666        assert_eq!(tokens.len(), 3);
15667        assert_eq!(tokens[0], FsCassQueryToken::Term("foo".to_string()));
15668        assert_eq!(tokens[1], FsCassQueryToken::Term("bar".to_string()));
15669        assert_eq!(tokens[2], FsCassQueryToken::Term("baz".to_string()));
15670    }
15671
15672    #[test]
15673    fn parse_boolean_query_and_operator() {
15674        let tokens = fs_cass_parse_boolean_query("foo AND bar");
15675        assert_eq!(tokens.len(), 3);
15676        assert_eq!(tokens[0], FsCassQueryToken::Term("foo".to_string()));
15677        assert_eq!(tokens[1], FsCassQueryToken::And);
15678        assert_eq!(tokens[2], FsCassQueryToken::Term("bar".to_string()));
15679
15680        // Also test && syntax
15681        let tokens2 = fs_cass_parse_boolean_query("foo && bar");
15682        assert_eq!(tokens2.len(), 3);
15683        assert_eq!(tokens2[1], FsCassQueryToken::And);
15684    }
15685
15686    #[test]
15687    fn parse_boolean_query_or_operator() {
15688        let tokens = fs_cass_parse_boolean_query("foo OR bar");
15689        assert_eq!(tokens.len(), 3);
15690        assert_eq!(tokens[0], FsCassQueryToken::Term("foo".to_string()));
15691        assert_eq!(tokens[1], FsCassQueryToken::Or);
15692        assert_eq!(tokens[2], FsCassQueryToken::Term("bar".to_string()));
15693
15694        // Also test || syntax
15695        let tokens2 = fs_cass_parse_boolean_query("foo || bar");
15696        assert_eq!(tokens2.len(), 3);
15697        assert_eq!(tokens2[1], FsCassQueryToken::Or);
15698    }
15699
15700    #[test]
15701    fn parse_boolean_query_not_operator() {
15702        let tokens = fs_cass_parse_boolean_query("foo NOT bar");
15703        assert_eq!(tokens.len(), 3);
15704        assert_eq!(tokens[0], FsCassQueryToken::Term("foo".to_string()));
15705        assert_eq!(tokens[1], FsCassQueryToken::Not);
15706        assert_eq!(tokens[2], FsCassQueryToken::Term("bar".to_string()));
15707    }
15708
15709    #[test]
15710    fn parse_boolean_query_quoted_phrase() {
15711        let tokens = fs_cass_parse_boolean_query(r#"foo "exact phrase" bar"#);
15712        assert_eq!(tokens.len(), 3);
15713        assert_eq!(tokens[0], FsCassQueryToken::Term("foo".to_string()));
15714        assert_eq!(
15715            tokens[1],
15716            FsCassQueryToken::Phrase("exact phrase".to_string())
15717        );
15718        assert_eq!(tokens[2], FsCassQueryToken::Term("bar".to_string()));
15719    }
15720
15721    #[test]
15722    fn parse_boolean_query_complex() {
15723        let tokens = fs_cass_parse_boolean_query(r#"error OR warning NOT "false positive""#);
15724        assert_eq!(tokens.len(), 5);
15725        assert_eq!(tokens[0], FsCassQueryToken::Term("error".to_string()));
15726        assert_eq!(tokens[1], FsCassQueryToken::Or);
15727        assert_eq!(tokens[2], FsCassQueryToken::Term("warning".to_string()));
15728        assert_eq!(tokens[3], FsCassQueryToken::Not);
15729        assert_eq!(
15730            tokens[4],
15731            FsCassQueryToken::Phrase("false positive".to_string())
15732        );
15733    }
15734
15735    #[test]
15736    fn has_boolean_operators_detection() {
15737        assert!(!fs_cass_has_boolean_operators("foo bar"));
15738        assert!(fs_cass_has_boolean_operators("foo AND bar"));
15739        assert!(fs_cass_has_boolean_operators("foo OR bar"));
15740        assert!(fs_cass_has_boolean_operators("foo NOT bar"));
15741        assert!(fs_cass_has_boolean_operators(r#""exact phrase""#));
15742        assert!(fs_cass_has_boolean_operators("foo && bar"));
15743        assert!(fs_cass_has_boolean_operators("foo || bar"));
15744    }
15745
15746    #[test]
15747    fn parse_boolean_query_case_insensitive_operators() {
15748        // Operators should be case-insensitive
15749        let tokens = fs_cass_parse_boolean_query("foo and bar or baz not qux");
15750        assert_eq!(tokens.len(), 7);
15751        assert_eq!(tokens[1], FsCassQueryToken::And);
15752        assert_eq!(tokens[3], FsCassQueryToken::Or);
15753        assert_eq!(tokens[5], FsCassQueryToken::Not);
15754    }
15755
15756    #[test]
15757    fn parse_boolean_query_with_wildcards() {
15758        let tokens = fs_cass_parse_boolean_query("*config* OR env*");
15759        assert_eq!(tokens.len(), 3);
15760        assert_eq!(tokens[0], FsCassQueryToken::Term("*config*".to_string()));
15761        assert_eq!(tokens[1], FsCassQueryToken::Or);
15762        assert_eq!(tokens[2], FsCassQueryToken::Term("env*".to_string()));
15763    }
15764
15765    // ============================================================
15766    // Filter Fidelity Property Tests (glt.9)
15767    // Verify filters are never violated in search results
15768    // ============================================================
15769
15770    #[test]
15771    fn tantivy_search_hydrates_long_content_when_content_field_is_not_stored() -> Result<()> {
15772        let dir = TempDir::new()?;
15773        let db_path = dir.path().join("cass.db");
15774        let storage = FrankenStorage::open(&db_path)?;
15775        let workspace_id = storage.ensure_workspace(dir.path(), None)?;
15776        let agent = Agent {
15777            id: None,
15778            slug: "codex".into(),
15779            name: "Codex".into(),
15780            version: None,
15781            kind: AgentKind::Cli,
15782        };
15783        let agent_id = storage.ensure_agent(&agent)?;
15784        let long_content = format!(
15785            "{}needle appears past the preview boundary for hydration proof",
15786            "padding ".repeat(70)
15787        );
15788        let short_content = "shortneedle fits entirely inside the stored preview".to_string();
15789        let conversation = Conversation {
15790            id: None,
15791            agent_slug: "codex".into(),
15792            workspace: Some(dir.path().to_path_buf()),
15793            external_id: Some("hydrate-long-content".into()),
15794            title: Some("hydrated lexical doc".into()),
15795            source_path: dir.path().join("hydrate.jsonl"),
15796            started_at: Some(1_700_000_123_000),
15797            ended_at: Some(1_700_000_123_000),
15798            approx_tokens: Some(32),
15799            metadata_json: json!({}),
15800            messages: vec![
15801                Message {
15802                    id: None,
15803                    idx: 0,
15804                    role: MessageRole::User,
15805                    author: Some("user".into()),
15806                    created_at: Some(1_700_000_123_000),
15807                    content: long_content.clone(),
15808                    extra_json: json!({}),
15809                    snippets: Vec::new(),
15810                },
15811                Message {
15812                    id: None,
15813                    idx: 1,
15814                    role: MessageRole::Agent,
15815                    author: Some("assistant".into()),
15816                    created_at: Some(1_700_000_124_000),
15817                    content: short_content.clone(),
15818                    extra_json: json!({}),
15819                    snippets: Vec::new(),
15820                },
15821            ],
15822            source_id: crate::sources::provenance::LOCAL_SOURCE_ID.to_string(),
15823            origin_host: None,
15824        };
15825        storage.insert_conversation_tree(agent_id, Some(workspace_id), &conversation)?;
15826        storage.close()?;
15827
15828        let index_path = dir.path().join("search-index");
15829        let mut index = TantivyIndex::open_or_create(&index_path)?;
15830        let normalized = NormalizedConversation {
15831            agent_slug: "codex".into(),
15832            external_id: Some("hydrate-long-content".into()),
15833            title: Some("hydrated lexical doc".into()),
15834            workspace: Some(dir.path().to_path_buf()),
15835            source_path: dir.path().join("hydrate.jsonl"),
15836            started_at: Some(1_700_000_123_000),
15837            ended_at: Some(1_700_000_123_000),
15838            metadata: json!({}),
15839            messages: vec![
15840                NormalizedMessage {
15841                    idx: 0,
15842                    role: "user".into(),
15843                    author: Some("user".into()),
15844                    created_at: Some(1_700_000_123_000),
15845                    content: long_content.clone(),
15846                    extra: json!({}),
15847                    snippets: vec![],
15848                    invocations: Vec::new(),
15849                },
15850                NormalizedMessage {
15851                    idx: 1,
15852                    role: "assistant".into(),
15853                    author: Some("assistant".into()),
15854                    created_at: Some(1_700_000_124_000),
15855                    content: short_content.clone(),
15856                    extra: json!({}),
15857                    snippets: vec![],
15858                    invocations: Vec::new(),
15859                },
15860            ],
15861        };
15862        index.add_conversation(&normalized)?;
15863        index.commit()?;
15864
15865        let client = SearchClient::open(&index_path, Some(&db_path))?.expect("db-backed client");
15866        let hits = client.search("needle", SearchFilters::default(), 5, 0, FieldMask::FULL)?;
15867
15868        assert_eq!(hits.len(), 1, "expected one lexical hit");
15869        assert_eq!(hits[0].title, "hydrated lexical doc");
15870        assert!(
15871            hits[0]
15872                .content
15873                .contains("needle appears past the preview boundary"),
15874            "lexical hit should hydrate full content from sqlite when Tantivy content is not stored"
15875        );
15876        assert!(
15877            hits[0].snippet.to_lowercase().contains("needle"),
15878            "snippet should still be rendered from hydrated content"
15879        );
15880
15881        let bounded_hits = client.search(
15882            "needle",
15883            SearchFilters::default(),
15884            5,
15885            0,
15886            FieldMask::FULL.with_preview_content_limit(Some(200)),
15887        )?;
15888
15889        assert_eq!(bounded_hits.len(), 1, "expected one lexical hit");
15890        assert!(
15891            bounded_hits[0].content.starts_with("padding padding"),
15892            "bounded content may be served from the stored preview prefix"
15893        );
15894        assert!(
15895            !bounded_hits[0]
15896                .content
15897                .contains("needle appears past the preview boundary"),
15898            "bounded preview content should not hydrate the full sqlite row"
15899        );
15900
15901        let short_client =
15902            SearchClient::open(&index_path, Some(&db_path))?.expect("db-backed client");
15903        assert!(
15904            short_client
15905                .sqlite
15906                .lock()
15907                .map(|guard| guard.is_none())
15908                .unwrap_or(false),
15909            "sqlite should start closed for short preview hit"
15910        );
15911
15912        let short_hits = short_client.search(
15913            "shortneedle",
15914            SearchFilters::default(),
15915            5,
15916            0,
15917            FieldMask::FULL,
15918        )?;
15919
15920        assert_eq!(short_hits.len(), 1, "expected one short lexical hit");
15921        assert_eq!(
15922            short_hits[0].content, short_content,
15923            "untruncated stored preview is exact full content"
15924        );
15925        assert!(
15926            short_client
15927                .sqlite
15928                .lock()
15929                .map(|guard| guard.is_none())
15930                .unwrap_or(false),
15931            "short full-content hit should not lazy-open sqlite"
15932        );
15933
15934        Ok(())
15935    }
15936
15937    #[test]
15938    fn filter_fidelity_agent_filter_respected() -> Result<()> {
15939        // Multiple agents; filter should return only matching agent
15940        let dir = TempDir::new()?;
15941        let mut index = TantivyIndex::open_or_create(dir.path())?;
15942
15943        // Agent A (codex)
15944        let conv_a = NormalizedConversation {
15945            agent_slug: "codex".into(),
15946            external_id: None,
15947            title: Some("alpha doc".into()),
15948            workspace: None,
15949            source_path: dir.path().join("a.jsonl"),
15950            started_at: Some(100),
15951            ended_at: None,
15952            metadata: serde_json::json!({}),
15953            messages: vec![NormalizedMessage {
15954                idx: 0,
15955                role: "user".into(),
15956                author: None,
15957                created_at: Some(100),
15958                content: "hello world findme alpha".into(),
15959                extra: serde_json::json!({}),
15960                snippets: vec![],
15961                invocations: Vec::new(),
15962            }],
15963        };
15964        // Agent B (claude)
15965        let conv_b = NormalizedConversation {
15966            agent_slug: "claude".into(),
15967            external_id: None,
15968            title: Some("beta doc".into()),
15969            workspace: None,
15970            source_path: dir.path().join("b.jsonl"),
15971            started_at: Some(200),
15972            ended_at: None,
15973            metadata: serde_json::json!({}),
15974            messages: vec![NormalizedMessage {
15975                idx: 0,
15976                role: "user".into(),
15977                author: None,
15978                created_at: Some(200),
15979                content: "hello world findme beta".into(),
15980                extra: serde_json::json!({}),
15981                snippets: vec![],
15982                invocations: Vec::new(),
15983            }],
15984        };
15985        index.add_conversation(&conv_a)?;
15986        index.add_conversation(&conv_b)?;
15987        index.commit()?;
15988
15989        let client = SearchClient::open(dir.path(), None)?.expect("index present");
15990
15991        // Search with agent filter for codex only
15992        let mut filters = SearchFilters::default();
15993        filters.agents.insert("codex".into());
15994
15995        let hits = client.search("findme", filters.clone(), 10, 0, FieldMask::FULL)?;
15996
15997        // Property: all results must have agent == "codex"
15998        for hit in &hits {
15999            assert_eq!(
16000                hit.agent, "codex",
16001                "Agent filter violated: got agent '{}' instead of 'codex'",
16002                hit.agent
16003            );
16004        }
16005        assert!(!hits.is_empty(), "Should have found results");
16006
16007        // Repeat search (should use cache) and verify same property
16008        let cached_hits = client.search("findme", filters, 10, 0, FieldMask::FULL)?;
16009        for hit in &cached_hits {
16010            assert_eq!(hit.agent, "codex", "Cached search violated agent filter");
16011        }
16012
16013        Ok(())
16014    }
16015
16016    #[test]
16017    fn filter_fidelity_workspace_filter_respected() -> Result<()> {
16018        // Multiple workspaces; filter should return only matching workspace
16019        let dir = TempDir::new()?;
16020        let mut index = TantivyIndex::open_or_create(dir.path())?;
16021
16022        // Workspace A
16023        let conv_a = NormalizedConversation {
16024            agent_slug: "codex".into(),
16025            external_id: None,
16026            title: Some("ws_a doc".into()),
16027            workspace: Some(std::path::PathBuf::from("/workspace/alpha")),
16028            source_path: dir.path().join("a.jsonl"),
16029            started_at: Some(100),
16030            ended_at: None,
16031            metadata: serde_json::json!({}),
16032            messages: vec![NormalizedMessage {
16033                idx: 0,
16034                role: "user".into(),
16035                author: None,
16036                created_at: Some(100),
16037                content: "workspace test needle".into(),
16038                extra: serde_json::json!({}),
16039                snippets: vec![],
16040                invocations: Vec::new(),
16041            }],
16042        };
16043        // Workspace B
16044        let conv_b = NormalizedConversation {
16045            agent_slug: "codex".into(),
16046            external_id: None,
16047            title: Some("ws_b doc".into()),
16048            workspace: Some(std::path::PathBuf::from("/workspace/beta")),
16049            source_path: dir.path().join("b.jsonl"),
16050            started_at: Some(200),
16051            ended_at: None,
16052            metadata: serde_json::json!({}),
16053            messages: vec![NormalizedMessage {
16054                idx: 0,
16055                role: "user".into(),
16056                author: None,
16057                created_at: Some(200),
16058                content: "workspace test needle".into(),
16059                extra: serde_json::json!({}),
16060                snippets: vec![],
16061                invocations: Vec::new(),
16062            }],
16063        };
16064        index.add_conversation(&conv_a)?;
16065        index.add_conversation(&conv_b)?;
16066        index.commit()?;
16067
16068        let client = SearchClient::open(dir.path(), None)?.expect("index present");
16069
16070        // Search with workspace filter for beta only
16071        let mut filters = SearchFilters::default();
16072        filters.workspaces.insert("/workspace/beta".into());
16073
16074        let hits = client.search("needle", filters.clone(), 10, 0, FieldMask::FULL)?;
16075
16076        // Property: all results must have workspace == "/workspace/beta"
16077        for hit in &hits {
16078            assert_eq!(
16079                hit.workspace, "/workspace/beta",
16080                "Workspace filter violated: got '{}' instead of '/workspace/beta'",
16081                hit.workspace
16082            );
16083        }
16084        assert!(!hits.is_empty(), "Should have found results");
16085
16086        // Repeat search (should use cache)
16087        let cached_hits = client.search("needle", filters, 10, 0, FieldMask::FULL)?;
16088        for hit in &cached_hits {
16089            assert_eq!(
16090                hit.workspace, "/workspace/beta",
16091                "Cached search violated workspace filter"
16092            );
16093        }
16094
16095        Ok(())
16096    }
16097
16098    #[test]
16099    fn filter_fidelity_date_range_respected() -> Result<()> {
16100        // Multiple dates; filter should return only within range
16101        let dir = TempDir::new()?;
16102        let mut index = TantivyIndex::open_or_create(dir.path())?;
16103
16104        // Early doc (ts=100)
16105        let conv_early = NormalizedConversation {
16106            agent_slug: "codex".into(),
16107            external_id: None,
16108            title: Some("early".into()),
16109            workspace: None,
16110            source_path: dir.path().join("early.jsonl"),
16111            started_at: Some(100),
16112            ended_at: None,
16113            metadata: serde_json::json!({}),
16114            messages: vec![NormalizedMessage {
16115                idx: 0,
16116                role: "user".into(),
16117                author: None,
16118                created_at: Some(100),
16119                content: "date range test".into(),
16120                extra: serde_json::json!({}),
16121                snippets: vec![],
16122                invocations: Vec::new(),
16123            }],
16124        };
16125        // Middle doc (ts=500)
16126        let conv_middle = NormalizedConversation {
16127            agent_slug: "codex".into(),
16128            external_id: None,
16129            title: Some("middle".into()),
16130            workspace: None,
16131            source_path: dir.path().join("middle.jsonl"),
16132            started_at: Some(500),
16133            ended_at: None,
16134            metadata: serde_json::json!({}),
16135            messages: vec![NormalizedMessage {
16136                idx: 0,
16137                role: "user".into(),
16138                author: None,
16139                created_at: Some(500),
16140                content: "date range test".into(),
16141                extra: serde_json::json!({}),
16142                snippets: vec![],
16143                invocations: Vec::new(),
16144            }],
16145        };
16146        // Late doc (ts=900)
16147        let conv_late = NormalizedConversation {
16148            agent_slug: "codex".into(),
16149            external_id: None,
16150            title: Some("late".into()),
16151            workspace: None,
16152            source_path: dir.path().join("late.jsonl"),
16153            started_at: Some(900),
16154            ended_at: None,
16155            metadata: serde_json::json!({}),
16156            messages: vec![NormalizedMessage {
16157                idx: 0,
16158                role: "user".into(),
16159                author: None,
16160                created_at: Some(900),
16161                content: "date range test".into(),
16162                extra: serde_json::json!({}),
16163                snippets: vec![],
16164                invocations: Vec::new(),
16165            }],
16166        };
16167        index.add_conversation(&conv_early)?;
16168        index.add_conversation(&conv_middle)?;
16169        index.add_conversation(&conv_late)?;
16170        index.commit()?;
16171
16172        let client = SearchClient::open(dir.path(), None)?.expect("index present");
16173
16174        // Filter for middle range only (400-600)
16175        let filters = SearchFilters {
16176            created_from: Some(400),
16177            created_to: Some(600),
16178            ..Default::default()
16179        };
16180
16181        let hits = client.search("range", filters.clone(), 10, 0, FieldMask::FULL)?;
16182
16183        // Property: all results must have created_at within [400, 600]
16184        for hit in &hits {
16185            if let Some(ts) = hit.created_at {
16186                assert!(
16187                    (400..=600).contains(&ts),
16188                    "Date range filter violated: got ts={ts} outside [400, 600]"
16189                );
16190            }
16191        }
16192        // Should find only the middle doc
16193        assert_eq!(hits.len(), 1, "Should find exactly 1 doc in range");
16194
16195        // Repeat search (cache)
16196        let cached_hits = client.search("range", filters, 10, 0, FieldMask::FULL)?;
16197        for hit in &cached_hits {
16198            if let Some(ts) = hit.created_at {
16199                assert!(
16200                    (400..=600).contains(&ts),
16201                    "Cached search violated date range filter"
16202                );
16203            }
16204        }
16205
16206        Ok(())
16207    }
16208
16209    #[test]
16210    fn filter_fidelity_combined_filters_respected() -> Result<()> {
16211        // Combine agent + workspace + date filters
16212        let dir = TempDir::new()?;
16213        let mut index = TantivyIndex::open_or_create(dir.path())?;
16214
16215        // Create 4 docs with different combinations
16216        let combinations = [
16217            ("codex", "/ws/prod", 100),  // wrong date
16218            ("claude", "/ws/prod", 500), // correct agent, correct ws, correct date
16219            ("claude", "/ws/dev", 500),  // correct agent, wrong ws, correct date
16220            ("claude", "/ws/prod", 900), // correct agent, correct ws, wrong date
16221        ];
16222
16223        for (i, (agent, ws, ts)) in combinations.iter().enumerate() {
16224            let conv = NormalizedConversation {
16225                agent_slug: (*agent).into(),
16226                external_id: None,
16227                title: Some(format!("combo-{i}")),
16228                workspace: Some(std::path::PathBuf::from(*ws)),
16229                source_path: dir.path().join(format!("{i}.jsonl")),
16230                started_at: Some(*ts),
16231                ended_at: None,
16232                metadata: serde_json::json!({}),
16233                messages: vec![NormalizedMessage {
16234                    idx: 0,
16235                    role: "user".into(),
16236                    author: None,
16237                    created_at: Some(*ts),
16238                    content: "hello world combotest query".into(),
16239                    extra: serde_json::json!({}),
16240                    snippets: vec![],
16241                    invocations: Vec::new(),
16242                }],
16243            };
16244            index.add_conversation(&conv)?;
16245        }
16246        index.commit()?;
16247
16248        let client = SearchClient::open(dir.path(), None)?.expect("index present");
16249
16250        // Filter: claude + /ws/prod + date 400-600
16251        let mut filters = SearchFilters::default();
16252        filters.agents.insert("claude".into());
16253        filters.workspaces.insert("/ws/prod".into());
16254        filters.created_from = Some(400);
16255        filters.created_to = Some(600);
16256
16257        let hits = client.search("combotest", filters.clone(), 10, 0, FieldMask::FULL)?;
16258
16259        // Should find exactly 1 doc (index 1 in combinations)
16260        assert_eq!(hits.len(), 1, "Combined filter should match exactly 1 doc");
16261
16262        for hit in &hits {
16263            assert_eq!(hit.agent, "claude", "Agent filter violated");
16264            assert_eq!(hit.workspace, "/ws/prod", "Workspace filter violated");
16265            if let Some(ts) = hit.created_at {
16266                assert!((400..=600).contains(&ts), "Date filter violated: ts={ts}");
16267            }
16268        }
16269
16270        // Cache hit
16271        let cached = client.search("combotest", filters, 10, 0, FieldMask::FULL)?;
16272        assert_eq!(cached.len(), 1, "Cached result count mismatch");
16273
16274        Ok(())
16275    }
16276
16277    #[test]
16278    fn lexical_hits_normalize_trimmed_local_source_metadata() -> Result<()> {
16279        let dir = TempDir::new()?;
16280        let mut index = TantivyIndex::open_or_create(dir.path())?;
16281
16282        let conv = NormalizedConversation {
16283            agent_slug: "codex".into(),
16284            external_id: None,
16285            title: Some("trimmed local doc".into()),
16286            workspace: None,
16287            source_path: dir.path().join("trimmed-local.jsonl"),
16288            started_at: Some(100),
16289            ended_at: None,
16290            metadata: serde_json::json!({
16291                "cass": {
16292                    "origin": {
16293                        "source_id": "  LOCAL  ",
16294                        "kind": "local"
16295                    }
16296                }
16297            }),
16298            messages: vec![NormalizedMessage {
16299                idx: 0,
16300                role: "user".into(),
16301                author: None,
16302                created_at: Some(100),
16303                content: "trimmed local lexical".into(),
16304                extra: serde_json::json!({}),
16305                snippets: vec![],
16306                invocations: Vec::new(),
16307            }],
16308        };
16309        index.add_conversation(&conv)?;
16310        index.commit()?;
16311
16312        let client = SearchClient::open(dir.path(), None)?.expect("index present");
16313        let hits = client.search("trimmed", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
16314
16315        assert_eq!(hits.len(), 1);
16316        assert_eq!(hits[0].source_id, "local");
16317        assert_eq!(hits[0].origin_kind, "local");
16318
16319        Ok(())
16320    }
16321
16322    #[test]
16323    fn lexical_hits_normalize_remote_origin_kind_without_source_id() -> Result<()> {
16324        let dir = TempDir::new()?;
16325        let mut index = TantivyIndex::open_or_create(dir.path())?;
16326
16327        let conv = NormalizedConversation {
16328            agent_slug: "codex".into(),
16329            external_id: None,
16330            title: Some("remote lexical doc".into()),
16331            workspace: None,
16332            source_path: dir.path().join("remote-lexical.jsonl"),
16333            started_at: Some(100),
16334            ended_at: None,
16335            metadata: serde_json::json!({
16336                "cass": {
16337                    "origin": {
16338                        "source_id": "   ",
16339                        "kind": "ssh",
16340                        "host": "dev@laptop"
16341                    }
16342                }
16343            }),
16344            messages: vec![NormalizedMessage {
16345                idx: 0,
16346                role: "user".into(),
16347                author: None,
16348                created_at: Some(100),
16349                content: "remote lexical".into(),
16350                extra: serde_json::json!({}),
16351                snippets: vec![],
16352                invocations: Vec::new(),
16353            }],
16354        };
16355        index.add_conversation(&conv)?;
16356        index.commit()?;
16357
16358        let client = SearchClient::open(dir.path(), None)?.expect("index present");
16359        let hits = client.search("remote", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
16360
16361        assert_eq!(hits.len(), 1);
16362        assert_eq!(hits[0].source_id, "dev@laptop");
16363        assert_eq!(hits[0].origin_kind, "remote");
16364        assert_eq!(hits[0].origin_host.as_deref(), Some("dev@laptop"));
16365
16366        Ok(())
16367    }
16368
16369    #[test]
16370    fn lexical_hits_infer_remote_origin_from_host_without_kind() -> Result<()> {
16371        let dir = TempDir::new()?;
16372        let mut index = TantivyIndex::open_or_create(dir.path())?;
16373
16374        let conv = NormalizedConversation {
16375            agent_slug: "codex".into(),
16376            external_id: None,
16377            title: Some("legacy host-only lexical doc".into()),
16378            workspace: None,
16379            source_path: dir.path().join("legacy-host-only-lexical.jsonl"),
16380            started_at: Some(100),
16381            ended_at: None,
16382            metadata: serde_json::json!({
16383                "cass": {
16384                    "origin": {
16385                        "source_id": "   ",
16386                        "host": "dev@laptop"
16387                    }
16388                }
16389            }),
16390            messages: vec![NormalizedMessage {
16391                idx: 0,
16392                role: "user".into(),
16393                author: None,
16394                created_at: Some(100),
16395                content: "legacy remote lexical".into(),
16396                extra: serde_json::json!({}),
16397                snippets: vec![],
16398                invocations: Vec::new(),
16399            }],
16400        };
16401        index.add_conversation(&conv)?;
16402        index.commit()?;
16403
16404        let client = SearchClient::open(dir.path(), None)?.expect("index present");
16405        let hits = client.search("legacy", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
16406
16407        assert_eq!(hits.len(), 1);
16408        assert_eq!(hits[0].source_id, "dev@laptop");
16409        assert_eq!(hits[0].origin_kind, "remote");
16410        assert_eq!(hits[0].origin_host.as_deref(), Some("dev@laptop"));
16411
16412        Ok(())
16413    }
16414
16415    #[test]
16416    fn filter_fidelity_source_filter_respected() -> Result<()> {
16417        // P3.1: Source filter should filter by origin_kind or source_id
16418        let dir = TempDir::new()?;
16419        let mut index = TantivyIndex::open_or_create(dir.path())?;
16420
16421        // Local source doc
16422        let conv_local = NormalizedConversation {
16423            agent_slug: "codex".into(),
16424            external_id: None,
16425            title: Some("local doc".into()),
16426            workspace: None,
16427            source_path: dir.path().join("local.jsonl"),
16428            started_at: Some(100),
16429            ended_at: None,
16430            metadata: serde_json::json!({}),
16431            messages: vec![NormalizedMessage {
16432                idx: 0,
16433                role: "user".into(),
16434                author: None,
16435                created_at: Some(100),
16436                content: "source filter test local".into(),
16437                extra: serde_json::json!({}),
16438                snippets: vec![],
16439                invocations: Vec::new(),
16440            }],
16441        };
16442        // Remote source doc (would need to be indexed with ssh origin_kind)
16443        // For now, test that local filter returns local docs
16444        index.add_conversation(&conv_local)?;
16445        index.commit()?;
16446
16447        let client = SearchClient::open(dir.path(), None)?.expect("index present");
16448
16449        // Filter for local sources
16450        let filters = SearchFilters {
16451            source_filter: SourceFilter::Local,
16452            ..Default::default()
16453        };
16454
16455        let hits = client.search("source", filters.clone(), 10, 0, FieldMask::FULL)?;
16456
16457        // Property: all results should have source_id == "local"
16458        for hit in &hits {
16459            assert_eq!(
16460                hit.source_id, "local",
16461                "Source filter violated: got source_id '{}' instead of 'local'",
16462                hit.source_id
16463            );
16464        }
16465        assert!(!hits.is_empty(), "Should have found local results");
16466
16467        // Filter for specific source ID
16468        let filters_id = SearchFilters {
16469            source_filter: SourceFilter::SourceId("  LOCAL  ".to_string()),
16470            ..Default::default()
16471        };
16472
16473        let hits_id = client.search("source", filters_id, 10, 0, FieldMask::FULL)?;
16474        for hit in &hits_id {
16475            assert_eq!(
16476                hit.source_id, "local",
16477                "SourceId filter violated: got '{}' instead of 'local'",
16478                hit.source_id
16479            );
16480        }
16481        assert!(
16482            !hits_id.is_empty(),
16483            "Should have found results for source_id=local"
16484        );
16485
16486        Ok(())
16487    }
16488
16489    #[test]
16490    fn filter_fidelity_cache_key_isolation() {
16491        // Different filters should have different cache keys
16492        let client = SearchClient {
16493            reader: None,
16494            sqlite: Mutex::new(None),
16495            sqlite_path: None,
16496            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
16497            reload_on_search: true,
16498            last_reload: Mutex::new(None),
16499            last_generation: Mutex::new(None),
16500            reload_epoch: Arc::new(AtomicU64::new(0)),
16501            warm_tx: None,
16502            _warm_handle: None,
16503            metrics: Metrics::default(),
16504            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
16505            semantic: Mutex::new(None),
16506            last_tantivy_total_count: Mutex::new(None),
16507        };
16508
16509        let filters_empty = SearchFilters::default();
16510        let mut filters_agent = SearchFilters::default();
16511        filters_agent.agents.insert("codex".into());
16512
16513        let mut filters_ws = SearchFilters::default();
16514        filters_ws.workspaces.insert("/ws".into());
16515
16516        let key_empty = client.cache_key("test", &filters_empty);
16517        let key_agent = client.cache_key("test", &filters_agent);
16518        let key_ws = client.cache_key("test", &filters_ws);
16519
16520        // All keys should be different
16521        assert_ne!(
16522            key_empty, key_agent,
16523            "Empty vs agent filter keys should differ"
16524        );
16525        assert_ne!(
16526            key_empty, key_ws,
16527            "Empty vs workspace filter keys should differ"
16528        );
16529        assert_ne!(
16530            key_agent, key_ws,
16531            "Agent vs workspace filter keys should differ"
16532        );
16533
16534        // Same filter should produce same key
16535        let mut filters_agent2 = SearchFilters::default();
16536        filters_agent2.agents.insert("codex".into());
16537        let key_agent2 = client.cache_key("test", &filters_agent2);
16538        assert_eq!(key_agent, key_agent2, "Same filter should produce same key");
16539    }
16540
16541    // ==========================================================================
16542    // FTS5 Query Generation Tests (tst.srch.fts)
16543    // Additional tests for SQL/FTS5 query generation edge cases
16544    // ==========================================================================
16545
16546    // --- Additional sanitize_query tests (edge cases) ---
16547
16548    #[test]
16549    fn sanitize_query_preserves_unicode_alphanumeric() {
16550        // Unicode letters and digits should be preserved
16551        assert_eq!(fs_cass_sanitize_query("こんにちは"), "こんにちは");
16552        assert_eq!(fs_cass_sanitize_query("café"), "café");
16553        assert_eq!(fs_cass_sanitize_query("日本語123"), "日本語123");
16554    }
16555
16556    #[test]
16557    fn sanitize_query_handles_multiple_consecutive_special_chars() {
16558        assert_eq!(fs_cass_sanitize_query("foo---bar"), "foo---bar");
16559        // a!@#$%^&()b has 9 special chars between a and b: ! @ # $ % ^ & ( )
16560        assert_eq!(fs_cass_sanitize_query("a!@#$%^&()b"), "a         b");
16561    }
16562
16563    // --- Additional WildcardPattern::parse tests (edge cases) ---
16564
16565    #[test]
16566    fn wildcard_pattern_empty_after_trim_returns_exact_empty() {
16567        assert_eq!(
16568            FsCassWildcardPattern::parse("*"),
16569            FsCassWildcardPattern::Exact(String::new())
16570        );
16571        assert_eq!(
16572            FsCassWildcardPattern::parse("**"),
16573            FsCassWildcardPattern::Exact(String::new())
16574        );
16575        assert_eq!(
16576            FsCassWildcardPattern::parse("***"),
16577            FsCassWildcardPattern::Exact(String::new())
16578        );
16579    }
16580
16581    #[test]
16582    fn wildcard_pattern_to_regex_generation() {
16583        // Exact and prefix patterns don't need regex
16584        assert_eq!(FsCassWildcardPattern::Exact("foo".into()).to_regex(), None);
16585        assert_eq!(FsCassWildcardPattern::Prefix("foo".into()).to_regex(), None);
16586        // Suffix and substring need regex
16587        // Suffix needs $ anchor for "ends with" semantics
16588        assert_eq!(
16589            FsCassWildcardPattern::Suffix("foo".into()).to_regex(),
16590            Some(".*foo$".into())
16591        );
16592        assert_eq!(
16593            FsCassWildcardPattern::Substring("foo".into()).to_regex(),
16594            Some(".*foo.*".into())
16595        );
16596    }
16597
16598    // --- Additional parse_boolean_query tests (edge cases) ---
16599
16600    #[test]
16601    fn parse_boolean_query_prefix_minus_not() {
16602        // Prefix minus at start of query should trigger NOT
16603        let tokens = fs_cass_parse_boolean_query("-world");
16604        let expected = vec![
16605            FsCassQueryToken::Not,
16606            FsCassQueryToken::Term("world".into()),
16607        ];
16608        assert_eq!(tokens, expected);
16609
16610        // Prefix minus after space should trigger NOT
16611        let tokens = fs_cass_parse_boolean_query("hello -world");
16612        let expected = vec![
16613            FsCassQueryToken::Term("hello".into()),
16614            FsCassQueryToken::Not,
16615            FsCassQueryToken::Term("world".into()),
16616        ];
16617        assert_eq!(tokens, expected);
16618    }
16619
16620    #[test]
16621    fn parse_boolean_query_empty_quoted_phrase_ignored() {
16622        let tokens = parse_boolean_query("\"\"");
16623        assert!(tokens.is_empty());
16624
16625        let tokens = parse_boolean_query("foo \"\" bar");
16626        let expected: QueryTokenList = vec![
16627            QueryToken::Term("foo".into()),
16628            QueryToken::Term("bar".into()),
16629        ];
16630        assert_eq!(tokens, expected);
16631    }
16632
16633    #[test]
16634    fn parse_boolean_query_unclosed_quote() {
16635        // Unclosed quote should collect until end
16636        let tokens = parse_boolean_query("\"hello world");
16637        let expected: QueryTokenList = vec![QueryToken::Phrase("hello world".into())];
16638        assert_eq!(tokens, expected);
16639    }
16640
16641    #[test]
16642    fn transpile_to_fts5_rejects_leading_unary_not_queries() {
16643        assert_eq!(transpile_to_fts5("NOT foo"), None);
16644        assert_eq!(transpile_to_fts5("-foo"), None);
16645    }
16646
16647    #[test]
16648    fn transpile_to_fts5_rejects_or_not_forms_it_cannot_represent() {
16649        assert_eq!(transpile_to_fts5("foo OR NOT bar"), None);
16650        assert_eq!(transpile_to_fts5("foo NOT bar OR baz"), None);
16651    }
16652
16653    #[test]
16654    fn transpile_to_fts5_ignores_leading_or() {
16655        assert_eq!(transpile_to_fts5("OR test"), Some("test".to_string()));
16656        assert_eq!(
16657            transpile_to_fts5("OR foo-bar"),
16658            Some("(foo AND bar)".to_string())
16659        );
16660    }
16661
16662    #[test]
16663    fn transpile_to_fts5_splits_hyphenated_subterms_for_sqlite_fts() {
16664        assert_eq!(
16665            transpile_to_fts5("br-123.jsonl"),
16666            Some("(br AND 123 AND jsonl)".to_string())
16667        );
16668        assert_eq!(
16669            transpile_to_fts5("br-123.json*"),
16670            Some("(br AND 123 AND json*)".to_string())
16671        );
16672    }
16673
16674    #[test]
16675    fn transpile_to_fts5_preserves_supported_binary_not() {
16676        assert_eq!(
16677            transpile_to_fts5("foo NOT bar").as_deref(),
16678            Some("foo NOT bar")
16679        );
16680        assert_eq!(
16681            transpile_to_fts5("foo NOT bar-baz"),
16682            Some("foo NOT (bar AND baz)".to_string())
16683        );
16684    }
16685
16686    #[test]
16687    fn search_sqlite_fts5_returns_empty_when_sqlite_is_unavailable() {
16688        let client = SearchClient {
16689            reader: None,
16690            sqlite: Mutex::new(None),
16691            sqlite_path: None,
16692            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
16693            reload_on_search: false,
16694            last_reload: Mutex::new(None),
16695            last_generation: Mutex::new(None),
16696            reload_epoch: Arc::new(AtomicU64::new(0)),
16697            warm_tx: None,
16698            _warm_handle: None,
16699            metrics: Metrics::default(),
16700            cache_namespace: "fts5-disabled".to_string(),
16701            semantic: Mutex::new(None),
16702            last_tantivy_total_count: Mutex::new(None),
16703        };
16704
16705        let hits = client.search_sqlite_fts5(
16706            Path::new("/nonexistent"),
16707            "test query",
16708            SearchFilters::default(),
16709            10,
16710            0,
16711            FieldMask::FULL,
16712        );
16713
16714        assert!(hits.is_ok(), "disabled FTS5 path should stay non-fatal");
16715        assert!(
16716            hits.unwrap().is_empty(),
16717            "unavailable SQLite fallback should keep returning an empty result set"
16718        );
16719    }
16720
16721    /// `coding_agent_session_search-k0e5p` (ibuuh.24.2 sub-bead):
16722    /// E2E equivalence gate for the rank+hydrate FTS5 fallback split
16723    /// landed in peer commit c91ea038. The peer's existing unit test
16724    /// pins the rank-SQL SHAPE (no content columns referenced) but
16725    /// nothing pins the user-facing RESULT-SET equivalence. A
16726    /// regression where the hydrate phase silently re-orders, drops,
16727    /// or re-filters hits would slip past the SQL-shape check and
16728    /// produce user-visible quality changes.
16729    ///
16730    /// This test pins the prefix invariant (same pattern as bead
16731    /// 1dd5u for the lexical search path): seed N ranked hits in the
16732    /// FTS5 fallback DB, run search_sqlite_fts5 at limit=K and
16733    /// limit=N, assert the smaller-limit result is a prefix of the
16734    /// larger-limit result. A regression in either rank or hydrate
16735    /// (re-order, drop, re-filter) trips immediately.
16736    ///
16737    /// Pins three invariants:
16738    /// 1. Smaller-limit hits are a strict prefix of larger-limit hits.
16739    /// 2. Limit=N returns exactly N matches when ≥N candidates exist.
16740    /// 3. Limit=0 returns empty (boundary case the rank+hydrate
16741    ///    split could break by hydrating before honoring the limit).
16742    #[test]
16743    fn search_sqlite_fts5_rank_and_hydrate_split_preserves_limit_prefix_invariant() -> Result<()> {
16744        let conn = Connection::open(":memory:")?;
16745        conn.execute_batch(
16746            "CREATE TABLE sources (id TEXT PRIMARY KEY, kind TEXT);
16747             CREATE TABLE agents (id INTEGER PRIMARY KEY, slug TEXT NOT NULL UNIQUE);
16748             CREATE TABLE workspaces (id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE);
16749             CREATE TABLE conversations (
16750                id INTEGER PRIMARY KEY,
16751                agent_id INTEGER,
16752                workspace_id INTEGER,
16753                source_id TEXT,
16754                origin_host TEXT,
16755                title TEXT,
16756                source_path TEXT
16757             );
16758             CREATE TABLE messages (
16759                id INTEGER PRIMARY KEY,
16760                conversation_id INTEGER,
16761                idx INTEGER,
16762                content TEXT,
16763                created_at INTEGER
16764             );
16765             CREATE VIRTUAL TABLE fts_messages USING fts5(
16766                content,
16767                title,
16768                agent,
16769                workspace,
16770                source_path,
16771                created_at UNINDEXED,
16772                message_id UNINDEXED,
16773                tokenize='porter'
16774             );",
16775        )?;
16776        conn.execute("INSERT INTO sources(id, kind) VALUES('local', 'local')")?;
16777        conn.execute("INSERT INTO agents(id, slug) VALUES(1, 'codex')")?;
16778        conn.execute("INSERT INTO workspaces(id, path) VALUES(1, '/tmp/k0e5p')")?;
16779
16780        // Seed N=6 messages all matching the same query token. Each
16781        // gets a distinct message_id + content shape so the prefix
16782        // assertion can pin specific ordering rather than just
16783        // counts. The bm25 score depends on per-row term frequency;
16784        // we vary `rankprobe` repetition (1×..6×) so the rank phase
16785        // produces a deterministic descending order.
16786        for (i, repeats) in (1..=6_i64).enumerate() {
16787            let conv_id = i as i64 + 1;
16788            let msg_id = (i as i64 + 1) * 10;
16789            conn.execute_compat(
16790                "INSERT INTO conversations(id, agent_id, workspace_id, source_id, \
16791                 origin_host, title, source_path) \
16792                 VALUES(?1, 1, 1, 'local', NULL, ?2, ?3)",
16793                params![
16794                    conv_id,
16795                    format!("k0e5p-{}", i),
16796                    format!("/tmp/k0e5p/{}.jsonl", i),
16797                ],
16798            )?;
16799            let content = "rankprobe ".repeat(repeats as usize);
16800            conn.execute_compat(
16801                "INSERT INTO messages(id, conversation_id, idx, content, created_at) \
16802                 VALUES(?1, ?2, ?3, ?4, ?5)",
16803                params![
16804                    msg_id,
16805                    conv_id,
16806                    i as i64,
16807                    content.as_str(),
16808                    1_700_000_000_i64 + i as i64
16809                ],
16810            )?;
16811            conn.execute_compat(
16812                "INSERT INTO fts_messages(rowid, content, title, agent, workspace, \
16813                 source_path, created_at, message_id) \
16814                 VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
16815                params![
16816                    msg_id,
16817                    content.as_str(),
16818                    format!("k0e5p-{}", i),
16819                    "codex",
16820                    "/tmp/k0e5p",
16821                    format!("/tmp/k0e5p/{}.jsonl", i),
16822                    1_700_000_000_i64 + i as i64,
16823                    msg_id,
16824                ],
16825            )?;
16826        }
16827
16828        let client = SearchClient {
16829            reader: None,
16830            sqlite: Mutex::new(Some(SendConnection(conn))),
16831            sqlite_path: None,
16832            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
16833            reload_on_search: false,
16834            last_reload: Mutex::new(None),
16835            last_generation: Mutex::new(None),
16836            reload_epoch: Arc::new(AtomicU64::new(0)),
16837            warm_tx: None,
16838            _warm_handle: None,
16839            metrics: Metrics::default(),
16840            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:k0e5p"),
16841            semantic: Mutex::new(None),
16842            last_tantivy_total_count: Mutex::new(None),
16843        };
16844
16845        // Hit-key tuple: (source_path, line_number) is the stable
16846        // operator-visible identity. Two limits that share a prefix
16847        // must produce hits with the same identities in the same
16848        // order across that prefix.
16849        fn hit_keys(hits: &[SearchHit]) -> Vec<(String, Option<usize>)> {
16850            hits.iter()
16851                .map(|h| (h.source_path.clone(), h.line_number))
16852                .collect()
16853        }
16854
16855        let large_hits = client.search_sqlite_fts5(
16856            Path::new(":memory:"),
16857            "rankprobe",
16858            SearchFilters::default(),
16859            6,
16860            0,
16861            FieldMask::FULL,
16862        )?;
16863        assert_eq!(
16864            large_hits.len(),
16865            6,
16866            "limit=N must return all N candidates when the corpus has exactly N matches"
16867        );
16868
16869        let small_hits = client.search_sqlite_fts5(
16870            Path::new(":memory:"),
16871            "rankprobe",
16872            SearchFilters::default(),
16873            3,
16874            0,
16875            FieldMask::FULL,
16876        )?;
16877        assert_eq!(small_hits.len(), 3, "limit=3 must return exactly 3 hits");
16878
16879        // Invariant 1: smaller-limit hits are a STRICT prefix of the
16880        // larger-limit hits — same identity, same order.
16881        let large_keys = hit_keys(&large_hits);
16882        let small_keys = hit_keys(&small_hits);
16883        assert_eq!(
16884            small_keys,
16885            large_keys[..3],
16886            "limit=3 hit keys MUST be the first 3 of limit=6 hit keys (rank+hydrate \
16887             split must not re-order or re-filter); small={small_keys:?} \
16888             large_prefix={:?}",
16889            &large_keys[..3]
16890        );
16891
16892        // Invariant 2: hit content is also identical across the
16893        // shared prefix — the hydrate phase preserves the content
16894        // string the rank phase ranked. A regression where hydrate
16895        // pulled from a different DB row than rank pointed at would
16896        // trip this even if the keys aligned.
16897        for (idx, (small, large)) in small_hits.iter().zip(large_hits.iter()).enumerate() {
16898            assert_eq!(
16899                small.content, large.content,
16900                "hit[{idx}] content must agree across limit=3 and limit=6: \
16901                 small={:?} large={:?}",
16902                small.content, large.content
16903            );
16904            assert_eq!(
16905                small.title, large.title,
16906                "hit[{idx}] title must agree across limit=3 and limit=6"
16907            );
16908        }
16909
16910        // Invariant 3: limit=0 boundary. The rank+hydrate split could
16911        // break this by hydrating before honoring the limit; pinning
16912        // it directly catches that regression class.
16913        let zero_hits = client.search_sqlite_fts5(
16914            Path::new(":memory:"),
16915            "rankprobe",
16916            SearchFilters::default(),
16917            0,
16918            0,
16919            FieldMask::FULL,
16920        )?;
16921        assert!(
16922            zero_hits.is_empty(),
16923            "limit=0 must return zero hits even though the rank phase has candidates; \
16924             got {} hits",
16925            zero_hits.len()
16926        );
16927
16928        Ok(())
16929    }
16930
16931    // --- levenshtein_distance tests ---
16932
16933    #[test]
16934    fn levenshtein_distance_identical_strings() {
16935        assert_eq!(levenshtein_distance("hello", "hello"), 0);
16936        assert_eq!(levenshtein_distance("", ""), 0);
16937    }
16938
16939    #[test]
16940    fn levenshtein_distance_insertions() {
16941        assert_eq!(levenshtein_distance("", "abc"), 3);
16942        assert_eq!(levenshtein_distance("cat", "cats"), 1);
16943    }
16944
16945    #[test]
16946    fn levenshtein_distance_deletions() {
16947        assert_eq!(levenshtein_distance("abc", ""), 3);
16948        assert_eq!(levenshtein_distance("cats", "cat"), 1);
16949    }
16950
16951    #[test]
16952    fn levenshtein_distance_substitutions() {
16953        assert_eq!(levenshtein_distance("cat", "bat"), 1);
16954        assert_eq!(levenshtein_distance("kitten", "sitten"), 1);
16955    }
16956
16957    #[test]
16958    fn levenshtein_distance_mixed_operations() {
16959        assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
16960        assert_eq!(levenshtein_distance("saturday", "sunday"), 3);
16961    }
16962
16963    // --- is_tool_invocation_noise tests ---
16964
16965    #[test]
16966    fn is_tool_invocation_noise_allows_real_content() {
16967        assert!(!is_tool_invocation_noise("This is a normal message"));
16968        assert!(!is_tool_invocation_noise(
16969            "Let me use the Tool feature to accomplish this task. Here is the implementation..."
16970        ));
16971        // Long content that happens to start with [Tool: should be allowed if it's substantial
16972        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.";
16973        assert!(!is_tool_invocation_noise(long_content));
16974    }
16975
16976    #[test]
16977    fn is_tool_invocation_noise_handles_short_tool_markers() {
16978        assert!(is_tool_invocation_noise("[tool: x]"));
16979        assert!(is_tool_invocation_noise("tool: bash"));
16980    }
16981
16982    // --- Integration tests for boolean queries through search ---
16983
16984    #[test]
16985    fn search_boolean_and_filters_results() -> Result<()> {
16986        let dir = TempDir::new()?;
16987        let mut index = TantivyIndex::open_or_create(dir.path())?;
16988
16989        // Create documents with different word combinations
16990        let conv1 = NormalizedConversation {
16991            agent_slug: "codex".into(),
16992            external_id: None,
16993            title: Some("doc1".into()),
16994            workspace: None,
16995            source_path: dir.path().join("1.jsonl"),
16996            started_at: Some(1),
16997            ended_at: None,
16998            metadata: serde_json::json!({}),
16999            messages: vec![NormalizedMessage {
17000                idx: 0,
17001                role: "user".into(),
17002                author: None,
17003                created_at: Some(1),
17004                content: "alpha beta gamma".into(),
17005                extra: serde_json::json!({}),
17006                snippets: vec![],
17007                invocations: Vec::new(),
17008            }],
17009        };
17010        let conv2 = NormalizedConversation {
17011            agent_slug: "codex".into(),
17012            external_id: None,
17013            title: Some("doc2".into()),
17014            workspace: None,
17015            source_path: dir.path().join("2.jsonl"),
17016            started_at: Some(2),
17017            ended_at: None,
17018            metadata: serde_json::json!({}),
17019            messages: vec![NormalizedMessage {
17020                idx: 0,
17021                role: "user".into(),
17022                author: None,
17023                created_at: Some(2),
17024                content: "alpha delta".into(),
17025                extra: serde_json::json!({}),
17026                snippets: vec![],
17027                invocations: Vec::new(),
17028            }],
17029        };
17030        index.add_conversation(&conv1)?;
17031        index.add_conversation(&conv2)?;
17032        index.commit()?;
17033
17034        let client = SearchClient::open(dir.path(), None)?.expect("index present");
17035
17036        // "alpha AND beta" should only match doc1
17037        let hits = client.search(
17038            "alpha AND beta",
17039            SearchFilters::default(),
17040            10,
17041            0,
17042            FieldMask::FULL,
17043        )?;
17044        assert_eq!(hits.len(), 1);
17045        assert!(hits[0].content.contains("gamma"));
17046
17047        // "alpha AND delta" should only match doc2
17048        let hits = client.search(
17049            "alpha AND delta",
17050            SearchFilters::default(),
17051            10,
17052            0,
17053            FieldMask::FULL,
17054        )?;
17055        assert_eq!(hits.len(), 1);
17056        assert!(hits[0].content.contains("delta"));
17057
17058        Ok(())
17059    }
17060
17061    #[test]
17062    fn search_boolean_or_expands_results() -> Result<()> {
17063        let dir = TempDir::new()?;
17064        let mut index = TantivyIndex::open_or_create(dir.path())?;
17065
17066        let conv1 = NormalizedConversation {
17067            agent_slug: "codex".into(),
17068            external_id: None,
17069            title: Some("doc1".into()),
17070            workspace: None,
17071            source_path: dir.path().join("1.jsonl"),
17072            started_at: Some(1),
17073            ended_at: None,
17074            metadata: serde_json::json!({}),
17075            messages: vec![NormalizedMessage {
17076                idx: 0,
17077                role: "user".into(),
17078                author: None,
17079                created_at: Some(1),
17080                content: "unique xyzzy term".into(),
17081                extra: serde_json::json!({}),
17082                snippets: vec![],
17083                invocations: Vec::new(),
17084            }],
17085        };
17086        let conv2 = NormalizedConversation {
17087            agent_slug: "codex".into(),
17088            external_id: None,
17089            title: Some("doc2".into()),
17090            workspace: None,
17091            source_path: dir.path().join("2.jsonl"),
17092            started_at: Some(2),
17093            ended_at: None,
17094            metadata: serde_json::json!({}),
17095            messages: vec![NormalizedMessage {
17096                idx: 0,
17097                role: "user".into(),
17098                author: None,
17099                created_at: Some(2),
17100                content: "unique plugh term".into(),
17101                extra: serde_json::json!({}),
17102                snippets: vec![],
17103                invocations: Vec::new(),
17104            }],
17105        };
17106        index.add_conversation(&conv1)?;
17107        index.add_conversation(&conv2)?;
17108        index.commit()?;
17109
17110        let client = SearchClient::open(dir.path(), None)?.expect("index present");
17111
17112        // "xyzzy OR plugh" should match both docs
17113        let hits = client.search(
17114            "xyzzy OR plugh",
17115            SearchFilters::default(),
17116            10,
17117            0,
17118            FieldMask::FULL,
17119        )?;
17120        assert_eq!(hits.len(), 2);
17121
17122        Ok(())
17123    }
17124
17125    #[test]
17126    fn search_boolean_not_excludes_results() -> Result<()> {
17127        let dir = TempDir::new()?;
17128        let mut index = TantivyIndex::open_or_create(dir.path())?;
17129
17130        let conv1 = NormalizedConversation {
17131            agent_slug: "codex".into(),
17132            external_id: None,
17133            title: Some("doc1".into()),
17134            workspace: None,
17135            source_path: dir.path().join("1.jsonl"),
17136            started_at: Some(1),
17137            ended_at: None,
17138            metadata: serde_json::json!({}),
17139            messages: vec![NormalizedMessage {
17140                idx: 0,
17141                role: "user".into(),
17142                author: None,
17143                created_at: Some(1),
17144                content: "nottest keep this".into(),
17145                extra: serde_json::json!({}),
17146                snippets: vec![],
17147                invocations: Vec::new(),
17148            }],
17149        };
17150        let conv2 = NormalizedConversation {
17151            agent_slug: "codex".into(),
17152            external_id: None,
17153            title: Some("doc2".into()),
17154            workspace: None,
17155            source_path: dir.path().join("2.jsonl"),
17156            started_at: Some(2),
17157            ended_at: None,
17158            metadata: serde_json::json!({}),
17159            messages: vec![NormalizedMessage {
17160                idx: 0,
17161                role: "user".into(),
17162                author: None,
17163                created_at: Some(2),
17164                content: "nottest exclude this".into(),
17165                extra: serde_json::json!({}),
17166                snippets: vec![],
17167                invocations: Vec::new(),
17168            }],
17169        };
17170        index.add_conversation(&conv1)?;
17171        index.add_conversation(&conv2)?;
17172        index.commit()?;
17173
17174        let client = SearchClient::open(dir.path(), None)?.expect("index present");
17175
17176        // "nottest NOT exclude" should only match doc1 (has nottest but NOT exclude)
17177        let hits = client.search(
17178            "nottest NOT exclude",
17179            SearchFilters::default(),
17180            10,
17181            0,
17182            FieldMask::FULL,
17183        )?;
17184        assert_eq!(hits.len(), 1);
17185        // Verify we got the right doc by checking it doesn't contain "exclude"
17186        assert!(
17187            !hits[0].content.contains("exclude"),
17188            "NOT exclude should filter out doc with 'exclude'"
17189        );
17190
17191        // Prefix "-" exclusion should behave like NOT for simple queries.
17192        let hits = client.search(
17193            "nottest -exclude",
17194            SearchFilters::default(),
17195            10,
17196            0,
17197            FieldMask::FULL,
17198        )?;
17199        assert_eq!(hits.len(), 1);
17200        assert!(
17201            !hits[0].content.contains("exclude"),
17202            "Prefix -exclude should filter out doc with 'exclude'"
17203        );
17204
17205        Ok(())
17206    }
17207
17208    #[test]
17209    fn search_phrase_query_matches_exact_sequence() -> Result<()> {
17210        let dir = TempDir::new()?;
17211        let mut index = TantivyIndex::open_or_create(dir.path())?;
17212
17213        let conv1 = NormalizedConversation {
17214            agent_slug: "codex".into(),
17215            external_id: None,
17216            title: Some("doc1".into()),
17217            workspace: None,
17218            source_path: dir.path().join("1.jsonl"),
17219            started_at: Some(1),
17220            ended_at: None,
17221            metadata: serde_json::json!({}),
17222            messages: vec![NormalizedMessage {
17223                idx: 0,
17224                role: "user".into(),
17225                author: None,
17226                created_at: Some(1),
17227                content: "the quick brown fox".into(),
17228                extra: serde_json::json!({}),
17229                snippets: vec![],
17230                invocations: Vec::new(),
17231            }],
17232        };
17233        let conv2 = NormalizedConversation {
17234            agent_slug: "codex".into(),
17235            external_id: None,
17236            title: Some("doc2".into()),
17237            workspace: None,
17238            source_path: dir.path().join("2.jsonl"),
17239            started_at: Some(2),
17240            ended_at: None,
17241            metadata: serde_json::json!({}),
17242            messages: vec![NormalizedMessage {
17243                idx: 0,
17244                role: "user".into(),
17245                author: None,
17246                created_at: Some(2),
17247                content: "the brown quick fox".into(),
17248                extra: serde_json::json!({}),
17249                snippets: vec![],
17250                invocations: Vec::new(),
17251            }],
17252        };
17253        index.add_conversation(&conv1)?;
17254        index.add_conversation(&conv2)?;
17255        index.commit()?;
17256
17257        let client = SearchClient::open(dir.path(), None)?.expect("index present");
17258
17259        // "quick brown" (without quotes) should match both (words just need to be present)
17260        let hits = client.search(
17261            "quick brown",
17262            SearchFilters::default(),
17263            10,
17264            0,
17265            FieldMask::FULL,
17266        )?;
17267        assert_eq!(hits.len(), 2);
17268
17269        // "\"quick brown\"" should match exact order only
17270        let hits = client.search(
17271            "\"quick brown\"",
17272            SearchFilters::default(),
17273            10,
17274            0,
17275            FieldMask::FULL,
17276        )?;
17277        assert_eq!(hits.len(), 1);
17278        assert!(hits[0].content.contains("quick brown"));
17279
17280        Ok(())
17281    }
17282
17283    #[test]
17284    fn search_dot_punctuation_splits_terms_but_hyphens_preserve_compound_semantics() -> Result<()> {
17285        let dir = TempDir::new()?;
17286        let mut index = TantivyIndex::open_or_create(dir.path())?;
17287
17288        let conv = NormalizedConversation {
17289            agent_slug: "codex".into(),
17290            external_id: None,
17291            title: Some("doc".into()),
17292            workspace: None,
17293            source_path: dir.path().join("3.jsonl"),
17294            started_at: Some(1),
17295            ended_at: None,
17296            metadata: serde_json::json!({}),
17297            messages: vec![NormalizedMessage {
17298                idx: 0,
17299                role: "user".into(),
17300                author: None,
17301                created_at: Some(1),
17302                content: "foo bar baz".into(),
17303                extra: serde_json::json!({}),
17304                snippets: vec![],
17305                invocations: Vec::new(),
17306            }],
17307        };
17308        index.add_conversation(&conv)?;
17309        index.commit()?;
17310
17311        let client = SearchClient::open(dir.path(), None)?.expect("index present");
17312
17313        let hits = client.search("foo.bar", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
17314        assert_eq!(hits.len(), 1);
17315
17316        let hits = client.search("foo-bar", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
17317        assert_eq!(hits.len(), 0);
17318
17319        Ok(())
17320    }
17321
17322    // ========================================================================
17323    // QueryExplanation tests
17324    // ========================================================================
17325
17326    #[test]
17327    fn explanation_classifies_simple_query() {
17328        let exp = QueryExplanation::analyze("hello", &SearchFilters::default());
17329        assert_eq!(exp.query_type, QueryType::Simple);
17330        assert_eq!(exp.index_strategy, IndexStrategy::EdgeNgram);
17331        assert_eq!(exp.estimated_cost, QueryCost::Low);
17332        assert!(exp.parsed.terms.len() == 1);
17333        assert_eq!(exp.parsed.terms[0].text, "hello");
17334        assert!(!exp.parsed.terms[0].subterms.is_empty());
17335        assert_eq!(exp.parsed.terms[0].subterms[0].pattern, "exact");
17336    }
17337
17338    #[test]
17339    fn explanation_classifies_wildcard_query() {
17340        let exp = QueryExplanation::analyze("*handler*", &SearchFilters::default());
17341        assert_eq!(exp.query_type, QueryType::Wildcard);
17342        assert_eq!(exp.index_strategy, IndexStrategy::RegexScan);
17343        assert_eq!(exp.estimated_cost, QueryCost::High);
17344        assert!(!exp.parsed.terms[0].subterms.is_empty());
17345        assert!(
17346            exp.parsed.terms[0].subterms[0]
17347                .pattern
17348                .contains("substring")
17349        );
17350        assert!(exp.warnings.iter().any(|w| w.contains("regex scan")));
17351    }
17352
17353    #[test]
17354    fn explanation_classifies_boolean_query() {
17355        let exp = QueryExplanation::analyze("foo AND bar", &SearchFilters::default());
17356        assert_eq!(exp.query_type, QueryType::Boolean);
17357        assert_eq!(exp.index_strategy, IndexStrategy::BooleanCombination);
17358        assert!(exp.parsed.operators.contains(&"AND".to_string()));
17359    }
17360
17361    #[test]
17362    fn explanation_classifies_phrase_query() {
17363        let exp = QueryExplanation::analyze("\"exact phrase\"", &SearchFilters::default());
17364        assert_eq!(exp.query_type, QueryType::Phrase);
17365        assert!(exp.parsed.phrases.contains(&"exact phrase".to_string()));
17366    }
17367
17368    #[test]
17369    fn explanation_handles_filtered_query() {
17370        let mut filters = SearchFilters::default();
17371        filters.agents.insert("codex".to_string());
17372
17373        let exp = QueryExplanation::analyze("test", &filters);
17374        assert_eq!(exp.query_type, QueryType::Filtered);
17375        assert_eq!(exp.filters_summary.agent_count, 1);
17376        assert!(
17377            exp.filters_summary
17378                .description
17379                .as_ref()
17380                .unwrap()
17381                .contains("1 agent")
17382        );
17383        assert!(exp.warnings.iter().any(|w| w.contains("codex")));
17384    }
17385
17386    #[test]
17387    fn explanation_handles_empty_query() {
17388        let exp = QueryExplanation::analyze("", &SearchFilters::default());
17389        assert_eq!(exp.query_type, QueryType::Empty);
17390        assert_eq!(exp.index_strategy, IndexStrategy::FullScan);
17391        assert_eq!(exp.estimated_cost, QueryCost::High);
17392        assert!(exp.warnings.iter().any(|w| w.contains("Empty query")));
17393    }
17394
17395    #[test]
17396    fn explanation_warns_short_terms() {
17397        let exp = QueryExplanation::analyze("a", &SearchFilters::default());
17398        assert!(exp.warnings.iter().any(|w| w.contains("Very short term")));
17399    }
17400
17401    #[test]
17402    fn explanation_with_wildcard_fallback() {
17403        let exp = QueryExplanation::analyze("test", &SearchFilters::default())
17404            .with_wildcard_fallback(true);
17405        assert!(exp.wildcard_applied);
17406        // Message starts with capital W: "Wildcard fallback was applied..."
17407        assert!(exp.warnings.iter().any(|w| w.contains("Wildcard fallback")));
17408    }
17409
17410    #[test]
17411    fn explanation_complex_query_has_higher_cost() {
17412        let exp = QueryExplanation::analyze(
17413            "foo AND bar OR baz NOT qux AND \"phrase here\"",
17414            &SearchFilters::default(),
17415        );
17416        assert_eq!(exp.query_type, QueryType::Boolean);
17417        // Complex query should have Medium or High cost
17418        assert!(matches!(
17419            exp.estimated_cost,
17420            QueryCost::Medium | QueryCost::High
17421        ));
17422    }
17423
17424    #[test]
17425    fn explanation_preserves_original_query() {
17426        let exp = QueryExplanation::analyze("Hello World!", &SearchFilters::default());
17427        assert_eq!(exp.original_query, "Hello World!");
17428        // Sanitized replaces special chars with spaces but preserves case
17429        assert!(exp.sanitized_query.contains("Hello"));
17430        // ! is replaced with space
17431        assert!(!exp.sanitized_query.contains("!"));
17432    }
17433
17434    #[test]
17435    fn explanation_detects_not_operator() {
17436        let exp = QueryExplanation::analyze("foo NOT bar", &SearchFilters::default());
17437        assert!(exp.parsed.operators.contains(&"NOT".to_string()));
17438        // Second term should be marked as negated
17439        assert!(
17440            exp.parsed
17441                .terms
17442                .iter()
17443                .any(|t| t.negated && t.text == "bar")
17444        );
17445    }
17446
17447    #[test]
17448    fn explanation_implicit_and() {
17449        let exp = QueryExplanation::analyze("foo bar", &SearchFilters::default());
17450        assert!(exp.parsed.implicit_and);
17451        assert_eq!(exp.parsed.terms.len(), 2);
17452    }
17453
17454    #[test]
17455    fn explanation_serializes_to_json() {
17456        let exp = QueryExplanation::analyze("test query", &SearchFilters::default());
17457        let json = serde_json::to_value(&exp).expect("should serialize");
17458        assert!(json["original_query"].is_string());
17459        assert!(json["query_type"].is_string());
17460        assert!(json["index_strategy"].is_string());
17461        assert!(json["estimated_cost"].is_string());
17462        assert!(json["parsed"]["terms"].is_array());
17463    }
17464
17465    // =========================================================================
17466    // Multi-filter combination tests (bead yln.2)
17467    // =========================================================================
17468
17469    #[test]
17470    fn search_multi_filter_agent_workspace_time() -> Result<()> {
17471        // Test combining agent, workspace, and time range filters
17472        let dir = TempDir::new()?;
17473        let mut index = TantivyIndex::open_or_create(dir.path())?;
17474
17475        // Create 4 conversations with different combinations
17476        let convs = [
17477            ("codex", "/ws/alpha", 100, "needle alpha codex"),
17478            ("claude", "/ws/alpha", 200, "needle alpha claude"),
17479            ("codex", "/ws/beta", 150, "needle beta codex"),
17480            ("codex", "/ws/alpha", 300, "needle alpha codex late"),
17481        ];
17482
17483        for (i, (agent, ws, ts, content)) in convs.iter().enumerate() {
17484            let conv = NormalizedConversation {
17485                agent_slug: (*agent).into(),
17486                external_id: None,
17487                title: Some(format!("conv-{i}")),
17488                workspace: Some(std::path::PathBuf::from(*ws)),
17489                source_path: dir.path().join(format!("{i}.jsonl")),
17490                started_at: Some(*ts),
17491                ended_at: None,
17492                metadata: serde_json::json!({}),
17493                messages: vec![NormalizedMessage {
17494                    idx: 0,
17495                    role: "user".into(),
17496                    author: None,
17497                    created_at: Some(*ts),
17498                    content: (*content).into(),
17499                    extra: serde_json::json!({}),
17500                    snippets: vec![],
17501                    invocations: Vec::new(),
17502                }],
17503            };
17504            index.add_conversation(&conv)?;
17505        }
17506        index.commit()?;
17507
17508        let client = SearchClient::open(dir.path(), None)?.expect("index present");
17509
17510        // Filter: codex + alpha + time 50-250
17511        let mut filters = SearchFilters::default();
17512        filters.agents.insert("codex".into());
17513        filters.workspaces.insert("/ws/alpha".into());
17514        filters.created_from = Some(50);
17515        filters.created_to = Some(250);
17516
17517        let hits = client.search("needle", filters, 10, 0, FieldMask::FULL)?;
17518        assert_eq!(
17519            hits.len(),
17520            1,
17521            "Should match only one conv (codex + alpha + ts=100)"
17522        );
17523        assert_eq!(hits[0].agent, "codex");
17524        assert_eq!(hits[0].workspace, "/ws/alpha");
17525        assert!(hits[0].content.contains("alpha codex"));
17526        assert!(!hits[0].content.contains("late")); // Not the ts=300 one
17527
17528        Ok(())
17529    }
17530
17531    #[test]
17532    fn search_multi_agent_filter() -> Result<()> {
17533        // Test filtering by multiple agents
17534        let dir = TempDir::new()?;
17535        let mut index = TantivyIndex::open_or_create(dir.path())?;
17536
17537        for agent in ["codex", "claude", "cline", "gemini"] {
17538            let conv = NormalizedConversation {
17539                agent_slug: agent.into(),
17540                external_id: None,
17541                title: Some(format!("{agent}-conv")),
17542                workspace: Some(std::path::PathBuf::from("/ws")),
17543                source_path: dir.path().join(format!("{agent}.jsonl")),
17544                started_at: Some(100),
17545                ended_at: None,
17546                metadata: serde_json::json!({}),
17547                messages: vec![NormalizedMessage {
17548                    idx: 0,
17549                    role: "user".into(),
17550                    author: None,
17551                    created_at: Some(100),
17552                    content: format!("needle from {agent}"),
17553                    extra: serde_json::json!({}),
17554                    snippets: vec![],
17555                    invocations: Vec::new(),
17556                }],
17557            };
17558            index.add_conversation(&conv)?;
17559        }
17560        index.commit()?;
17561
17562        let client = SearchClient::open(dir.path(), None)?.expect("index present");
17563
17564        // Filter for codex and claude only
17565        let mut filters = SearchFilters::default();
17566        filters.agents.insert("codex".into());
17567        filters.agents.insert("claude".into());
17568
17569        let hits = client.search("needle", filters, 10, 0, FieldMask::FULL)?;
17570        assert_eq!(hits.len(), 2);
17571        let agents: Vec<_> = hits.iter().map(|h| h.agent.as_str()).collect();
17572        assert!(agents.contains(&"codex"));
17573        assert!(agents.contains(&"claude"));
17574        assert!(!agents.contains(&"cline"));
17575        assert!(!agents.contains(&"gemini"));
17576
17577        Ok(())
17578    }
17579
17580    // =========================================================================
17581    // Cache metrics tests (bead yln.2)
17582    // =========================================================================
17583
17584    #[test]
17585    fn cache_metrics_incremented_on_operations() {
17586        let client = SearchClient {
17587            reader: None,
17588            sqlite: Mutex::new(None),
17589            sqlite_path: None,
17590            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
17591            reload_on_search: true,
17592            last_reload: Mutex::new(None),
17593            last_generation: Mutex::new(None),
17594            reload_epoch: Arc::new(AtomicU64::new(0)),
17595            warm_tx: None,
17596            _warm_handle: None,
17597            metrics: Metrics::default(),
17598            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
17599            semantic: Mutex::new(None),
17600            last_tantivy_total_count: Mutex::new(None),
17601        };
17602
17603        // Initial metrics should be zero
17604        let (hits, miss, shortfall, reloads, _) = client.metrics.snapshot_all();
17605        assert_eq!((hits, miss, shortfall, reloads), (0, 0, 0, 0));
17606
17607        // Simulate operations
17608        client.metrics.inc_cache_hits();
17609        client.metrics.inc_cache_hits();
17610        client.metrics.inc_cache_miss();
17611        client.metrics.inc_cache_shortfall();
17612        client.metrics.inc_reload();
17613
17614        let (hits, miss, shortfall, reloads, _) = client.metrics.snapshot_all();
17615        assert_eq!(hits, 2);
17616        assert_eq!(miss, 1);
17617        assert_eq!(shortfall, 1);
17618        assert_eq!(reloads, 1);
17619    }
17620
17621    #[test]
17622    fn cache_shard_name_deterministic() {
17623        // Verify that shard name generation is deterministic for same filters
17624        let client = SearchClient {
17625            reader: None,
17626            sqlite: Mutex::new(None),
17627            sqlite_path: None,
17628            prefix_cache: Mutex::new(CacheShards::new(*CACHE_TOTAL_CAP, *CACHE_BYTE_CAP)),
17629            reload_on_search: true,
17630            last_reload: Mutex::new(None),
17631            last_generation: Mutex::new(None),
17632            reload_epoch: Arc::new(AtomicU64::new(0)),
17633            warm_tx: None,
17634            _warm_handle: None,
17635            metrics: Metrics::default(),
17636            cache_namespace: format!("v{CACHE_KEY_VERSION}|schema:test"),
17637            semantic: Mutex::new(None),
17638            last_tantivy_total_count: Mutex::new(None),
17639        };
17640
17641        let filters1 = SearchFilters::default();
17642        let mut filters2 = SearchFilters::default();
17643        filters2.agents.insert("codex".into());
17644        let mut filters3 = SearchFilters::default();
17645        filters3.workspaces.insert("/tmp/cass-workspace".into());
17646
17647        // Same filters should always produce same shard name
17648        let shard1_first = client.shard_name(&filters1);
17649        let shard1_second = client.shard_name(&filters1);
17650        assert_eq!(
17651            shard1_first, shard1_second,
17652            "Same filters should produce same shard name"
17653        );
17654
17655        // Different filters produce different shard names
17656        let shard2 = client.shard_name(&filters2);
17657        assert_ne!(
17658            shard1_first, shard2,
17659            "Different filters should produce different shard names"
17660        );
17661
17662        // Shard name is deterministic
17663        assert_eq!(shard2, client.shard_name(&filters2));
17664        assert_eq!(
17665            client.shard_name(&filters3),
17666            "workspace:/tmp/cass-workspace"
17667        );
17668    }
17669
17670    // =========================================================================
17671    // Wildcard fallback edge cases (bead yln.2)
17672    // =========================================================================
17673
17674    #[test]
17675    fn wildcard_fallback_respects_filter_constraints() -> Result<()> {
17676        let dir = TempDir::new()?;
17677        let mut index = TantivyIndex::open_or_create(dir.path())?;
17678
17679        // Create conversations that would match wildcard but not filter
17680        let conv_match = NormalizedConversation {
17681            agent_slug: "codex".into(),
17682            external_id: None,
17683            title: Some("match".into()),
17684            workspace: Some(std::path::PathBuf::from("/target")),
17685            source_path: dir.path().join("match.jsonl"),
17686            started_at: Some(100),
17687            ended_at: None,
17688            metadata: serde_json::json!({}),
17689            messages: vec![NormalizedMessage {
17690                idx: 0,
17691                role: "user".into(),
17692                author: None,
17693                created_at: Some(100),
17694                content: "unique specific term here".into(),
17695                extra: serde_json::json!({}),
17696                snippets: vec![],
17697                invocations: Vec::new(),
17698            }],
17699        };
17700
17701        let conv_other = NormalizedConversation {
17702            agent_slug: "claude".into(),
17703            external_id: None,
17704            title: Some("other".into()),
17705            workspace: Some(std::path::PathBuf::from("/other")),
17706            source_path: dir.path().join("other.jsonl"),
17707            started_at: Some(100),
17708            ended_at: None,
17709            metadata: serde_json::json!({}),
17710            messages: vec![NormalizedMessage {
17711                idx: 0,
17712                role: "user".into(),
17713                author: None,
17714                created_at: Some(100),
17715                content: "unique specific also here".into(),
17716                extra: serde_json::json!({}),
17717                snippets: vec![],
17718                invocations: Vec::new(),
17719            }],
17720        };
17721
17722        index.add_conversation(&conv_match)?;
17723        index.add_conversation(&conv_other)?;
17724        index.commit()?;
17725
17726        let client = SearchClient::open(dir.path(), None)?.expect("index present");
17727
17728        // Search with filter that only matches conv_match
17729        let mut filters = SearchFilters::default();
17730        filters.agents.insert("codex".into());
17731
17732        let result =
17733            client.search_with_fallback("unique", filters.clone(), 10, 0, 100, FieldMask::FULL)?;
17734        // Should only return the codex conversation, not claude
17735        assert!(result.hits.iter().all(|h| h.agent == "codex"));
17736
17737        Ok(())
17738    }
17739
17740    #[test]
17741    fn wildcard_fallback_short_query_triggers_prefix() -> Result<()> {
17742        let dir = TempDir::new()?;
17743        let mut index = TantivyIndex::open_or_create(dir.path())?;
17744
17745        let conv = NormalizedConversation {
17746            agent_slug: "codex".into(),
17747            external_id: None,
17748            title: Some("test".into()),
17749            workspace: None,
17750            source_path: dir.path().join("test.jsonl"),
17751            started_at: Some(100),
17752            ended_at: None,
17753            metadata: serde_json::json!({}),
17754            messages: vec![NormalizedMessage {
17755                idx: 0,
17756                role: "user".into(),
17757                author: None,
17758                created_at: Some(100),
17759                content: "authentication authorization oauth".into(),
17760                extra: serde_json::json!({}),
17761                snippets: vec![],
17762                invocations: Vec::new(),
17763            }],
17764        };
17765        index.add_conversation(&conv)?;
17766        index.commit()?;
17767
17768        let client = SearchClient::open(dir.path(), None)?.expect("index present");
17769
17770        // Short prefix "auth" should match "authentication" and "authorization"
17771        let result = client.search_with_fallback(
17772            "auth",
17773            SearchFilters::default(),
17774            10,
17775            0,
17776            100,
17777            FieldMask::FULL,
17778        )?;
17779        assert!(
17780            !result.hits.is_empty(),
17781            "Short prefix should match via prefix search"
17782        );
17783        assert!(result.hits[0].content.contains("auth"));
17784
17785        Ok(())
17786    }
17787
17788    // =========================================================================
17789    // Real fixture tests with metrics (bead yln.2)
17790    // =========================================================================
17791
17792    #[test]
17793    fn search_real_fixture_multiple_messages() -> Result<()> {
17794        let dir = TempDir::new()?;
17795        let mut index = TantivyIndex::open_or_create(dir.path())?;
17796
17797        // Create a realistic conversation with multiple messages
17798        let conv = NormalizedConversation {
17799            agent_slug: "claude_code".into(),
17800            external_id: Some("conv-123".into()),
17801            title: Some("Implementing authentication".into()),
17802            workspace: Some(std::path::PathBuf::from("/home/user/project")),
17803            source_path: dir.path().join("session-1.jsonl"),
17804            started_at: Some(1700000000000),
17805            ended_at: Some(1700000060000),
17806            metadata: serde_json::json!({
17807                "model": "claude-3-sonnet",
17808                "tokens": 1500
17809            }),
17810            messages: vec![
17811                NormalizedMessage {
17812                    idx: 0,
17813                    role: "user".into(),
17814                    author: Some("developer".into()),
17815                    created_at: Some(1700000000000),
17816                    content: "Help me implement JWT authentication for my Express API".into(),
17817                    extra: serde_json::json!({}),
17818                    snippets: vec![],
17819                    invocations: Vec::new(),
17820                },
17821                NormalizedMessage {
17822                    idx: 1,
17823                    role: "assistant".into(),
17824                    author: Some("claude".into()),
17825                    created_at: Some(1700000010000),
17826                    content: "I'll help you implement JWT authentication. First, let's install the required packages.".into(),
17827                    extra: serde_json::json!({}),
17828                    snippets: vec![NormalizedSnippet {
17829                        file_path: Some("package.json".into()),
17830                        start_line: Some(1),
17831                        end_line: Some(5),
17832                        language: Some("json".into()),
17833                        snippet_text: Some(r#"{"dependencies":{"jsonwebtoken":"^9.0.0"}}"#.into()),
17834                    }],
17835                    invocations: Vec::new(),
17836                },
17837                NormalizedMessage {
17838                    idx: 2,
17839                    role: "user".into(),
17840                    author: Some("developer".into()),
17841                    created_at: Some(1700000030000),
17842                    content: "Can you also add refresh token support?".into(),
17843                    extra: serde_json::json!({}),
17844                    snippets: vec![],
17845                    invocations: Vec::new(),
17846                },
17847            ],
17848        };
17849        index.add_conversation(&conv)?;
17850        index.commit()?;
17851
17852        let client = SearchClient::open(dir.path(), None)?.expect("index present");
17853
17854        // Search for various terms that should match
17855        let hits = client.search(
17856            "JWT authentication",
17857            SearchFilters::default(),
17858            10,
17859            0,
17860            FieldMask::FULL,
17861        )?;
17862        assert!(!hits.is_empty(), "Should find JWT authentication");
17863        assert!(hits.iter().any(|h| h.agent == "claude_code"));
17864        assert!(
17865            hits.iter()
17866                .any(|h| h.snippet.contains("JWT") || h.snippet.contains("authentication"))
17867        );
17868
17869        // Search for assistant response content
17870        let hits = client.search(
17871            "required packages",
17872            SearchFilters::default(),
17873            10,
17874            0,
17875            FieldMask::FULL,
17876        )?;
17877        assert!(
17878            !hits.is_empty(),
17879            "Should find 'required packages' in assistant response"
17880        );
17881
17882        // Search for user question about refresh tokens
17883        let hits = client.search(
17884            "refresh token",
17885            SearchFilters::default(),
17886            10,
17887            0,
17888            FieldMask::FULL,
17889        )?;
17890        assert!(!hits.is_empty(), "Should find refresh token");
17891        assert!(hits.iter().any(|h| h.content.contains("refresh")));
17892
17893        Ok(())
17894    }
17895
17896    #[test]
17897    fn search_deduplication_with_similar_content() -> Result<()> {
17898        let dir = TempDir::new()?;
17899        let mut index = TantivyIndex::open_or_create(dir.path())?;
17900
17901        // Create two conversations with very similar content
17902        for i in 0..2 {
17903            let conv = NormalizedConversation {
17904                agent_slug: "codex".into(),
17905                external_id: None,
17906                title: Some(format!("similar-{i}")),
17907                workspace: Some(std::path::PathBuf::from("/ws")),
17908                source_path: dir.path().join(format!("similar-{i}.jsonl")),
17909                started_at: Some(100 + i),
17910                ended_at: None,
17911                metadata: serde_json::json!({}),
17912                messages: vec![NormalizedMessage {
17913                    idx: 0,
17914                    role: "user".into(),
17915                    author: None,
17916                    created_at: Some(100 + i),
17917                    // Exactly the same content
17918                    content: "implement the sorting algorithm".into(),
17919                    extra: serde_json::json!({}),
17920                    snippets: vec![],
17921                    invocations: Vec::new(),
17922                }],
17923            };
17924            index.add_conversation(&conv)?;
17925        }
17926        index.commit()?;
17927
17928        let client = SearchClient::open(dir.path(), None)?.expect("index present");
17929        let result = client.search_with_fallback(
17930            "sorting algorithm",
17931            SearchFilters::default(),
17932            10,
17933            0,
17934            100,
17935            FieldMask::FULL,
17936        )?;
17937
17938        // Both should be returned (different source_paths mean different conversations)
17939        // but if they have exact same content from same source, dedup should apply
17940        assert!(!result.hits.is_empty());
17941
17942        Ok(())
17943    }
17944
17945    // =========================================================================
17946    // Session paths filter tests (chained searches)
17947    // =========================================================================
17948
17949    #[test]
17950    fn search_session_paths_filter() -> Result<()> {
17951        // Test filtering by specific session source paths (for chained searches)
17952        let dir = TempDir::new()?;
17953        let mut index = TantivyIndex::open_or_create(dir.path())?;
17954
17955        // Create 3 conversations with different source paths
17956        let paths = [
17957            dir.path().join("session-a.jsonl"),
17958            dir.path().join("session-b.jsonl"),
17959            dir.path().join("session-c.jsonl"),
17960        ];
17961
17962        for (i, path) in paths.iter().enumerate() {
17963            let conv = NormalizedConversation {
17964                agent_slug: "claude".into(),
17965                external_id: None,
17966                title: Some(format!("session-{}", i)),
17967                workspace: Some(std::path::PathBuf::from("/ws")),
17968                source_path: path.clone(),
17969                started_at: Some(100 + i as i64),
17970                ended_at: None,
17971                metadata: serde_json::json!({}),
17972                messages: vec![NormalizedMessage {
17973                    idx: 0,
17974                    role: "user".into(),
17975                    author: None,
17976                    created_at: Some(100 + i as i64),
17977                    content: format!("needle content for session {}", i),
17978                    extra: serde_json::json!({}),
17979                    snippets: vec![],
17980                    invocations: Vec::new(),
17981                }],
17982            };
17983            index.add_conversation(&conv)?;
17984        }
17985        index.commit()?;
17986
17987        let client = SearchClient::open(dir.path(), None)?.expect("index present");
17988
17989        // First, search without filter - should get all 3
17990        let hits_all = client.search("needle", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
17991        assert_eq!(hits_all.len(), 3, "Should find all 3 sessions");
17992
17993        // Now filter to only sessions A and C
17994        let mut filters = SearchFilters::default();
17995        filters
17996            .session_paths
17997            .insert(paths[0].to_string_lossy().to_string());
17998        filters
17999            .session_paths
18000            .insert(paths[2].to_string_lossy().to_string());
18001
18002        let hits_filtered = client.search("needle", filters, 10, 0, FieldMask::FULL)?;
18003        assert_eq!(
18004            hits_filtered.len(),
18005            2,
18006            "Should find only 2 sessions (A and C)"
18007        );
18008
18009        // Verify the correct sessions are returned
18010        let filtered_paths: HashSet<&str> = hits_filtered
18011            .iter()
18012            .map(|h| h.source_path.as_str())
18013            .collect();
18014        assert!(filtered_paths.contains(paths[0].to_string_lossy().as_ref()));
18015        assert!(filtered_paths.contains(paths[2].to_string_lossy().as_ref()));
18016        assert!(!filtered_paths.contains(paths[1].to_string_lossy().as_ref()));
18017
18018        Ok(())
18019    }
18020
18021    #[test]
18022    fn lexical_session_paths_filter_retries_past_initial_page() -> Result<()> {
18023        let dir = TempDir::new()?;
18024        let mut index = TantivyIndex::open_or_create(dir.path())?;
18025        let requested_path = dir.path().join("requested-session.jsonl");
18026
18027        for i in 0..4 {
18028            let conv = NormalizedConversation {
18029                agent_slug: "claude".into(),
18030                external_id: None,
18031                title: Some(format!("distractor-{i}")),
18032                workspace: Some(std::path::PathBuf::from("/ws")),
18033                source_path: dir.path().join(format!("distractor-{i}.jsonl")),
18034                started_at: Some(100 + i as i64),
18035                ended_at: None,
18036                metadata: serde_json::json!({}),
18037                messages: vec![NormalizedMessage {
18038                    idx: 0,
18039                    role: "user".into(),
18040                    author: None,
18041                    created_at: Some(100 + i as i64),
18042                    content: "needle needle needle high ranking distractor".into(),
18043                    extra: serde_json::json!({}),
18044                    snippets: vec![],
18045                    invocations: Vec::new(),
18046                }],
18047            };
18048            index.add_conversation(&conv)?;
18049        }
18050
18051        let requested = NormalizedConversation {
18052            agent_slug: "claude".into(),
18053            external_id: None,
18054            title: Some("requested".into()),
18055            workspace: Some(std::path::PathBuf::from("/ws")),
18056            source_path: requested_path.clone(),
18057            started_at: Some(200),
18058            ended_at: None,
18059            metadata: serde_json::json!({}),
18060            messages: vec![NormalizedMessage {
18061                idx: 0,
18062                role: "user".into(),
18063                author: None,
18064                created_at: Some(200),
18065                content: "needle requested session should survive post-filter paging".into(),
18066                extra: serde_json::json!({}),
18067                snippets: vec![],
18068                invocations: Vec::new(),
18069            }],
18070        };
18071        index.add_conversation(&requested)?;
18072        index.commit()?;
18073
18074        let client = SearchClient::open(dir.path(), None)?.expect("index present");
18075        let mut filters = SearchFilters::default();
18076        filters
18077            .session_paths
18078            .insert(requested_path.to_string_lossy().to_string());
18079
18080        let hits = client.search("needle", filters, 1, 0, FieldMask::FULL)?;
18081
18082        assert_eq!(hits.len(), 1);
18083        assert_eq!(hits[0].source_path, requested_path.to_string_lossy());
18084
18085        Ok(())
18086    }
18087
18088    #[test]
18089    fn search_session_paths_empty_filter_returns_all() -> Result<()> {
18090        // Empty session_paths filter should not restrict results
18091        let dir = TempDir::new()?;
18092        let mut index = TantivyIndex::open_or_create(dir.path())?;
18093
18094        let conv = NormalizedConversation {
18095            agent_slug: "claude".into(),
18096            external_id: None,
18097            title: Some("test".into()),
18098            workspace: Some(std::path::PathBuf::from("/ws")),
18099            source_path: dir.path().join("test.jsonl"),
18100            started_at: Some(100),
18101            ended_at: None,
18102            metadata: serde_json::json!({}),
18103            messages: vec![NormalizedMessage {
18104                idx: 0,
18105                role: "user".into(),
18106                author: None,
18107                created_at: Some(100),
18108                content: "needle content".into(),
18109                extra: serde_json::json!({}),
18110                snippets: vec![],
18111                invocations: Vec::new(),
18112            }],
18113        };
18114        index.add_conversation(&conv)?;
18115        index.commit()?;
18116
18117        let client = SearchClient::open(dir.path(), None)?.expect("index present");
18118
18119        // Empty session_paths should not filter
18120        let filters = SearchFilters::default();
18121        assert!(filters.session_paths.is_empty());
18122
18123        let hits = client.search("needle", filters, 10, 0, FieldMask::FULL)?;
18124        assert_eq!(hits.len(), 1);
18125
18126        Ok(())
18127    }
18128
18129    #[test]
18130    fn search_client_reads_federated_lexical_bundle_as_one_corpus() -> Result<()> {
18131        let root = TempDir::new()?;
18132        let shard_a = root.path().join("shard-a");
18133        let shard_b = root.path().join("shard-b");
18134        let published = root.path().join("published");
18135
18136        let mut shard_a_index = TantivyIndex::open_or_create(&shard_a)?;
18137        let mut shard_b_index = TantivyIndex::open_or_create(&shard_b)?;
18138
18139        let make_conv =
18140            |external_id: &str, title: &str, source_path: &str, tag: &str| NormalizedConversation {
18141                agent_slug: "codex".into(),
18142                external_id: Some(external_id.into()),
18143                title: Some(title.into()),
18144                workspace: Some(std::path::PathBuf::from("/ws")),
18145                source_path: std::path::PathBuf::from(source_path),
18146                started_at: Some(1_700_000_100_000),
18147                ended_at: Some(1_700_000_100_100),
18148                metadata: json!({}),
18149                messages: vec![
18150                    NormalizedMessage {
18151                        idx: 0,
18152                        role: "user".into(),
18153                        author: None,
18154                        created_at: Some(1_700_000_100_010),
18155                        content: format!("shared federated needle {tag} user"),
18156                        extra: json!({}),
18157                        snippets: vec![],
18158                        invocations: Vec::new(),
18159                    },
18160                    NormalizedMessage {
18161                        idx: 1,
18162                        role: "assistant".into(),
18163                        author: None,
18164                        created_at: Some(1_700_000_100_020),
18165                        content: format!("shared federated needle {tag} assistant"),
18166                        extra: json!({}),
18167                        snippets: vec![],
18168                        invocations: Vec::new(),
18169                    },
18170                ],
18171            };
18172
18173        let conv_a = make_conv(
18174            "fed-query-a",
18175            "Fed Query A",
18176            "/tmp/fed-query-a.jsonl",
18177            "alpha",
18178        );
18179        let conv_b = make_conv(
18180            "fed-query-b",
18181            "Fed Query B",
18182            "/tmp/fed-query-b.jsonl",
18183            "beta",
18184        );
18185
18186        shard_a_index.add_conversation(&conv_a)?;
18187        shard_b_index.add_conversation(&conv_b)?;
18188        shard_a_index.commit()?;
18189        shard_b_index.commit()?;
18190        drop(shard_a_index);
18191        drop(shard_b_index);
18192
18193        crate::search::tantivy::publish_federated_searchable_index_directories(
18194            &published,
18195            &[&shard_a, &shard_b],
18196        )?;
18197
18198        let client = SearchClient::open(&published, None)?.expect("federated index present");
18199        assert!(client.has_tantivy());
18200        assert_eq!(client.total_docs(), 4);
18201
18202        let hits = client.search(
18203            "shared federated needle",
18204            SearchFilters::default(),
18205            10,
18206            0,
18207            FieldMask::FULL,
18208        )?;
18209        assert_eq!(hits.len(), 4);
18210        let observed_order = hits
18211            .iter()
18212            .map(|hit| {
18213                (
18214                    hit.source_path.clone(),
18215                    hit.line_number,
18216                    hit.content.clone(),
18217                    hit.score.to_bits(),
18218                )
18219            })
18220            .collect::<Vec<_>>();
18221        let hit_paths = hits
18222            .iter()
18223            .map(|hit| hit.source_path.as_str())
18224            .collect::<std::collections::HashSet<_>>();
18225        assert!(hit_paths.contains("/tmp/fed-query-a.jsonl"));
18226        assert!(hit_paths.contains("/tmp/fed-query-b.jsonl"));
18227
18228        for attempt in 0..3 {
18229            let repeated = client.search(
18230                "shared federated needle",
18231                SearchFilters::default(),
18232                10,
18233                0,
18234                FieldMask::FULL,
18235            )?;
18236            let repeated_order = repeated
18237                .iter()
18238                .map(|hit| {
18239                    (
18240                        hit.source_path.clone(),
18241                        hit.line_number,
18242                        hit.content.clone(),
18243                        hit.score.to_bits(),
18244                    )
18245                })
18246                .collect::<Vec<_>>();
18247            assert_eq!(
18248                repeated_order, observed_order,
18249                "federated lexical query order drifted on repeated attempt {attempt}"
18250            );
18251        }
18252
18253        Ok(())
18254    }
18255
18256    #[test]
18257    fn semantic_search_session_paths_filter_retries_past_initial_candidates() -> Result<()> {
18258        let fixture = build_semantic_test_fixture()?;
18259        let mut filters = SearchFilters::default();
18260        filters
18261            .session_paths
18262            .insert(fixture.source_paths[2].clone());
18263
18264        let (hits, ann_stats) = fixture.client.search_semantic(
18265            "semantic fixture query",
18266            filters,
18267            1,
18268            0,
18269            FieldMask::FULL,
18270            false,
18271        )?;
18272
18273        assert!(
18274            ann_stats.is_none(),
18275            "exact search should not emit ANN stats"
18276        );
18277        assert_eq!(
18278            hits.len(),
18279            1,
18280            "filtered semantic search should still return a hit"
18281        );
18282        assert_eq!(
18283            hits[0].source_path, fixture.source_paths[2],
18284            "semantic search should keep searching until it finds the requested session path"
18285        );
18286
18287        Ok(())
18288    }
18289
18290    #[test]
18291    fn semantic_search_offsets_after_session_paths_filtering() -> Result<()> {
18292        let fixture = build_semantic_test_fixture()?;
18293        let mut filters = SearchFilters::default();
18294        filters
18295            .session_paths
18296            .insert(fixture.source_paths[1].clone());
18297        filters
18298            .session_paths
18299            .insert(fixture.source_paths[2].clone());
18300
18301        let (hits, _) = fixture.client.search_semantic(
18302            "semantic fixture query",
18303            filters,
18304            1,
18305            1,
18306            FieldMask::FULL,
18307            false,
18308        )?;
18309
18310        assert_eq!(
18311            hits.len(),
18312            1,
18313            "second filtered page should still return one hit"
18314        );
18315        assert_eq!(
18316            hits[0].source_path, fixture.source_paths[2],
18317            "offset must apply after semantic deduplication and session path filtering"
18318        );
18319
18320        Ok(())
18321    }
18322
18323    #[test]
18324    fn semantic_search_merges_sharded_vector_indexes() -> Result<()> {
18325        let fixture = build_sharded_semantic_test_fixture()?;
18326        let (hits, ann_stats) = fixture.client.search_semantic(
18327            "semantic fixture query",
18328            SearchFilters::default(),
18329            3,
18330            0,
18331            FieldMask::FULL,
18332            false,
18333        )?;
18334
18335        assert!(
18336            ann_stats.is_none(),
18337            "sharded exact search should not emit ANN stats"
18338        );
18339        assert_eq!(hits.len(), 3);
18340        assert_eq!(hits[0].source_path, fixture.source_paths[0]);
18341        assert_eq!(hits[1].source_path, fixture.source_paths[1]);
18342        assert_eq!(hits[2].source_path, fixture.source_paths[2]);
18343
18344        Ok(())
18345    }
18346
18347    #[test]
18348    fn progressive_phase_overfetches_before_session_paths_filtering() -> Result<()> {
18349        let fixture = build_semantic_test_fixture()?;
18350        let mut filters = SearchFilters::default();
18351        filters
18352            .session_paths
18353            .insert(fixture.source_paths[2].clone());
18354
18355        let results = vec![
18356            FsScoredResult {
18357                doc_id: fixture.doc_ids[0].clone(),
18358                score: 1.0,
18359                source: FsScoreSource::SemanticFast,
18360                index: None,
18361                fast_score: Some(1.0),
18362                quality_score: None,
18363                lexical_score: None,
18364                rerank_score: None,
18365                explanation: None,
18366                metadata: None,
18367            },
18368            FsScoredResult {
18369                doc_id: fixture.doc_ids[1].clone(),
18370                score: 0.9,
18371                source: FsScoreSource::SemanticFast,
18372                index: None,
18373                fast_score: Some(0.9),
18374                quality_score: None,
18375                lexical_score: None,
18376                rerank_score: None,
18377                explanation: None,
18378                metadata: None,
18379            },
18380            FsScoredResult {
18381                doc_id: fixture.doc_ids[2].clone(),
18382                score: 0.8,
18383                source: FsScoreSource::SemanticFast,
18384                index: None,
18385                fast_score: Some(0.8),
18386                quality_score: None,
18387                lexical_score: None,
18388                rerank_score: None,
18389                explanation: None,
18390                metadata: None,
18391            },
18392        ];
18393
18394        let result = fixture.client.progressive_phase_to_result(
18395            &results,
18396            ProgressivePhaseContext {
18397                query: "session path filter",
18398                filters: &filters,
18399                field_mask: FieldMask::FULL,
18400                lexical_cache: None,
18401                limit: 1,
18402                fetch_limit: 3,
18403            },
18404        )?;
18405
18406        assert_eq!(
18407            result.hits.len(),
18408            1,
18409            "progressive phase should retain enough overfetched hits to satisfy post-search session path filtering"
18410        );
18411        assert_eq!(
18412            result.hits[0].source_path, fixture.source_paths[2],
18413            "progressive phase should page after session path filtering"
18414        );
18415
18416        Ok(())
18417    }
18418
18419    // =============================================================================
18420    // SQL Placeholder Builder Tests (Opt 4.5: Pre-sized String Buffers)
18421    // =============================================================================
18422
18423    #[test]
18424    fn sql_placeholders_empty() {
18425        assert_eq!(sql_placeholders(0), "");
18426    }
18427
18428    #[test]
18429    fn sql_placeholders_single() {
18430        assert_eq!(sql_placeholders(1), "?");
18431    }
18432
18433    #[test]
18434    fn sql_placeholders_multiple() {
18435        assert_eq!(sql_placeholders(3), "?,?,?");
18436        assert_eq!(sql_placeholders(5), "?,?,?,?,?");
18437    }
18438
18439    #[test]
18440    fn sql_placeholders_capacity_efficient() {
18441        // For count=3, capacity should be exactly 2*3-1=5 ("?,?,?" = 5 chars)
18442        let result = sql_placeholders(3);
18443        assert_eq!(result.len(), 5);
18444        assert!(result.capacity() >= 5); // Should have allocated at least 5
18445
18446        // For count=10, capacity should be exactly 2*10-1=19
18447        let result = sql_placeholders(10);
18448        assert_eq!(result.len(), 19);
18449        assert!(result.capacity() >= 19);
18450    }
18451
18452    #[test]
18453    fn sql_placeholders_large_count() {
18454        // Test with a large count to ensure no off-by-one errors
18455        let result = sql_placeholders(100);
18456        assert_eq!(result.len(), 199); // 100 "?" + 99 ","
18457        assert_eq!(result.chars().filter(|c| *c == '?').count(), 100);
18458        assert_eq!(result.chars().filter(|c| *c == ',').count(), 99);
18459    }
18460
18461    #[test]
18462    fn hybrid_budget_identifier_biases_lexical() {
18463        let budget = hybrid_candidate_budget("src/main.rs", 20, 20, 5, 10_000);
18464        assert!(
18465            budget.lexical_candidates > budget.semantic_candidates,
18466            "identifier queries should allocate more lexical than semantic fanout"
18467        );
18468        assert!(budget.lexical_candidates >= 25);
18469    }
18470
18471    #[test]
18472    fn hybrid_budget_natural_language_biases_semantic() {
18473        let budget = hybrid_candidate_budget(
18474            "how do we fix authentication middleware latency",
18475            20,
18476            20,
18477            5,
18478            10_000,
18479        );
18480        assert!(
18481            budget.semantic_candidates > budget.lexical_candidates,
18482            "natural language queries should allocate more semantic than lexical fanout"
18483        );
18484    }
18485
18486    #[test]
18487    fn hybrid_budget_no_limit_caps_both_lexical_and_semantic() {
18488        // Regression: a "no limit" hybrid search on a large corpus used to
18489        // set `lexical_candidates = total_docs`, which let a single
18490        // `cass search` request grow to tens of GB of RAM on a ~500k-row
18491        // user history and saturate disk IO. Both lexical and semantic
18492        // fanout are now bounded, lexical against the RAM-proportional
18493        // `no_limit_result_cap()` ceiling and semantic against the narrower
18494        // `HYBRID_NO_LIMIT_SEMANTIC_CAP` ceiling.
18495        let total_docs = 2_000_000;
18496        let budget =
18497            hybrid_candidate_budget("authentication middleware", 0, total_docs, 0, total_docs);
18498        let cap = no_limit_result_cap();
18499        assert!(
18500            budget.lexical_candidates <= cap,
18501            "lexical fanout must respect no_limit_result_cap() = {cap}; got {}",
18502            budget.lexical_candidates
18503        );
18504        assert!(
18505            budget.lexical_candidates <= NO_LIMIT_RESULT_MAX,
18506            "lexical fanout must respect the absolute NO_LIMIT_RESULT_MAX; got {}",
18507            budget.lexical_candidates
18508        );
18509        assert!(budget.semantic_candidates <= HYBRID_NO_LIMIT_SEMANTIC_CAP);
18510        // Invariant preserved by the `.min(lexical)` clamp inside
18511        // hybrid_candidate_budget: semantic fanout never exceeds
18512        // lexical fanout. On typical hosts lexical >> semantic, but
18513        // the cheaper `<=` assertion also holds on edge-case tiny
18514        // boxes where the overall cap pulls lexical down to the
18515        // planning window.
18516        assert!(
18517            budget.semantic_candidates <= budget.lexical_candidates,
18518            "semantic ({}) must not exceed lexical ({}) fanout",
18519            budget.semantic_candidates,
18520            budget.lexical_candidates
18521        );
18522    }
18523
18524    #[test]
18525    fn compute_no_limit_result_cap_clamps_explicit_over_ceiling_env_override() {
18526        // A naively large explicit override must still be clamped. The
18527        // old implementation returned the env value unclamped, which
18528        // reintroduced the unbounded-result failure mode. Driven via
18529        // the pure `*_from` helper so we can't race with other
18530        // concurrent tests that read the real env.
18531        let cap = compute_no_limit_result_cap_from(Some("999999999999".to_string()), None, None);
18532        assert!(
18533            cap <= NO_LIMIT_RESULT_MAX,
18534            "explicit override must still clamp to ceiling; got {cap} > {NO_LIMIT_RESULT_MAX}"
18535        );
18536        assert!(cap >= NO_LIMIT_RESULT_MIN);
18537    }
18538
18539    #[test]
18540    fn compute_no_limit_result_cap_clamps_tiny_explicit_override_up_to_floor() {
18541        // Mirror case: an explicit override under the floor is lifted.
18542        let cap = compute_no_limit_result_cap_from(Some("1".to_string()), None, None);
18543        assert_eq!(cap, NO_LIMIT_RESULT_MIN);
18544    }
18545
18546    #[test]
18547    fn exact_total_count_policy_allows_small_indexes_only() {
18548        assert!(should_collect_exact_total_count(49_999, 50_000));
18549        assert!(should_collect_exact_total_count(50_000, 50_000));
18550        assert!(!should_collect_exact_total_count(50_001, 50_000));
18551    }
18552
18553    #[test]
18554    fn exact_total_count_policy_zero_limit_disables_recount() {
18555        assert!(!should_collect_exact_total_count(0, 0));
18556        assert!(!should_collect_exact_total_count(1, 0));
18557        assert!(!should_collect_exact_total_count(usize::MAX, 0));
18558    }
18559
18560    #[test]
18561    fn automatic_wildcard_fallback_policy_allows_small_indexes_only() {
18562        assert!(should_allow_automatic_wildcard_fallback(9_999, 10_000));
18563        assert!(should_allow_automatic_wildcard_fallback(10_000, 10_000));
18564        assert!(!should_allow_automatic_wildcard_fallback(10_001, 10_000));
18565    }
18566
18567    #[test]
18568    fn automatic_wildcard_fallback_policy_zero_limit_disables_fallback() {
18569        assert!(!should_allow_automatic_wildcard_fallback(0, 0));
18570        assert!(!should_allow_automatic_wildcard_fallback(1, 0));
18571        assert!(!should_allow_automatic_wildcard_fallback(usize::MAX, 0));
18572    }
18573
18574    #[test]
18575    fn compute_no_limit_result_cap_uses_meminfo_when_no_env_override() {
18576        // 128 GiB available → 128 / 16 = 8 GiB budget (under the 16 GiB
18577        // ceiling, above the 256 MiB floor) → 8 GiB / 80 KiB ≈ 104k
18578        // hits. That lands inside [MIN, MAX] and above floor.
18579        let cap = compute_no_limit_result_cap_from(None, None, Some(128u64 * 1024 * 1024 * 1024));
18580        assert!(cap >= NO_LIMIT_RESULT_MIN, "cap {cap} below floor");
18581        assert!(cap <= NO_LIMIT_RESULT_MAX, "cap {cap} above ceiling");
18582        // Sanity: 128 GiB / 16 / 80 KiB is nowhere near 1k.
18583        assert!(cap > NO_LIMIT_RESULT_MIN * 10);
18584    }
18585
18586    #[test]
18587    fn compute_no_limit_result_cap_falls_back_to_floor_when_meminfo_unavailable() {
18588        // Simulates non-Linux (no /proc/meminfo): must still produce a
18589        // finite, in-envelope cap. The floor budget (256 MiB) / 80 KiB
18590        // ≈ 3276 hits — above MIN, below MAX.
18591        let cap = compute_no_limit_result_cap_from(None, None, None);
18592        assert!(cap >= NO_LIMIT_RESULT_MIN);
18593        assert!(cap <= NO_LIMIT_RESULT_MAX);
18594    }
18595
18596    #[test]
18597    fn compute_no_limit_result_cap_bytes_env_takes_priority_over_meminfo() {
18598        // Explicit bytes override wins over MemAvailable. 4 GiB bytes
18599        // / 80 KiB ≈ 52k hits, distinct from what a large MemAvailable
18600        // hint would otherwise produce (which would hit the 16 GiB
18601        // ceiling → ~209k hits).
18602        let four_gib = (4u64 * 1024 * 1024 * 1024).to_string();
18603        let cap = compute_no_limit_result_cap_from(
18604            None,
18605            Some(four_gib),
18606            Some(1024u64 * 1024 * 1024 * 1024), // 1 TiB (would ceiling otherwise)
18607        );
18608        let expected_hits = ((4u64 * 1024 * 1024 * 1024) / AVG_HIT_BYTES) as usize;
18609        let expected = expected_hits.clamp(NO_LIMIT_RESULT_MIN, NO_LIMIT_RESULT_MAX);
18610        assert_eq!(cap, expected, "bytes env must win over meminfo");
18611    }
18612
18613    #[test]
18614    fn no_limit_budget_bytes_preserves_fallback_priority() {
18615        let huge_meminfo = Some(1024u64 * 1024 * 1024 * 1024);
18616        let four_gib = 4u64 * 1024 * 1024 * 1024;
18617
18618        assert_eq!(
18619            no_limit_budget_bytes(Some(four_gib.to_string()), huge_meminfo),
18620            four_gib
18621        );
18622        assert_eq!(
18623            no_limit_budget_bytes(Some("0".to_string()), huge_meminfo),
18624            NO_LIMIT_BYTES_CEILING
18625        );
18626        assert_eq!(no_limit_budget_bytes(None, None), NO_LIMIT_BYTES_FLOOR);
18627    }
18628
18629    #[test]
18630    fn compute_no_limit_result_cap_ignores_malformed_env() {
18631        // Garbage or zero values fall back to meminfo / floor, not crash.
18632        for bad in ["", "abc", "0", "-1"] {
18633            let cap = compute_no_limit_result_cap_from(
18634                Some(bad.to_string()),
18635                Some(bad.to_string()),
18636                None,
18637            );
18638            assert!(cap >= NO_LIMIT_RESULT_MIN, "bad={bad:?} cap={cap}");
18639            assert!(cap <= NO_LIMIT_RESULT_MAX, "bad={bad:?} cap={cap}");
18640        }
18641    }
18642
18643    // =============================================================================
18644    // RRF (Reciprocal Rank Fusion) Tests
18645    // =============================================================================
18646
18647    fn make_test_hit(id: &str, score: f32) -> SearchHit {
18648        SearchHit {
18649            title: id.to_string(),
18650            snippet: String::new(),
18651            content: id.to_string(),
18652            content_hash: stable_content_hash(id),
18653            score,
18654            source_path: format!("/path/{}.jsonl", id),
18655            agent: "test".to_string(),
18656            workspace: "/workspace".to_string(),
18657            workspace_original: None,
18658            created_at: Some(1_700_000_000_000),
18659            line_number: Some(1),
18660            match_type: MatchType::Exact,
18661            source_id: "local".to_string(),
18662            origin_kind: "local".to_string(),
18663            origin_host: None,
18664            conversation_id: None,
18665        }
18666    }
18667
18668    #[test]
18669    fn test_rrf_fusion_ordering() {
18670        // Test that RRF correctly combines rankings from both lists
18671        // Higher ranks in both lists should result in higher final ranking
18672        let lexical = vec![
18673            make_test_hit("A", 10.0),
18674            make_test_hit("B", 8.0),
18675            make_test_hit("C", 6.0),
18676        ];
18677        let semantic = vec![
18678            make_test_hit("A", 0.9),
18679            make_test_hit("B", 0.7),
18680            make_test_hit("D", 0.5),
18681        ];
18682
18683        let fused = rrf_fuse_hits(&lexical, &semantic, "", 10, 0);
18684
18685        // A and B should be top (in both lists), A first (rank 0 in both)
18686        assert_eq!(fused.len(), 4);
18687        assert_eq!(fused[0].title, "A"); // Rank 0 in both
18688        assert_eq!(fused[1].title, "B"); // Rank 1 in both
18689        // C and D are in only one list each, order depends on their ranks
18690    }
18691
18692    #[test]
18693    fn test_rrf_handles_disjoint_sets() {
18694        // Test with no overlap between lexical and semantic results
18695        let lexical = vec![make_test_hit("A", 10.0), make_test_hit("B", 8.0)];
18696        let semantic = vec![make_test_hit("C", 0.9), make_test_hit("D", 0.7)];
18697
18698        let fused = rrf_fuse_hits(&lexical, &semantic, "", 10, 0);
18699
18700        // All 4 items should be present
18701        assert_eq!(fused.len(), 4);
18702        let titles: Vec<&str> = fused.iter().map(|h| h.title.as_str()).collect();
18703        assert!(titles.contains(&"A"));
18704        assert!(titles.contains(&"B"));
18705        assert!(titles.contains(&"C"));
18706        assert!(titles.contains(&"D"));
18707    }
18708
18709    #[test]
18710    fn test_rrf_tie_breaking_deterministic() {
18711        // Test that results are deterministic - same input always produces same output
18712        let lexical = vec![
18713            make_test_hit("X", 5.0),
18714            make_test_hit("Y", 5.0),
18715            make_test_hit("Z", 5.0),
18716        ];
18717        let semantic = vec![]; // Empty semantic list
18718
18719        // Run multiple times and verify same order
18720        let fused1 = rrf_fuse_hits(&lexical, &semantic, "", 10, 0);
18721        let fused2 = rrf_fuse_hits(&lexical, &semantic, "", 10, 0);
18722        let fused3 = rrf_fuse_hits(&lexical, &semantic, "", 10, 0);
18723
18724        // Order should be deterministic based on key comparison
18725        assert_eq!(fused1.len(), fused2.len());
18726        assert_eq!(fused2.len(), fused3.len());
18727
18728        for i in 0..fused1.len() {
18729            assert_eq!(fused1[i].title, fused2[i].title, "Mismatch at index {}", i);
18730            assert_eq!(fused2[i].title, fused3[i].title, "Mismatch at index {}", i);
18731        }
18732    }
18733
18734    #[test]
18735    fn test_rrf_both_lists_bonus() {
18736        // Documents appearing in both lists should rank higher than those in only one
18737        // Even if their individual ranks are lower
18738        let lexical = vec![
18739            make_test_hit("solo_lex", 10.0), // Rank 0 lexical only
18740            make_test_hit("both", 5.0),      // Rank 1 lexical
18741        ];
18742        let semantic = vec![
18743            make_test_hit("solo_sem", 0.9), // Rank 0 semantic only
18744            make_test_hit("both", 0.5),     // Rank 1 semantic
18745        ];
18746
18747        let fused = rrf_fuse_hits(&lexical, &semantic, "", 10, 0);
18748
18749        // "both" should be first due to appearing in both lists
18750        // It gets RRF score from rank 1 in both lists = 1/(60+2) * 2 = 0.0322
18751        // vs solo items get 1/(60+1) = 0.0164 each
18752        assert_eq!(
18753            fused[0].title, "both",
18754            "Doc in both lists should rank first"
18755        );
18756    }
18757
18758    #[test]
18759    fn test_rrf_respects_limit_and_offset() {
18760        let lexical = vec![
18761            make_test_hit("A", 10.0),
18762            make_test_hit("B", 8.0),
18763            make_test_hit("C", 6.0),
18764        ];
18765        let semantic = vec![];
18766
18767        // Test limit
18768        let fused = rrf_fuse_hits(&lexical, &semantic, "", 2, 0);
18769        assert_eq!(fused.len(), 2);
18770
18771        // Test offset
18772        let fused_offset = rrf_fuse_hits(&lexical, &semantic, "", 10, 1);
18773        assert_eq!(fused_offset.len(), 2); // Skipped first one
18774
18775        // Test limit 0
18776        let fused_empty = rrf_fuse_hits(&lexical, &semantic, "", 0, 0);
18777        assert!(fused_empty.is_empty());
18778    }
18779
18780    #[test]
18781    fn test_rrf_empty_inputs() {
18782        let empty: Vec<SearchHit> = vec![];
18783        let non_empty = vec![make_test_hit("A", 10.0)];
18784
18785        // Both empty
18786        assert!(rrf_fuse_hits(&empty, &empty, "", 10, 0).is_empty());
18787
18788        // Lexical empty
18789        let fused = rrf_fuse_hits(&empty, &non_empty, "", 10, 0);
18790        assert_eq!(fused.len(), 1);
18791        assert_eq!(fused[0].title, "A");
18792
18793        // Semantic empty
18794        let fused = rrf_fuse_hits(&non_empty, &empty, "", 10, 0);
18795        assert_eq!(fused.len(), 1);
18796        assert_eq!(fused[0].title, "A");
18797    }
18798
18799    #[test]
18800    fn test_rrf_coalesces_empty_title_hits_across_search_modes() {
18801        let mut lexical = make_test_hit("shared", 10.0);
18802        lexical.title.clear();
18803        lexical.source_path = "/shared/untitled.jsonl".into();
18804        lexical.content = "same untitled body".into();
18805        lexical.content_hash = stable_content_hash("same untitled body");
18806
18807        let mut semantic = lexical.clone();
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].title, "");
18813    }
18814
18815    #[test]
18816    fn test_rrf_coalesces_blank_local_source_id_hits_across_search_modes() {
18817        let mut lexical = make_test_hit("shared-local", 10.0);
18818        lexical.source_path = "/shared/local.jsonl".into();
18819        lexical.content = "same local body".into();
18820        lexical.content_hash = stable_content_hash("same local body");
18821        lexical.source_id = "local".into();
18822        lexical.origin_kind = "local".into();
18823
18824        let mut semantic = lexical.clone();
18825        semantic.source_id = "   ".into();
18826        semantic.origin_kind = "local".into();
18827        semantic.score = 0.9;
18828
18829        let fused = rrf_fuse_hits(&[lexical], &[semantic], "", 10, 0);
18830        assert_eq!(fused.len(), 1);
18831        assert_eq!(fused[0].source_id, "local");
18832    }
18833
18834    #[test]
18835    fn test_rrf_keeps_repeated_same_content_at_different_lines() {
18836        let mut first = make_test_hit("same", 10.0);
18837        first.title = "Shared Session".into();
18838        first.source_path = "/shared/session.jsonl".into();
18839        first.content = "repeat me".into();
18840        first.content_hash = stable_content_hash("repeat me");
18841        first.line_number = Some(1);
18842        first.created_at = Some(100);
18843
18844        let mut second = first.clone();
18845        second.line_number = Some(2);
18846        second.created_at = Some(200);
18847        second.score = 0.9;
18848
18849        let fused = rrf_fuse_hits(&[first], &[second], "", 10, 0);
18850        assert_eq!(fused.len(), 2);
18851        assert_eq!(fused[0].line_number, Some(1));
18852        assert_eq!(fused[1].line_number, Some(2));
18853    }
18854
18855    #[test]
18856    fn test_rrf_coalesces_present_and_missing_conversation_id_for_same_message() {
18857        let mut lexical = make_test_hit("same", 10.0);
18858        lexical.title = "Shared Session".into();
18859        lexical.source_path = "/shared/session.jsonl".into();
18860        lexical.content = "identical body".into();
18861        lexical.content_hash = stable_content_hash("identical body");
18862        lexical.created_at = Some(100);
18863        lexical.line_number = Some(1);
18864        lexical.conversation_id = None;
18865
18866        let mut semantic = lexical.clone();
18867        semantic.conversation_id = Some(42);
18868        semantic.score = 0.9;
18869
18870        let fused = rrf_fuse_hits(&[lexical], &[semantic], "", 10, 0);
18871        assert_eq!(fused.len(), 1);
18872        assert_eq!(fused[0].conversation_id, Some(42));
18873    }
18874
18875    #[test]
18876    fn test_rrf_coalesces_present_and_missing_conversation_id_despite_blank_local_source_id() {
18877        let mut lexical = make_test_hit("same", 10.0);
18878        lexical.title = "Shared Session".into();
18879        lexical.source_path = "/shared/session.jsonl".into();
18880        lexical.content = "identical body".into();
18881        lexical.content_hash = stable_content_hash("identical body");
18882        lexical.created_at = Some(100);
18883        lexical.line_number = Some(1);
18884        lexical.conversation_id = None;
18885        lexical.source_id = "local".into();
18886        lexical.origin_kind = "local".into();
18887
18888        let mut semantic = lexical.clone();
18889        semantic.conversation_id = Some(42);
18890        semantic.source_id = "   ".into();
18891        semantic.origin_kind = "local".into();
18892        semantic.score = 0.9;
18893
18894        let fused = rrf_fuse_hits(&[lexical], &[semantic], "", 10, 0);
18895        assert_eq!(fused.len(), 1);
18896        assert_eq!(fused[0].conversation_id, Some(42));
18897    }
18898
18899    #[test]
18900    fn test_rrf_keeps_distinct_conversation_ids_for_shared_path_and_content() {
18901        let mut first = make_test_hit("same", 10.0);
18902        first.title = "Shared Session".into();
18903        first.source_path = "/shared/session.jsonl".into();
18904        first.content = "identical body".into();
18905        first.content_hash = stable_content_hash("identical body");
18906        first.conversation_id = Some(1);
18907
18908        let mut second = first.clone();
18909        second.conversation_id = Some(2);
18910        second.score = 0.9;
18911
18912        let fused = rrf_fuse_hits(&[first], &[second], "", 10, 0);
18913        assert_eq!(fused.len(), 2);
18914        assert!(fused.iter().any(|hit| hit.conversation_id == Some(1)));
18915        assert!(fused.iter().any(|hit| hit.conversation_id == Some(2)));
18916    }
18917
18918    #[test]
18919    fn test_rrf_coalesces_same_conversation_id_despite_title_drift() {
18920        let mut lexical = make_test_hit("same", 10.0);
18921        lexical.title = "Morning Session".into();
18922        lexical.source_path = "/shared/session.jsonl".into();
18923        lexical.content = "identical body".into();
18924        lexical.content_hash = stable_content_hash("identical body");
18925        lexical.conversation_id = Some(9);
18926
18927        let mut semantic = lexical.clone();
18928        semantic.title = "Evening Session".into();
18929        semantic.score = 0.9;
18930
18931        let fused = rrf_fuse_hits(&[lexical], &[semantic], "", 10, 0);
18932        assert_eq!(fused.len(), 1);
18933        assert_eq!(fused[0].conversation_id, Some(9));
18934    }
18935
18936    #[test]
18937    fn test_rrf_keeps_distinct_titles_for_shared_path_and_content() {
18938        let mut morning = make_test_hit("same", 10.0);
18939        morning.title = "Morning Session".into();
18940        morning.source_path = "/shared/session.jsonl".into();
18941        morning.content = "identical body".into();
18942        morning.content_hash = stable_content_hash("identical body");
18943        morning.created_at = None;
18944
18945        let mut evening = morning.clone();
18946        evening.title = "Evening Session".into();
18947        evening.score = 0.9;
18948
18949        let fused = rrf_fuse_hits(&[morning], &[evening], "", 10, 0);
18950        assert_eq!(fused.len(), 2);
18951        assert!(fused.iter().any(|hit| hit.title == "Morning Session"));
18952        assert!(fused.iter().any(|hit| hit.title == "Evening Session"));
18953    }
18954
18955    #[test]
18956    fn test_rrf_candidate_depth() {
18957        // Test with many candidates to ensure proper fusion
18958        let lexical: Vec<_> = (0..50)
18959            .map(|i| make_test_hit(&format!("L{}", i), 100.0 - i as f32))
18960            .collect();
18961        let semantic: Vec<_> = (0..50)
18962            .map(|i| make_test_hit(&format!("S{}", i), 1.0 - 0.01 * i as f32))
18963            .collect();
18964
18965        let fused = rrf_fuse_hits(&lexical, &semantic, "", 20, 0);
18966
18967        // Should return 20 items
18968        assert_eq!(fused.len(), 20);
18969
18970        // All items should be unique
18971        let mut seen = std::collections::HashSet::new();
18972        for hit in &fused {
18973            assert!(seen.insert(&hit.title), "Duplicate hit: {}", hit.title);
18974        }
18975    }
18976
18977    // ==========================================================================
18978    // QueryTokenList Behavior Tests (Opt 4.4)
18979    // ==========================================================================
18980
18981    #[test]
18982    fn query_token_list_parses_small_queries() {
18983        let cases = [
18984            ("hello", 1),
18985            ("hello world", 2),
18986            ("hello AND world", 3),
18987            ("hello world foo bar", 4),
18988        ];
18989
18990        for (query, expected_len) in cases {
18991            let tokens = parse_boolean_query(query);
18992            assert_eq!(tokens.len(), expected_len, "{query}");
18993        }
18994    }
18995
18996    #[test]
18997    fn query_token_list_parses_large_queries() {
18998        let tokens = parse_boolean_query("a b c d e f g h i");
18999        assert_eq!(tokens.len(), 9);
19000    }
19001
19002    #[test]
19003    fn query_token_list_handles_quoted_phrases() {
19004        let tokens = parse_boolean_query("\"hello world\" test");
19005        assert_eq!(tokens.len(), 2);
19006
19007        // Verify the phrase is correctly parsed
19008        assert!(
19009            matches!(&tokens[0], QueryToken::Phrase(phrase) if phrase == "hello world"),
19010            "Expected Phrase token"
19011        );
19012    }
19013
19014    #[test]
19015    fn query_token_list_handles_operators() {
19016        let tokens = parse_boolean_query("foo AND bar OR baz");
19017        assert_eq!(tokens.len(), 5);
19018        assert_eq!(tokens[1], QueryToken::And);
19019        assert_eq!(tokens[3], QueryToken::Or);
19020    }
19021
19022    #[test]
19023    fn query_token_list_empty_query() {
19024        let tokens = parse_boolean_query("");
19025        assert!(tokens.is_empty());
19026    }
19027
19028    #[test]
19029    fn query_token_list_iteration_works() {
19030        let tokens = parse_boolean_query("a b c");
19031        let terms: Vec<_> = tokens
19032            .iter()
19033            .filter_map(|t| match t {
19034                QueryToken::Term(s) => Some(s.as_str()),
19035                _ => None,
19036            })
19037            .collect();
19038        assert_eq!(terms, vec!["a", "b", "c"]);
19039    }
19040
19041    // ==========================================================================
19042    // Unicode Query Parsing Tests (br-327c)
19043    // Comprehensive Unicode handling tests covering emoji, CJK, RTL, mixed
19044    // scripts, zero-width characters, combining characters, normalization,
19045    // supplementary plane characters, and bidirectional text.
19046    // ==========================================================================
19047
19048    // --- Emoji queries ---
19049
19050    #[test]
19051    fn unicode_emoji_treated_as_separator() {
19052        // Emoji are not alphanumeric per Unicode, so sanitize_query replaces them with spaces
19053        let sanitized = sanitize_query("🚀 launch");
19054        assert_eq!(sanitized, "  launch", "Emoji should become space");
19055    }
19056
19057    #[test]
19058    fn unicode_emoji_splits_terms() {
19059        // Emoji between words acts as a separator
19060        let sanitized = sanitize_query("hot🔥code");
19061        assert_eq!(sanitized, "hot code", "Emoji between words splits them");
19062    }
19063
19064    #[test]
19065    fn unicode_multiple_emoji_become_spaces() {
19066        let sanitized = sanitize_query("🚀🔥💻");
19067        assert_eq!(
19068            sanitized.trim(),
19069            "",
19070            "All-emoji query sanitizes to whitespace"
19071        );
19072    }
19073
19074    #[test]
19075    fn unicode_emoji_query_parses_without_panic() {
19076        let tokens = parse_boolean_query("🚀 launch code 🔥");
19077        let terms: Vec<_> = tokens
19078            .iter()
19079            .filter_map(|t| match t {
19080                QueryToken::Term(s) => Some(s.clone()),
19081                _ => None,
19082            })
19083            .collect();
19084        // Emoji removed by sanitization in normalize_term_parts, only words remain
19085        assert!(
19086            terms
19087                .iter()
19088                .any(|t| t.contains("launch") || t.contains("code"))
19089        );
19090    }
19091
19092    #[test]
19093    fn unicode_emoji_query_terms_lower() {
19094        let terms = QueryTermsLower::from_query("🚀 LAUNCH");
19095        // Emoji becomes space, LAUNCH lowercased
19096        let tokens: Vec<&str> = terms.tokens().collect();
19097        assert!(
19098            tokens.contains(&"launch"),
19099            "Should extract 'launch' from emoji query"
19100        );
19101    }
19102
19103    // --- CJK character queries ---
19104
19105    #[test]
19106    fn unicode_cjk_chinese_preserved() {
19107        assert_eq!(sanitize_query("测试代码"), "测试代码");
19108        assert_eq!(sanitize_query("测试 代码"), "测试 代码");
19109    }
19110
19111    #[test]
19112    fn unicode_cjk_japanese_preserved() {
19113        assert_eq!(sanitize_query("テスト"), "テスト");
19114        // Hiragana and Katakana are alphanumeric
19115        assert_eq!(sanitize_query("こんにちは世界"), "こんにちは世界");
19116    }
19117
19118    #[test]
19119    fn unicode_cjk_korean_preserved() {
19120        assert_eq!(sanitize_query("테스트"), "테스트");
19121        assert_eq!(sanitize_query("안녕하세요"), "안녕하세요");
19122    }
19123
19124    #[test]
19125    fn unicode_cjk_parsed_as_terms() {
19126        let tokens = parse_boolean_query("测试 代码 search");
19127        let terms: Vec<_> = tokens
19128            .iter()
19129            .filter_map(|t| match t {
19130                QueryToken::Term(s) => Some(s.as_str()),
19131                _ => None,
19132            })
19133            .collect();
19134        assert_eq!(terms, vec!["测试", "代码", "search"]);
19135    }
19136
19137    #[test]
19138    fn unicode_cjk_query_terms_lower() {
19139        let terms = QueryTermsLower::from_query("测试 代码");
19140        let tokens: Vec<&str> = terms.tokens().collect();
19141        assert_eq!(tokens, vec!["测试", "代码"]);
19142    }
19143
19144    // --- RTL text queries ---
19145
19146    #[test]
19147    fn unicode_hebrew_preserved() {
19148        assert_eq!(sanitize_query("שלום עולם"), "שלום עולם");
19149    }
19150
19151    #[test]
19152    fn unicode_arabic_preserved() {
19153        assert_eq!(sanitize_query("مرحبا"), "مرحبا");
19154    }
19155
19156    #[test]
19157    fn unicode_hebrew_parsed_as_terms() {
19158        let tokens = parse_boolean_query("שלום עולם");
19159        let terms: Vec<_> = tokens
19160            .iter()
19161            .filter_map(|t| match t {
19162                QueryToken::Term(s) => Some(s.as_str()),
19163                _ => None,
19164            })
19165            .collect();
19166        assert_eq!(terms, vec!["שלום", "עולם"]);
19167    }
19168
19169    #[test]
19170    fn unicode_arabic_query_terms_lower() {
19171        // Arabic doesn't have case, so lowercasing is a no-op
19172        let terms = QueryTermsLower::from_query("مرحبا بالعالم");
19173        let tokens: Vec<&str> = terms.tokens().collect();
19174        assert_eq!(tokens, vec!["مرحبا", "بالعالم"]);
19175    }
19176
19177    // --- Mixed script queries ---
19178
19179    #[test]
19180    fn unicode_mixed_scripts_preserved() {
19181        let sanitized = sanitize_query("Hello 世界 мир");
19182        assert_eq!(sanitized, "Hello 世界 мир");
19183    }
19184
19185    #[test]
19186    fn unicode_mixed_scripts_parsed() {
19187        let tokens = parse_boolean_query("Hello 世界 мир");
19188        let terms: Vec<_> = tokens
19189            .iter()
19190            .filter_map(|t| match t {
19191                QueryToken::Term(s) => Some(s.as_str()),
19192                _ => None,
19193            })
19194            .collect();
19195        assert_eq!(terms, vec!["Hello", "世界", "мир"]);
19196    }
19197
19198    #[test]
19199    fn unicode_mixed_scripts_with_emoji() {
19200        // Emoji stripped, scripts preserved
19201        let sanitized = sanitize_query("Hello 🌍 世界");
19202        assert_eq!(sanitized, "Hello   世界");
19203    }
19204
19205    #[test]
19206    fn unicode_latin_cyrillic_arabic_query() {
19207        let terms = QueryTermsLower::from_query("Hello Мир مرحبا");
19208        let tokens: Vec<&str> = terms.tokens().collect();
19209        assert_eq!(tokens, vec!["hello", "мир", "مرحبا"]);
19210    }
19211
19212    // --- Zero-width characters ---
19213
19214    #[test]
19215    fn unicode_zero_width_joiner_removed() {
19216        // Zero-width joiner (U+200D) is not alphanumeric → becomes space
19217        let sanitized = sanitize_query("test\u{200D}query");
19218        assert_eq!(sanitized, "test query");
19219    }
19220
19221    #[test]
19222    fn unicode_zero_width_non_joiner_removed() {
19223        // Zero-width non-joiner (U+200C) is not alphanumeric → becomes space
19224        let sanitized = sanitize_query("test\u{200C}query");
19225        assert_eq!(sanitized, "test query");
19226    }
19227
19228    #[test]
19229    fn unicode_zero_width_space_removed() {
19230        // Zero-width space (U+200B) is not alphanumeric → becomes space
19231        let sanitized = sanitize_query("test\u{200B}query");
19232        assert_eq!(sanitized, "test query");
19233    }
19234
19235    #[test]
19236    fn unicode_bom_removed() {
19237        // Byte-order mark (U+FEFF) should not appear in search terms
19238        let sanitized = sanitize_query("\u{FEFF}test");
19239        assert_eq!(sanitized, " test");
19240    }
19241
19242    // --- Combining characters ---
19243
19244    #[test]
19245    fn unicode_precomposed_accent_preserved() {
19246        // Precomposed é (U+00E9) is a single letter → alphanumeric
19247        let sanitized = sanitize_query("café");
19248        assert_eq!(sanitized, "café");
19249    }
19250
19251    #[test]
19252    fn unicode_combining_accent_becomes_separator() {
19253        // Decomposed: 'e' + combining acute accent (U+0301)
19254        // nfc_sanitize_query first normalizes to NFC, composing e + U+0301
19255        // into precomposed é (U+00E9), which is alphanumeric and preserved.
19256        let input = "cafe\u{0301}";
19257        let sanitized = sanitize_query(input);
19258        assert_eq!(sanitized, "caf\u{00e9}");
19259    }
19260
19261    #[test]
19262    fn unicode_nfc_and_nfd_produce_same_sanitized_query() {
19263        // NFC (precomposed): é = U+00E9 (single char, alphanumeric)
19264        let nfc = "caf\u{00E9}";
19265        // NFD (decomposed): e + ◌́ = U+0065 U+0301 (two chars, accent not alphanumeric)
19266        let nfd = "cafe\u{0301}";
19267
19268        let san_nfc = sanitize_query(nfc);
19269        let san_nfd = sanitize_query(nfd);
19270
19271        // Both produce "café" because nfc_sanitize_query normalizes to NFC
19272        // before sanitization, matching the NFC-indexed content from
19273        // DefaultCanonicalizer.
19274        assert_eq!(san_nfc, "café");
19275        assert_eq!(san_nfd, "café");
19276        assert_eq!(san_nfc, san_nfd);
19277    }
19278
19279    #[test]
19280    fn unicode_combining_marks_do_not_panic() {
19281        // Multiple combining marks stacked (e.g., Zalgo text)
19282        let zalgo = "t\u{0301}\u{0302}\u{0303}e\u{0304}\u{0305}st";
19283        let sanitized = sanitize_query(zalgo);
19284        // Should not panic; combining marks become spaces
19285        assert!(sanitized.contains('t'));
19286        assert!(sanitized.contains('s'));
19287    }
19288
19289    // --- Supplementary plane characters (outside BMP) ---
19290
19291    #[test]
19292    fn unicode_mathematical_bold_letters_preserved() {
19293        // Mathematical Bold Capital A (U+1D400) — classified as Letter
19294        let input = "\u{1D400}\u{1D401}\u{1D402}";
19295        let sanitized = sanitize_query(input);
19296        assert_eq!(
19297            sanitized, input,
19298            "Mathematical bold letters are alphanumeric"
19299        );
19300    }
19301
19302    #[test]
19303    fn unicode_supplementary_ideograph_preserved() {
19304        // CJK Unified Ideographs Extension B character (U+20000)
19305        let input = "\u{20000}";
19306        let sanitized = sanitize_query(input);
19307        assert_eq!(
19308            sanitized, input,
19309            "Supplementary CJK ideographs are alphanumeric"
19310        );
19311    }
19312
19313    #[test]
19314    fn unicode_supplementary_emoji_removed() {
19315        // Grinning face (U+1F600) — Symbol, not alphanumeric
19316        let input = "test\u{1F600}query";
19317        let sanitized = sanitize_query(input);
19318        assert_eq!(sanitized, "test query");
19319    }
19320
19321    // --- Bidirectional text ---
19322
19323    #[test]
19324    fn unicode_bidi_mixed_ltr_rtl_no_panic() {
19325        let input = "hello שלום world עולם";
19326        let tokens = parse_boolean_query(input);
19327        let terms: Vec<_> = tokens
19328            .iter()
19329            .filter_map(|t| match t {
19330                QueryToken::Term(s) => Some(s.as_str()),
19331                _ => None,
19332            })
19333            .collect();
19334        assert_eq!(terms.len(), 4);
19335        assert!(terms.contains(&"hello"));
19336        assert!(terms.contains(&"שלום"));
19337        assert!(terms.contains(&"world"));
19338        assert!(terms.contains(&"עולם"));
19339    }
19340
19341    #[test]
19342    fn unicode_bidi_override_chars_removed() {
19343        // Left-to-right override (U+202D) and pop directional (U+202C)
19344        // These are format characters, not alphanumeric
19345        let input = "test\u{202D}content\u{202C}end";
19346        let sanitized = sanitize_query(input);
19347        assert_eq!(sanitized, "test content end");
19348    }
19349
19350    #[test]
19351    fn unicode_bidi_rtl_mark_removed() {
19352        // Right-to-left mark (U+200F) is not alphanumeric
19353        let input = "test\u{200F}content";
19354        let sanitized = sanitize_query(input);
19355        assert_eq!(sanitized, "test content");
19356    }
19357
19358    // --- Full pipeline integration tests ---
19359
19360    #[test]
19361    fn unicode_full_pipeline_cjk_query() {
19362        let explanation = QueryExplanation::analyze("测试 代码", &SearchFilters::default());
19363        assert_eq!(explanation.parsed.terms.len(), 2);
19364        assert!(!explanation.parsed.terms[0].text.is_empty());
19365        assert!(!explanation.parsed.terms[1].text.is_empty());
19366    }
19367
19368    #[test]
19369    fn unicode_full_pipeline_mixed_script_boolean() {
19370        let explanation =
19371            QueryExplanation::analyze("Hello AND 世界 OR مرحبا", &SearchFilters::default());
19372        // Should parse operators correctly even with mixed scripts
19373        assert!(
19374            explanation.parsed.operators.iter().any(|op| op == "AND"),
19375            "AND operator should be recognized in mixed-script query"
19376        );
19377    }
19378
19379    #[test]
19380    fn unicode_full_pipeline_emoji_query_type() {
19381        // An all-emoji query sanitizes to empty — should handle gracefully
19382        let explanation = QueryExplanation::analyze("🚀🔥💻", &SearchFilters::default());
19383        // Should not panic; terms may be empty after sanitization
19384        assert!(
19385            explanation.parsed.terms.is_empty()
19386                || explanation
19387                    .parsed
19388                    .terms
19389                    .iter()
19390                    .all(|t| t.subterms.is_empty()),
19391            "All-emoji query should produce no meaningful terms"
19392        );
19393    }
19394
19395    #[test]
19396    fn unicode_full_pipeline_phrase_with_cjk() {
19397        let explanation = QueryExplanation::analyze("\"测试代码\"", &SearchFilters::default());
19398        assert!(
19399            !explanation.parsed.phrases.is_empty(),
19400            "CJK phrase should be recognized"
19401        );
19402    }
19403
19404    #[test]
19405    fn unicode_full_pipeline_wildcard_with_unicode() {
19406        let explanation = QueryExplanation::analyze("*测试*", &SearchFilters::default());
19407        assert!(
19408            !explanation.parsed.terms.is_empty(),
19409            "Wildcard with CJK should produce terms"
19410        );
19411        // Check that the term has a substring/wildcard pattern
19412        if let Some(term) = explanation.parsed.terms.first() {
19413            assert!(
19414                term.subterms
19415                    .iter()
19416                    .any(|s| s.pattern.contains("*") || s.pattern == "exact"),
19417                "CJK wildcard should produce wildcard or exact pattern"
19418            );
19419        }
19420    }
19421
19422    #[test]
19423    fn unicode_query_terms_lower_case_folding() {
19424        // German sharp s (ß) lowercases to ß (not ss in Rust)
19425        let terms = QueryTermsLower::from_query("STRAßE");
19426        assert_eq!(terms.query_lower, "straße");
19427
19428        // Turkish dotless I (İ → i with dot below in some locales, but
19429        // Rust uses simple Unicode case mapping)
19430        let terms2 = QueryTermsLower::from_query("HELLO");
19431        assert_eq!(terms2.query_lower, "hello");
19432    }
19433
19434    #[test]
19435    fn unicode_normalize_term_parts_cjk() {
19436        let parts = normalize_term_parts("测试 代码");
19437        assert_eq!(parts, vec!["测试", "代码"]);
19438    }
19439
19440    #[test]
19441    fn unicode_normalize_term_parts_strips_emoji() {
19442        let parts = normalize_term_parts("🚀launch🔥code");
19443        // Emoji replaced with space, splitting into two terms
19444        assert!(parts.contains(&"launch".to_string()));
19445        assert!(parts.contains(&"code".to_string()));
19446    }
19447
19448    // ── Special character query tests (br-g650) ────────────────────────────
19449
19450    // Category 1: Unbalanced quotes
19451
19452    #[test]
19453    fn special_char_unbalanced_quote_no_panic() {
19454        let tokens = parse_boolean_query("\"hello world");
19455        assert!(
19456            tokens
19457                .iter()
19458                .any(|t| matches!(t, QueryToken::Phrase(p) if p.contains("hello"))),
19459            "Unbalanced quote should still produce a phrase: {tokens:?}"
19460        );
19461    }
19462
19463    #[test]
19464    fn special_char_unbalanced_trailing_quote() {
19465        let tokens = parse_boolean_query("test\"");
19466        assert!(
19467            tokens
19468                .iter()
19469                .any(|t| matches!(t, QueryToken::Term(w) if w == "test")),
19470            "Text before trailing quote should parse as term: {tokens:?}"
19471        );
19472    }
19473
19474    #[test]
19475    fn special_char_multiple_unbalanced_quotes() {
19476        let tokens = parse_boolean_query("\"foo \"bar");
19477        assert!(
19478            !tokens.is_empty(),
19479            "Should parse despite odd quotes: {tokens:?}"
19480        );
19481    }
19482
19483    #[test]
19484    fn special_char_empty_quotes() {
19485        let tokens = parse_boolean_query("\"\" test");
19486        assert!(
19487            tokens
19488                .iter()
19489                .any(|t| matches!(t, QueryToken::Term(w) if w == "test")),
19490            "Empty quotes should be skipped: {tokens:?}"
19491        );
19492    }
19493
19494    #[test]
19495    fn special_char_unbalanced_via_sanitize() {
19496        let sanitized = sanitize_query("\"hello world");
19497        assert!(
19498            sanitized.contains('"'),
19499            "Quotes preserved by sanitize_query"
19500        );
19501    }
19502
19503    // Category 2: Escaped quotes
19504
19505    #[test]
19506    fn special_char_backslash_quote_sanitize() {
19507        let sanitized = sanitize_query("\\\"test\\\"");
19508        assert!(sanitized.contains('"'));
19509        assert!(!sanitized.contains('\\'), "Backslash should be stripped");
19510    }
19511
19512    #[test]
19513    fn special_char_backslash_quote_parse() {
19514        let tokens = parse_boolean_query("\\\"test\\\"");
19515        assert!(!tokens.is_empty(), "Should parse without panic: {tokens:?}");
19516    }
19517
19518    #[test]
19519    fn special_char_inner_escaped_quotes() {
19520        let tokens = parse_boolean_query("\"test \\\"inner\\\" test\"");
19521        assert!(
19522            !tokens.is_empty(),
19523            "Nested escaped quotes should not panic: {tokens:?}"
19524        );
19525    }
19526
19527    // Category 3: Backslash sequences
19528
19529    #[test]
19530    fn special_char_windows_path_sanitize() {
19531        let sanitized = sanitize_query("C:\\Users\\test");
19532        assert_eq!(sanitized, "C  Users test");
19533    }
19534
19535    #[test]
19536    fn special_char_unc_path_sanitize() {
19537        let sanitized = sanitize_query("\\\\server\\share");
19538        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19539        assert!(parts.contains(&"server"));
19540        assert!(parts.contains(&"share"));
19541    }
19542
19543    #[test]
19544    fn special_char_windows_path_terms() {
19545        let parts = normalize_term_parts("C:\\Users\\test\\file.rs");
19546        assert!(parts.contains(&"C".to_string()));
19547        assert!(parts.contains(&"Users".to_string()));
19548        assert!(parts.contains(&"test".to_string()));
19549        assert!(parts.contains(&"file".to_string()));
19550        assert!(parts.contains(&"rs".to_string()));
19551    }
19552
19553    // Category 4: Regex metacharacters
19554
19555    #[test]
19556    fn special_char_regex_dot_star() {
19557        let sanitized = sanitize_query("foo.*bar");
19558        assert_eq!(sanitized, "foo *bar");
19559    }
19560
19561    #[test]
19562    fn special_char_regex_char_class() {
19563        let sanitized = sanitize_query("[a-z]+");
19564        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19565        assert_eq!(parts, vec!["a-z"]);
19566        assert_eq!(normalize_term_parts("[a-z]+"), vec!["a", "z"]);
19567    }
19568
19569    #[test]
19570    fn special_char_regex_anchors() {
19571        let sanitized = sanitize_query("^start$");
19572        assert_eq!(sanitized.trim(), "start");
19573    }
19574
19575    #[test]
19576    fn special_char_regex_pipe_groups() {
19577        let sanitized = sanitize_query("(foo|bar)");
19578        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19579        assert_eq!(parts, vec!["foo", "bar"]);
19580    }
19581
19582    // Category 5: SQL injection patterns
19583
19584    #[test]
19585    fn special_char_sql_injection_or() {
19586        let sanitized = sanitize_query("'OR 1=1--");
19587        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19588        assert!(parts.contains(&"OR"));
19589        assert!(parts.contains(&"1"));
19590        assert!(!sanitized.contains('\''));
19591        assert!(!sanitized.contains('='));
19592    }
19593
19594    #[test]
19595    fn special_char_sql_injection_drop() {
19596        let sanitized = sanitize_query("; DROP TABLE users;--");
19597        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19598        assert!(parts.contains(&"DROP"));
19599        assert!(parts.contains(&"TABLE"));
19600        assert!(parts.contains(&"users"));
19601        assert!(!sanitized.contains(';'));
19602    }
19603
19604    #[test]
19605    fn special_char_sql_injection_union() {
19606        let sanitized = sanitize_query("' UNION SELECT * FROM passwords --");
19607        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19608        assert!(parts.contains(&"UNION"));
19609        assert!(parts.contains(&"SELECT"));
19610        assert!(parts.contains(&"*"));
19611        assert!(parts.contains(&"FROM"));
19612        assert!(parts.contains(&"passwords"));
19613    }
19614
19615    #[test]
19616    fn special_char_sql_parse_as_literal() {
19617        let tokens = parse_boolean_query("OR 1=1");
19618        assert!(
19619            tokens.iter().any(|t| matches!(t, QueryToken::Or)),
19620            "OR should be parsed as Or operator: {tokens:?}"
19621        );
19622    }
19623
19624    // Category 6: Shell injection patterns
19625
19626    #[test]
19627    fn special_char_shell_subshell() {
19628        let sanitized = sanitize_query("$(cmd)");
19629        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19630        assert_eq!(parts, vec!["cmd"]);
19631    }
19632
19633    #[test]
19634    fn special_char_shell_backticks() {
19635        let sanitized = sanitize_query("`cmd`");
19636        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19637        assert_eq!(parts, vec!["cmd"]);
19638    }
19639
19640    #[test]
19641    fn special_char_shell_pipe_rm() {
19642        let sanitized = sanitize_query("| rm -rf /");
19643        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19644        assert!(parts.contains(&"rm"));
19645        assert!(parts.contains(&"-rf"));
19646        assert_eq!(normalize_term_parts("| rm -rf /"), vec!["rm", "rf"]);
19647        assert!(!sanitized.contains('|'));
19648        assert!(!sanitized.contains('/'));
19649    }
19650
19651    #[test]
19652    fn special_char_shell_semicolon_chain() {
19653        let sanitized = sanitize_query("test; echo pwned; cat /etc/passwd");
19654        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19655        assert!(parts.contains(&"test"));
19656        assert!(parts.contains(&"echo"));
19657        assert!(parts.contains(&"pwned"));
19658        assert!(!sanitized.contains(';'));
19659    }
19660
19661    // Category 7: Null bytes
19662
19663    #[test]
19664    fn special_char_null_byte_mid_string() {
19665        let sanitized = sanitize_query("test\x00hidden");
19666        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19667        assert_eq!(parts, vec!["test", "hidden"]);
19668    }
19669
19670    #[test]
19671    fn special_char_null_byte_leading() {
19672        let sanitized = sanitize_query("\x00\x00attack");
19673        assert_eq!(sanitized.trim(), "attack");
19674    }
19675
19676    #[test]
19677    fn special_char_null_byte_trailing() {
19678        let sanitized = sanitize_query("query\x00\x00\x00");
19679        assert_eq!(sanitized.trim(), "query");
19680    }
19681
19682    #[test]
19683    fn special_char_null_byte_parse() {
19684        let tokens = parse_boolean_query("test\x00hidden");
19685        assert!(
19686            !tokens.is_empty(),
19687            "Null bytes should not prevent parsing: {tokens:?}"
19688        );
19689    }
19690
19691    // Category 8: Control characters
19692
19693    #[test]
19694    fn special_char_control_newline() {
19695        let sanitized = sanitize_query("line1\nline2");
19696        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19697        assert_eq!(parts, vec!["line1", "line2"]);
19698    }
19699
19700    #[test]
19701    fn special_char_control_tab_cr() {
19702        let sanitized = sanitize_query("tab\there\r\nend");
19703        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19704        assert_eq!(parts, vec!["tab", "here", "end"]);
19705    }
19706
19707    #[test]
19708    fn special_char_control_parse_whitespace() {
19709        let tokens = parse_boolean_query("hello\tworld\ntest");
19710        let terms: Vec<&str> = tokens
19711            .iter()
19712            .filter_map(|t| match t {
19713                QueryToken::Term(s) => Some(s.as_str()),
19714                _ => None,
19715            })
19716            .collect();
19717        assert_eq!(terms, vec!["hello", "world", "test"]);
19718    }
19719
19720    #[test]
19721    fn special_char_control_bell_escape() {
19722        let sanitized = sanitize_query("test\x07\x1b[31mred");
19723        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19724        assert!(parts.contains(&"test"));
19725        assert!(parts.contains(&"31mred"));
19726    }
19727
19728    // Category 9: HTML/XML entities
19729
19730    #[test]
19731    fn special_char_html_entity_lt() {
19732        let sanitized = sanitize_query("&lt;script&gt;");
19733        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19734        assert_eq!(parts, vec!["lt", "script", "gt"]);
19735    }
19736
19737    #[test]
19738    fn special_char_html_numeric_entity() {
19739        let sanitized = sanitize_query("&#x3C;script&#x3E;");
19740        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19741        assert!(parts.contains(&"x3C"));
19742        assert!(parts.contains(&"script"));
19743        assert!(parts.contains(&"x3E"));
19744    }
19745
19746    #[test]
19747    fn special_char_html_tags_stripped() {
19748        let sanitized = sanitize_query("<script>alert('xss')</script>");
19749        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19750        assert!(parts.contains(&"script"));
19751        assert!(parts.contains(&"alert"));
19752        assert!(parts.contains(&"xss"));
19753    }
19754
19755    #[test]
19756    fn special_char_html_attribute() {
19757        let sanitized = sanitize_query("<img src=\"evil.js\" onerror=\"alert(1)\">");
19758        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19759        assert!(parts.contains(&"img"));
19760        assert!(parts.contains(&"src"));
19761        assert!(parts.contains(&"onerror"));
19762    }
19763
19764    // Category 10: URL encoding
19765
19766    #[test]
19767    fn special_char_url_percent_encoding() {
19768        let sanitized = sanitize_query("%20space%2Fslash");
19769        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19770        assert_eq!(parts, vec!["20space", "2Fslash"]);
19771    }
19772
19773    #[test]
19774    fn special_char_url_null_byte_encoded() {
19775        let sanitized = sanitize_query("test%00hidden");
19776        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19777        assert_eq!(parts, vec!["test", "00hidden"]);
19778    }
19779
19780    #[test]
19781    fn special_char_url_full_query_string() {
19782        let sanitized = sanitize_query("search?q=hello&lang=en");
19783        let parts: Vec<&str> = sanitized.split_whitespace().collect();
19784        assert_eq!(parts, vec!["search", "q", "hello", "lang", "en"]);
19785    }
19786
19787    // Cross-cutting: full pipeline integration
19788
19789    #[test]
19790    fn special_char_explain_sql_injection() {
19791        let filters = SearchFilters::default();
19792        let explanation = QueryExplanation::analyze("'OR 1=1--", &filters);
19793        assert!(
19794            !explanation.parsed.terms.is_empty() || !explanation.parsed.phrases.is_empty(),
19795            "SQL injection should produce parseable terms"
19796        );
19797    }
19798
19799    #[test]
19800    fn special_char_explain_shell_injection() {
19801        let filters = SearchFilters::default();
19802        let explanation = QueryExplanation::analyze("$(rm -rf /)", &filters);
19803        assert!(
19804            !explanation.parsed.terms.is_empty(),
19805            "Shell injection should produce parseable terms"
19806        );
19807    }
19808
19809    #[test]
19810    fn special_char_explain_html_xss() {
19811        let filters = SearchFilters::default();
19812        let explanation = QueryExplanation::analyze("<script>alert('xss')</script>", &filters);
19813        assert!(
19814            !explanation.parsed.terms.is_empty(),
19815            "XSS payload should produce parseable terms"
19816        );
19817    }
19818
19819    #[test]
19820    fn special_char_terms_lower_injection() {
19821        let qt = QueryTermsLower::from_query("'; DROP TABLE--");
19822        let tokens: Vec<&str> = qt.tokens().collect();
19823        for token in &tokens {
19824            assert!(
19825                token.chars().all(|c| c.is_alphanumeric()),
19826                "Token should only contain alphanumeric characters: {token}"
19827            );
19828        }
19829    }
19830
19831    #[test]
19832    fn special_char_terms_lower_null_bytes() {
19833        let qt = QueryTermsLower::from_query("test\x00hidden");
19834        let tokens: Vec<&str> = qt.tokens().collect();
19835        assert!(tokens.contains(&"test"));
19836        assert!(tokens.contains(&"hidden"));
19837    }
19838
19839    #[test]
19840    fn special_char_boolean_with_injection() {
19841        let tokens = parse_boolean_query("search AND 'OR 1=1-- NOT drop");
19842        assert!(
19843            tokens.iter().any(|t| matches!(t, QueryToken::And)),
19844            "Boolean AND should still be recognized: {tokens:?}"
19845        );
19846        assert!(
19847            tokens.iter().any(|t| matches!(t, QueryToken::Not)),
19848            "Boolean NOT should still be recognized: {tokens:?}"
19849        );
19850    }
19851
19852    // ==========================================================================
19853    // Query Length Stress Tests (coding_agent_session_search-z1bk)
19854    // Tests for extreme input sizes to ensure parser robustness.
19855    // ==========================================================================
19856
19857    #[test]
19858    fn stress_query_100k_chars_completes_quickly() {
19859        // 100k character query - must complete in <1 second
19860        let long_query = "a ".repeat(50000);
19861        assert_eq!(long_query.len(), 100000);
19862
19863        let start = std::time::Instant::now();
19864        let sanitized = sanitize_query(&long_query);
19865        let elapsed_sanitize = start.elapsed();
19866
19867        let start = std::time::Instant::now();
19868        let tokens = parse_boolean_query(&sanitized);
19869        let elapsed_parse = start.elapsed();
19870
19871        assert!(
19872            elapsed_sanitize < std::time::Duration::from_secs(1),
19873            "sanitize_query with 100k chars took {:?} (>1s)",
19874            elapsed_sanitize
19875        );
19876        assert!(
19877            elapsed_parse < std::time::Duration::from_secs(1),
19878            "parse_boolean_query with 100k chars took {:?} (>1s)",
19879            elapsed_parse
19880        );
19881        assert!(!tokens.is_empty(), "100k char query should produce tokens");
19882    }
19883
19884    #[test]
19885    fn stress_query_1000_terms() {
19886        // 1000 space-separated words
19887        let words: Vec<String> = (0..1000).map(|i| format!("word{}", i)).collect();
19888        let query = words.join(" ");
19889
19890        let start = std::time::Instant::now();
19891        let sanitized = sanitize_query(&query);
19892        let tokens = parse_boolean_query(&sanitized);
19893        let elapsed = start.elapsed();
19894
19895        assert!(
19896            elapsed < std::time::Duration::from_secs(1),
19897            "1000 terms query took {:?} (>1s)",
19898            elapsed
19899        );
19900        // Should have roughly 1000 Term tokens
19901        let term_count = tokens
19902            .iter()
19903            .filter(|t| matches!(t, QueryToken::Term(_)))
19904            .count();
19905        assert!(
19906            term_count >= 900,
19907            "Expected ~1000 terms, got {} terms",
19908            term_count
19909        );
19910    }
19911
19912    #[test]
19913    fn stress_query_1000_identical_terms() {
19914        // Same word repeated 1000 times
19915        let query = "test ".repeat(1000);
19916
19917        let start = std::time::Instant::now();
19918        let sanitized = sanitize_query(&query);
19919        let tokens = parse_boolean_query(&sanitized);
19920        let elapsed = start.elapsed();
19921
19922        assert!(
19923            elapsed < std::time::Duration::from_secs(1),
19924            "1000 identical terms query took {:?} (>1s)",
19925            elapsed
19926        );
19927
19928        // Verify parse_boolean_query produced expected tokens
19929        let parsed_term_count = tokens
19930            .iter()
19931            .filter(|t| matches!(t, QueryToken::Term(_)))
19932            .count();
19933        assert_eq!(parsed_term_count, 1000, "Parser should produce 1000 terms");
19934
19935        // QueryTermsLower should handle this efficiently
19936        let qt = QueryTermsLower::from_query(&query);
19937        let tokens_lower: Vec<&str> = qt.tokens().collect();
19938        assert_eq!(
19939            tokens_lower.len(),
19940            1000,
19941            "All 1000 identical terms should be preserved"
19942        );
19943        assert!(
19944            tokens_lower.iter().all(|t| *t == "test"),
19945            "All tokens should be 'test'"
19946        );
19947    }
19948
19949    #[test]
19950    fn stress_query_10k_char_single_term() {
19951        // 10k character single continuous string (no spaces)
19952        let long_term = "a".repeat(10000);
19953
19954        let start = std::time::Instant::now();
19955        let sanitized = sanitize_query(&long_term);
19956        let tokens = parse_boolean_query(&sanitized);
19957        let elapsed = start.elapsed();
19958
19959        assert!(
19960            elapsed < std::time::Duration::from_secs(1),
19961            "10k char single term took {:?} (>1s)",
19962            elapsed
19963        );
19964        assert_eq!(tokens.len(), 1, "Should produce exactly one token");
19965        assert!(
19966            matches!(&tokens[0], QueryToken::Term(t) if t.len() == 10000),
19967            "Expected Term token"
19968        );
19969    }
19970
19971    #[test]
19972    fn stress_deeply_nested_parentheses() {
19973        // 100+ levels of nested parentheses (though parser doesn't use them,
19974        // they become spaces and shouldn't cause issues)
19975        let open_parens = "(".repeat(100);
19976        let close_parens = ")".repeat(100);
19977        let query = format!("{}test{}", open_parens, close_parens);
19978
19979        let start = std::time::Instant::now();
19980        let sanitized = sanitize_query(&query);
19981        let tokens = parse_boolean_query(&sanitized);
19982        let elapsed = start.elapsed();
19983
19984        assert!(
19985            elapsed < std::time::Duration::from_millis(100),
19986            "Deeply nested parens took {:?} (>100ms)",
19987            elapsed
19988        );
19989        // Parentheses become spaces, leaving just "test"
19990        let term_count = tokens
19991            .iter()
19992            .filter(|t| matches!(t, QueryToken::Term(_)))
19993            .count();
19994        assert_eq!(term_count, 1, "Should have 1 term after sanitizing parens");
19995    }
19996
19997    #[test]
19998    fn stress_many_boolean_operators() {
19999        // 100+ boolean operators: "a AND b AND c AND ..."
20000        let terms: Vec<String> = (0..101).map(|i| format!("term{}", i)).collect();
20001        let query = terms.join(" AND ");
20002
20003        let start = std::time::Instant::now();
20004        let tokens = parse_boolean_query(&query);
20005        let elapsed = start.elapsed();
20006
20007        assert!(
20008            elapsed < std::time::Duration::from_secs(1),
20009            "100+ boolean ops took {:?} (>1s)",
20010            elapsed
20011        );
20012
20013        let and_count = tokens
20014            .iter()
20015            .filter(|t| matches!(t, QueryToken::And))
20016            .count();
20017        let term_count = tokens
20018            .iter()
20019            .filter(|t| matches!(t, QueryToken::Term(_)))
20020            .count();
20021
20022        assert_eq!(and_count, 100, "Should have 100 AND operators");
20023        assert_eq!(term_count, 101, "Should have 101 terms");
20024    }
20025
20026    #[test]
20027    fn stress_many_or_operators() {
20028        // 100+ OR operators: "a OR b OR c OR ..."
20029        let terms: Vec<String> = (0..101).map(|i| format!("opt{}", i)).collect();
20030        let query = terms.join(" OR ");
20031
20032        let start = std::time::Instant::now();
20033        let tokens = parse_boolean_query(&query);
20034        let elapsed = start.elapsed();
20035
20036        assert!(
20037            elapsed < std::time::Duration::from_secs(1),
20038            "100+ OR ops took {:?} (>1s)",
20039            elapsed
20040        );
20041
20042        let or_count = tokens
20043            .iter()
20044            .filter(|t| matches!(t, QueryToken::Or))
20045            .count();
20046        assert_eq!(or_count, 100, "Should have 100 OR operators");
20047    }
20048
20049    #[test]
20050    fn stress_mixed_boolean_operators() {
20051        // Complex query with many mixed operators
20052        let query = "a AND b OR c NOT d AND e OR f NOT g ".repeat(50);
20053
20054        let start = std::time::Instant::now();
20055        let tokens = parse_boolean_query(&query);
20056        let elapsed = start.elapsed();
20057
20058        assert!(
20059            elapsed < std::time::Duration::from_secs(1),
20060            "Mixed boolean ops took {:?} (>1s)",
20061            elapsed
20062        );
20063        assert!(
20064            !tokens.is_empty(),
20065            "Complex boolean query should produce tokens"
20066        );
20067    }
20068
20069    #[test]
20070    fn stress_memory_bounds_large_query() {
20071        // Verify no excessive memory allocation with large input
20072        // We can't easily measure memory in a unit test, but we can verify
20073        // the output size is reasonable relative to input.
20074        let large_query = "x".repeat(100000);
20075
20076        let sanitized = sanitize_query(&large_query);
20077        let tokens = parse_boolean_query(&sanitized);
20078
20079        // Sanitized output shouldn't be larger than input
20080        assert!(
20081            sanitized.len() <= large_query.len(),
20082            "Sanitized output should not exceed input size"
20083        );
20084
20085        // Should produce exactly 1 token
20086        assert_eq!(tokens.len(), 1);
20087
20088        // QueryTermsLower internal storage should be bounded
20089        let qt = QueryTermsLower::from_query(&large_query);
20090        let token_count = qt.tokens().count();
20091        assert_eq!(token_count, 1, "Should be 1 token of 100k chars");
20092    }
20093
20094    #[test]
20095    fn stress_concurrent_queries() {
20096        use std::thread;
20097
20098        let queries: Vec<String> = (0..100)
20099            .map(|i| format!("concurrent_query_{} test search", i))
20100            .collect();
20101
20102        let handles: Vec<_> = queries
20103            .into_iter()
20104            .map(|query| {
20105                thread::spawn(move || {
20106                    let sanitized = sanitize_query(&query);
20107                    let tokens = parse_boolean_query(&sanitized);
20108                    let qt = QueryTermsLower::from_query(&query);
20109                    (tokens.len(), qt.tokens().count())
20110                })
20111            })
20112            .collect();
20113
20114        for (i, handle) in handles.into_iter().enumerate() {
20115            let (token_len, qt_len) = handle.join().expect("Thread panicked");
20116            assert!(token_len > 0, "Query {} should produce tokens", i);
20117            assert!(qt_len > 0, "Query {} QueryTermsLower should have tokens", i);
20118        }
20119    }
20120
20121    #[test]
20122    fn stress_many_quoted_phrases() {
20123        // 50 quoted phrases
20124        let phrases: Vec<String> = (0..50)
20125            .map(|i| format!("\"phrase number {}\"", i))
20126            .collect();
20127        let query = phrases.join(" AND ");
20128
20129        let start = std::time::Instant::now();
20130        let tokens = parse_boolean_query(&query);
20131        let elapsed = start.elapsed();
20132
20133        assert!(
20134            elapsed < std::time::Duration::from_secs(1),
20135            "50 quoted phrases took {:?} (>1s)",
20136            elapsed
20137        );
20138
20139        let phrase_count = tokens
20140            .iter()
20141            .filter(|t| matches!(t, QueryToken::Phrase(_)))
20142            .count();
20143        assert_eq!(phrase_count, 50, "Should have 50 phrases");
20144    }
20145
20146    #[test]
20147    fn stress_alternating_quotes() {
20148        // Alternating quoted and unquoted: "a" b "c" d "e" ...
20149        let parts: Vec<String> = (0..100)
20150            .map(|i| {
20151                if i % 2 == 0 {
20152                    format!("\"word{}\"", i)
20153                } else {
20154                    format!("word{}", i)
20155                }
20156            })
20157            .collect();
20158        let query = parts.join(" ");
20159
20160        let start = std::time::Instant::now();
20161        let tokens = parse_boolean_query(&query);
20162        let elapsed = start.elapsed();
20163
20164        assert!(
20165            elapsed < std::time::Duration::from_secs(1),
20166            "100 alternating quotes took {:?} (>1s)",
20167            elapsed
20168        );
20169
20170        let phrase_count = tokens
20171            .iter()
20172            .filter(|t| matches!(t, QueryToken::Phrase(_)))
20173            .count();
20174        let term_count = tokens
20175            .iter()
20176            .filter(|t| matches!(t, QueryToken::Term(_)))
20177            .count();
20178
20179        assert_eq!(phrase_count, 50, "Should have 50 phrases");
20180        assert_eq!(term_count, 50, "Should have 50 terms");
20181    }
20182
20183    #[test]
20184    fn stress_many_wildcards() {
20185        // Many wildcard patterns
20186        let patterns: Vec<&str> = vec!["pre*", "*suf", "*sub*", "a*b", "test*", "*ing", "*tion*"];
20187        let query = patterns
20188            .iter()
20189            .cycle()
20190            .take(100)
20191            .cloned()
20192            .collect::<Vec<_>>()
20193            .join(" ");
20194
20195        let start = std::time::Instant::now();
20196        let sanitized = sanitize_query(&query);
20197        let tokens = parse_boolean_query(&sanitized);
20198        let elapsed = start.elapsed();
20199
20200        assert!(
20201            elapsed < std::time::Duration::from_secs(1),
20202            "100 wildcards took {:?} (>1s)",
20203            elapsed
20204        );
20205        assert!(!tokens.is_empty());
20206    }
20207
20208    #[test]
20209    fn stress_query_explanation_large_query() {
20210        // Test QueryExplanation with a large query
20211        let words: Vec<String> = (0..100).map(|i| format!("term{}", i)).collect();
20212        let query = words.join(" ");
20213        let filters = SearchFilters::default();
20214
20215        let start = std::time::Instant::now();
20216        let explanation = QueryExplanation::analyze(&query, &filters);
20217        let elapsed = start.elapsed();
20218
20219        assert!(
20220            elapsed < std::time::Duration::from_secs(2),
20221            "QueryExplanation for 100 terms took {:?} (>2s)",
20222            elapsed
20223        );
20224        assert!(
20225            !explanation.parsed.terms.is_empty(),
20226            "Should parse terms successfully"
20227        );
20228    }
20229
20230    #[test]
20231    fn stress_very_long_single_quoted_phrase() {
20232        // Single quoted phrase with many words
20233        let words: Vec<String> = (0..500).map(|i| format!("word{}", i)).collect();
20234        let phrase = format!("\"{}\"", words.join(" "));
20235
20236        let start = std::time::Instant::now();
20237        let tokens = parse_boolean_query(&phrase);
20238        let elapsed = start.elapsed();
20239
20240        assert!(
20241            elapsed < std::time::Duration::from_secs(1),
20242            "500-word phrase took {:?} (>1s)",
20243            elapsed
20244        );
20245
20246        let phrase_count = tokens
20247            .iter()
20248            .filter(|t| matches!(t, QueryToken::Phrase(_)))
20249            .count();
20250        assert_eq!(phrase_count, 1, "Should have exactly 1 phrase");
20251    }
20252
20253    #[test]
20254    fn stress_not_prefix_many() {
20255        // Many NOT prefixes: -a -b -c -d ...
20256        let terms: Vec<String> = (0..100).map(|i| format!("-term{}", i)).collect();
20257        let query = terms.join(" ");
20258
20259        let start = std::time::Instant::now();
20260        let tokens = parse_boolean_query(&query);
20261        let elapsed = start.elapsed();
20262
20263        assert!(
20264            elapsed < std::time::Duration::from_secs(1),
20265            "100 NOT prefixes took {:?} (>1s)",
20266            elapsed
20267        );
20268
20269        let not_count = tokens
20270            .iter()
20271            .filter(|t| matches!(t, QueryToken::Not))
20272            .count();
20273        assert_eq!(not_count, 100, "Should have 100 NOT operators");
20274    }
20275
20276    #[test]
20277    fn stress_unicode_large_cjk_query() {
20278        // Large CJK query (each char is alphanumeric)
20279        let cjk_chars = "中文日本語한국어".repeat(1000);
20280
20281        let start = std::time::Instant::now();
20282        let sanitized = sanitize_query(&cjk_chars);
20283        let qt = QueryTermsLower::from_query(&sanitized);
20284        let elapsed = start.elapsed();
20285
20286        assert!(
20287            elapsed < std::time::Duration::from_secs(1),
20288            "Large CJK query took {:?} (>1s)",
20289            elapsed
20290        );
20291        assert!(!qt.is_empty(), "CJK query should produce tokens");
20292    }
20293
20294    #[test]
20295    fn stress_unicode_many_emoji() {
20296        // Query with many emoji (non-alphanumeric, become spaces)
20297        let emoji_query = "🚀 🔍 📝 💻 🎯 ".repeat(500);
20298
20299        let start = std::time::Instant::now();
20300        let sanitized = sanitize_query(&emoji_query);
20301        let tokens = parse_boolean_query(&sanitized);
20302        let elapsed = start.elapsed();
20303
20304        assert!(
20305            elapsed < std::time::Duration::from_secs(1),
20306            "Emoji query took {:?} (>1s)",
20307            elapsed
20308        );
20309        // Emoji are stripped, leaving empty
20310        assert!(
20311            tokens.is_empty(),
20312            "Emoji-only query should produce no tokens"
20313        );
20314    }
20315
20316    #[test]
20317    fn stress_mixed_content_large() {
20318        // Mixed content: code, prose, symbols, unicode
20319        let mixed = r#"
20320            function test() { return x + y; }
20321            SELECT * FROM users WHERE id = 1;
20322            The quick brown fox 狐狸 jumps over lazy dog
20323            Error: "undefined is not a function" at line 42
20324            https://example.com/path?query=value&other=123
20325        "#
20326        .repeat(100);
20327
20328        let start = std::time::Instant::now();
20329        let sanitized = sanitize_query(&mixed);
20330        let tokens = parse_boolean_query(&sanitized);
20331        let qt = QueryTermsLower::from_query(&mixed);
20332        let elapsed = start.elapsed();
20333
20334        assert!(
20335            elapsed < std::time::Duration::from_secs(2),
20336            "Mixed content query took {:?} (>2s)",
20337            elapsed
20338        );
20339        assert!(!tokens.is_empty());
20340        assert!(!qt.is_empty());
20341    }
20342
20343    // ==========================================================================
20344    // Query Parser Unit Tests (br-335y) - Unicode, Special Chars, Edge Cases
20345    // ==========================================================================
20346
20347    // --- Unicode queries with emoji in terms ---
20348
20349    #[test]
20350    fn unicode_emoji_mixed_with_alphanumeric() {
20351        // Emoji surrounded by alphanumeric text
20352        let tokens = parse_boolean_query("rocket🚀launch");
20353        assert_eq!(tokens.len(), 1);
20354        // sanitize_query strips emoji (non-alphanumeric), so this becomes "rocket launch"
20355        let sanitized = sanitize_query("rocket🚀launch");
20356        assert_eq!(sanitized, "rocket launch");
20357
20358        // Multiple emoji between words
20359        let sanitized2 = sanitize_query("test🔥🎯code");
20360        assert_eq!(sanitized2, "test  code");
20361    }
20362
20363    #[test]
20364    fn unicode_emoji_with_boolean_operators() {
20365        // AND/OR/NOT with queries containing emoji
20366        let tokens = parse_boolean_query("🚀code AND test");
20367        // After parsing, we should have 3 tokens (emoji becomes space/empty)
20368        let term_count = tokens
20369            .iter()
20370            .filter(|t| matches!(t, QueryToken::Term(_)))
20371            .count();
20372        assert!(term_count >= 1, "Should have at least one term");
20373
20374        // OR with emoji
20375        let tokens_or = parse_boolean_query("deploy OR 🎯target");
20376        let has_or = tokens_or.iter().any(|t| matches!(t, QueryToken::Or));
20377        assert!(has_or, "Should detect OR operator");
20378    }
20379
20380    #[test]
20381    fn unicode_emoji_at_word_boundaries() {
20382        // Emoji at start of query
20383        let sanitized_start = sanitize_query("🔍search");
20384        assert_eq!(sanitized_start, " search");
20385
20386        // Emoji at end of query
20387        let sanitized_end = sanitize_query("complete✅");
20388        assert_eq!(sanitized_end, "complete ");
20389
20390        // Only emoji - becomes empty
20391        let sanitized_only = sanitize_query("🎉🎊🎁");
20392        assert!(
20393            sanitized_only.trim().is_empty(),
20394            "Emoji-only should be empty after trimming"
20395        );
20396    }
20397
20398    // --- RTL (Right-to-Left) text: Arabic and Hebrew ---
20399
20400    #[test]
20401    fn unicode_arabic_text_preserved() {
20402        // Arabic text should be preserved as alphanumeric
20403        let arabic = "مرحبا بالعالم"; // "Hello World" in Arabic
20404        let sanitized = sanitize_query(arabic);
20405        assert_eq!(
20406            sanitized, arabic,
20407            "Arabic alphanumeric chars should be preserved"
20408        );
20409
20410        let tokens = parse_boolean_query(arabic);
20411        assert!(!tokens.is_empty(), "Arabic query should produce tokens");
20412    }
20413
20414    #[test]
20415    fn unicode_hebrew_text_preserved() {
20416        // Hebrew text should be preserved
20417        let hebrew = "שלום עולם"; // "Hello World" in Hebrew
20418        let sanitized = sanitize_query(hebrew);
20419        assert_eq!(
20420            sanitized, hebrew,
20421            "Hebrew alphanumeric chars should be preserved"
20422        );
20423
20424        let tokens = parse_boolean_query(hebrew);
20425        assert!(!tokens.is_empty(), "Hebrew query should produce tokens");
20426    }
20427
20428    #[test]
20429    fn unicode_mixed_rtl_and_ltr() {
20430        // Mixed RTL (Arabic) and LTR (English) text
20431        let mixed = "hello مرحبا world";
20432        let sanitized = sanitize_query(mixed);
20433        assert_eq!(sanitized, mixed, "Mixed RTL/LTR should be preserved");
20434
20435        let tokens = parse_boolean_query(mixed);
20436        let term_count = tokens
20437            .iter()
20438            .filter(|t| matches!(t, QueryToken::Term(_)))
20439            .count();
20440        assert_eq!(term_count, 3, "Should have 3 terms");
20441    }
20442
20443    #[test]
20444    fn unicode_rtl_with_boolean_operators() {
20445        // Hebrew with AND operator
20446        let hebrew_and = "שלום AND עולם";
20447        let tokens = parse_boolean_query(hebrew_and);
20448        let has_and = tokens.iter().any(|t| matches!(t, QueryToken::And));
20449        assert!(has_and, "Should detect AND operator in Hebrew query");
20450
20451        // Arabic with NOT operator
20452        let arabic_not = "مرحبا NOT بالعالم";
20453        let tokens_not = parse_boolean_query(arabic_not);
20454        let has_not = tokens_not.iter().any(|t| matches!(t, QueryToken::Not));
20455        assert!(has_not, "Should detect NOT operator in Arabic query");
20456    }
20457
20458    // --- Backslash handling ---
20459
20460    #[test]
20461    fn special_chars_backslash_stripped() {
20462        // Backslash is not alphanumeric, so it becomes space
20463        let query = r"path\to\file";
20464        let sanitized = sanitize_query(query);
20465        assert_eq!(sanitized, "path to file");
20466    }
20467
20468    #[test]
20469    fn special_chars_escaped_quotes_handling() {
20470        // Backslash before quote - backslash stripped, quote preserved
20471        let query = r#"say \"hello\""#;
20472        let sanitized = sanitize_query(query);
20473        // Backslash becomes space, quotes preserved
20474        assert!(sanitized.contains('"'), "Quotes should be preserved");
20475    }
20476
20477    #[test]
20478    fn special_chars_windows_paths() {
20479        // Windows-style paths with backslashes
20480        let path = r"C:\Users\test\Documents";
20481        let sanitized = sanitize_query(path);
20482        assert_eq!(sanitized, "C  Users test Documents");
20483    }
20484
20485    // --- Nested/Complex boolean operators ---
20486
20487    #[test]
20488    fn boolean_deeply_nested_operators() {
20489        // Complex nested expression (parser treats this as linear)
20490        let query = "a AND b OR c NOT d AND e";
20491        let tokens = parse_boolean_query(query);
20492
20493        let mut and_count = 0;
20494        let mut or_count = 0;
20495        let mut not_count = 0;
20496        for token in &tokens {
20497            match token {
20498                QueryToken::And => and_count += 1,
20499                QueryToken::Or => or_count += 1,
20500                QueryToken::Not => not_count += 1,
20501                _ => {}
20502            }
20503        }
20504
20505        assert_eq!(and_count, 2, "Should have 2 AND operators");
20506        assert_eq!(or_count, 1, "Should have 1 OR operator");
20507        assert_eq!(not_count, 1, "Should have 1 NOT operator");
20508    }
20509
20510    #[test]
20511    fn boolean_consecutive_operators_degenerate() {
20512        // Consecutive operators: "AND AND" - second AND becomes a term
20513        let tokens = parse_boolean_query("foo AND AND bar");
20514        // "AND" as the final part of "AND AND" is treated as operator, then next "bar" is term
20515        let term_count = tokens
20516            .iter()
20517            .filter(|t| matches!(t, QueryToken::Term(_)))
20518            .count();
20519        assert!(
20520            term_count >= 2,
20521            "Should have at least 2 terms (foo and bar)"
20522        );
20523    }
20524
20525    #[test]
20526    fn boolean_operator_at_start() {
20527        // Operator at start of query
20528        let tokens = parse_boolean_query("AND foo");
20529        let has_and = tokens.iter().any(|t| matches!(t, QueryToken::And));
20530        assert!(has_and, "Leading AND should be detected");
20531
20532        let tokens_or = parse_boolean_query("OR test");
20533        let has_or = tokens_or.iter().any(|t| matches!(t, QueryToken::Or));
20534        assert!(has_or, "Leading OR should be detected");
20535    }
20536
20537    #[test]
20538    fn boolean_operator_at_end() {
20539        // Operator at end of query
20540        let tokens = parse_boolean_query("foo AND");
20541        let has_and = tokens.iter().any(|t| matches!(t, QueryToken::And));
20542        assert!(has_and, "Trailing AND should be detected");
20543    }
20544
20545    // --- Numeric-only queries ---
20546
20547    #[test]
20548    fn numeric_query_digits_only() {
20549        // Query with only digits
20550        let tokens = parse_boolean_query("12345");
20551        assert_eq!(tokens.len(), 1);
20552        assert_eq!(tokens[0], QueryToken::Term("12345".to_string()));
20553
20554        let sanitized = sanitize_query("12345");
20555        assert_eq!(sanitized, "12345");
20556    }
20557
20558    #[test]
20559    fn numeric_query_with_text() {
20560        // Mixed numeric and text
20561        let tokens = parse_boolean_query("error 404 not found");
20562        let term_count = tokens
20563            .iter()
20564            .filter(|t| matches!(t, QueryToken::Term(_)))
20565            .count();
20566        // "404", "error", "found" are terms, "not" is NOT operator
20567        assert!(term_count >= 3, "Should have at least 3 terms");
20568    }
20569
20570    #[test]
20571    fn numeric_versions_with_dots() {
20572        // Version numbers like "1.2.3"
20573        let sanitized = sanitize_query("version 1.2.3");
20574        assert_eq!(sanitized, "version 1 2 3"); // dots become spaces
20575    }
20576
20577    // --- Tab and newline handling ---
20578
20579    #[test]
20580    fn whitespace_tabs_treated_as_separators() {
20581        let tokens = parse_boolean_query("foo\tbar\tbaz");
20582        let term_count = tokens
20583            .iter()
20584            .filter(|t| matches!(t, QueryToken::Term(_)))
20585            .count();
20586        assert_eq!(term_count, 3, "Tabs should separate terms");
20587    }
20588
20589    #[test]
20590    fn whitespace_newlines_treated_as_separators() {
20591        let tokens = parse_boolean_query("foo\nbar\nbaz");
20592        let term_count = tokens
20593            .iter()
20594            .filter(|t| matches!(t, QueryToken::Term(_)))
20595            .count();
20596        assert_eq!(term_count, 3, "Newlines should separate terms");
20597    }
20598
20599    #[test]
20600    fn whitespace_mixed_types() {
20601        let tokens = parse_boolean_query("a \t b \n c   d");
20602        let term_count = tokens
20603            .iter()
20604            .filter(|t| matches!(t, QueryToken::Term(_)))
20605            .count();
20606        assert_eq!(term_count, 4, "Mixed whitespace should separate properly");
20607    }
20608
20609    // --- Very long single terms (no spaces) ---
20610
20611    #[test]
20612    fn stress_very_long_single_term() {
20613        // Single term with 10K characters (no spaces)
20614        let long_term = "a".repeat(10_000);
20615
20616        let start = std::time::Instant::now();
20617        let tokens = parse_boolean_query(&long_term);
20618        let elapsed = start.elapsed();
20619
20620        assert!(
20621            elapsed < std::time::Duration::from_secs(1),
20622            "10K char term took {:?} (>1s)",
20623            elapsed
20624        );
20625        assert_eq!(tokens.len(), 1);
20626        assert!(
20627            matches!(tokens.first(), Some(QueryToken::Term(t)) if t.len() == 10_000),
20628            "Expected 10K Term token, got {tokens:?}"
20629        );
20630    }
20631
20632    #[test]
20633    fn stress_very_long_term_with_wildcard() {
20634        // Long term with wildcard suffix
20635        let long_pattern = format!("{}*", "prefix".repeat(1000));
20636
20637        let start = std::time::Instant::now();
20638        let sanitized = sanitize_query(&long_pattern);
20639        let pattern = WildcardPattern::parse(&sanitized);
20640        let elapsed = start.elapsed();
20641
20642        assert!(
20643            elapsed < std::time::Duration::from_secs(1),
20644            "Long wildcard pattern took {:?} (>1s)",
20645            elapsed
20646        );
20647        assert!(
20648            matches!(pattern, WildcardPattern::Prefix(_)),
20649            "Should parse as prefix pattern"
20650        );
20651    }
20652
20653    // --- QueryExplanation edge cases ---
20654
20655    #[test]
20656    fn query_explanation_empty_query() {
20657        let explanation = QueryExplanation::analyze("", &SearchFilters::default());
20658        assert_eq!(explanation.query_type, QueryType::Empty);
20659    }
20660
20661    #[test]
20662    fn search_mode_default_is_hybrid_preferred() {
20663        assert_eq!(SearchMode::default(), SearchMode::Hybrid);
20664    }
20665
20666    #[test]
20667    fn query_explanation_whitespace_only_query() {
20668        let explanation = QueryExplanation::analyze("   \t\n  ", &SearchFilters::default());
20669        assert_eq!(explanation.query_type, QueryType::Empty);
20670    }
20671
20672    #[test]
20673    fn query_explanation_unicode_query() {
20674        let explanation = QueryExplanation::analyze("日本語 search", &SearchFilters::default());
20675        // Should classify as Simple (no operators, multiple terms = implicit AND)
20676        assert!(!explanation.parsed.terms.is_empty());
20677    }
20678
20679    // --- QueryTermsLower edge cases ---
20680
20681    #[test]
20682    fn query_terms_lower_unicode_normalization() {
20683        // Accented characters should be lowercased properly
20684        let terms = QueryTermsLower::from_query("CAFÉ RÉSUMÉ");
20685        assert_eq!(terms.query_lower, "café résumé");
20686    }
20687
20688    #[test]
20689    fn query_terms_lower_mixed_case_unicode() {
20690        // Mixed case CJK and Latin
20691        let terms = QueryTermsLower::from_query("Hello日本語World");
20692        // CJK chars have no case, Latin chars should be lowercased
20693        assert!(terms.query_lower.contains("hello"));
20694        assert!(terms.query_lower.contains("world"));
20695    }
20696
20697    #[test]
20698    fn query_terms_lower_preserves_numbers() {
20699        let terms = QueryTermsLower::from_query("ABC123XYZ");
20700        assert_eq!(terms.query_lower, "abc123xyz");
20701    }
20702
20703    // --- WildcardPattern edge cases ---
20704
20705    #[test]
20706    fn wildcard_pattern_internal_asterisk() {
20707        // Internal wildcard: f*o
20708        let pattern = WildcardPattern::parse("f*o");
20709        assert!(
20710            matches!(pattern, WildcardPattern::Complex(_)),
20711            "Internal asterisk should be Complex"
20712        );
20713    }
20714
20715    #[test]
20716    fn wildcard_pattern_multiple_internal_asterisks() {
20717        // Multiple internal wildcards: a*b*c
20718        let pattern = WildcardPattern::parse("a*b*c");
20719        assert!(
20720            matches!(pattern, WildcardPattern::Complex(_)),
20721            "Multiple internal asterisks should be Complex"
20722        );
20723    }
20724
20725    #[test]
20726    fn wildcard_pattern_regex_escapes_special_chars() {
20727        // Pattern with regex-special characters
20728        let pattern = WildcardPattern::parse("*foo.bar*");
20729        if let Some(regex) = pattern.to_regex() {
20730            assert!(
20731                regex.contains("\\."),
20732                "Dot should be escaped in regex: {}",
20733                regex
20734            );
20735        }
20736    }
20737
20738    #[test]
20739    fn wildcard_pattern_complex_regex_generation() {
20740        let pattern = WildcardPattern::parse("f*o*o");
20741        if let Some(regex) = pattern.to_regex() {
20742            // Should handle internal wildcards
20743            assert!(
20744                regex.contains(".*"),
20745                "Should have .* for internal wildcards: {}",
20746                regex
20747            );
20748        }
20749    }
20750
20751    #[test]
20752    fn test_transpile_to_fts5() {
20753        // Simple terms
20754        assert_eq!(
20755            transpile_to_fts5("foo bar"),
20756            Some("foo AND bar".to_string())
20757        );
20758
20759        // Boolean operators
20760        assert_eq!(
20761            transpile_to_fts5("foo AND bar"),
20762            Some("foo AND bar".to_string())
20763        );
20764        assert_eq!(
20765            transpile_to_fts5("foo OR bar"),
20766            Some("(foo OR bar)".to_string())
20767        );
20768        assert_eq!(transpile_to_fts5("OR foo"), Some("foo".to_string()));
20769        assert_eq!(transpile_to_fts5("NOT foo"), None);
20770
20771        // Precedence: OR binds tighter than AND in our parser logic
20772        // "A AND B OR C" -> "A AND (B OR C)"
20773        assert_eq!(
20774            transpile_to_fts5("A AND B OR C"),
20775            Some("A AND (B OR C)".to_string())
20776        );
20777
20778        // "A OR B AND C" -> "(A OR B) AND C"
20779        assert_eq!(
20780            transpile_to_fts5("A OR B AND C"),
20781            Some("(A OR B) AND C".to_string())
20782        );
20783
20784        // "A OR B OR C" -> "(A OR B OR C)"
20785        assert_eq!(
20786            transpile_to_fts5("A OR B OR C"),
20787            Some("(A OR B OR C)".to_string())
20788        );
20789
20790        // Phrases
20791        assert_eq!(
20792            transpile_to_fts5("\"foo bar\""),
20793            Some("\"foo bar\"".to_string())
20794        );
20795
20796        // Wildcards (allowed trailing)
20797        assert_eq!(transpile_to_fts5("foo*"), Some("foo*".to_string()));
20798
20799        // Unsupported wildcards (leading/internal)
20800        assert_eq!(transpile_to_fts5("*foo"), None);
20801        assert_eq!(transpile_to_fts5("f*o"), None);
20802
20803        // SQLite FTS5's porter tokenizer splits punctuation into separate
20804        // fragments, so fallback queries must do the same.
20805        assert_eq!(
20806            transpile_to_fts5("foo-bar"),
20807            Some("(foo AND bar)".to_string())
20808        );
20809        assert_eq!(
20810            transpile_to_fts5("foo-bar*"),
20811            Some("(foo AND bar*)".to_string())
20812        );
20813        assert_eq!(
20814            transpile_to_fts5("br-123.jsonl"),
20815            Some("(br AND 123 AND jsonl)".to_string())
20816        );
20817        assert_eq!(
20818            transpile_to_fts5("br-123.json*"),
20819            Some("(br AND 123 AND json*)".to_string())
20820        );
20821
20822        // Leading unary-NOT forms are not valid FTS5 queries.
20823        assert_eq!(transpile_to_fts5("NOT A OR B"), None);
20824    }
20825
20826    #[test]
20827    fn semantic_doc_id_roundtrip_from_query() {
20828        let hash_hex = "00".repeat(32);
20829        let doc_id = format!("m|42|2|3|7|11|1|1700000000000|{hash_hex}");
20830        let parsed = parse_semantic_doc_id(&doc_id).expect("roundtrip parse");
20831        assert_eq!(parsed.message_id, 42);
20832        assert_eq!(parsed.chunk_idx, 2);
20833        assert_eq!(parsed.agent_id, 3);
20834        assert_eq!(parsed.workspace_id, 7);
20835        assert_eq!(parsed.source_id, 11);
20836        assert_eq!(parsed.role, 1);
20837        assert_eq!(parsed.created_at_ms, 1_700_000_000_000);
20838    }
20839
20840    #[test]
20841    fn semantic_filter_applies_all_constraints() {
20842        use frankensearch::core::filter::SearchFilter;
20843
20844        let filter = SemanticFilter {
20845            agents: Some(HashSet::from([3])),
20846            workspaces: Some(HashSet::from([7])),
20847            sources: Some(HashSet::from([11])),
20848            roles: Some(HashSet::from([1])),
20849            created_from: Some(1_700_000_000_000),
20850            created_to: Some(1_700_000_000_100),
20851        };
20852
20853        assert!(filter.matches("m|42|2|3|7|11|1|1700000000001", None));
20854        assert!(!filter.matches("m|42|2|99|7|11|1|1700000000001", None));
20855        assert!(!filter.matches("m|42|2|3|7|11|1|1699999999999", None));
20856        assert!(!filter.matches("not-a-doc-id", None));
20857    }
20858
20859    #[test]
20860    fn fs_semantic_index_runs_filtered_search() -> Result<()> {
20861        let temp = TempDir::new()?;
20862        let index_path = crate::search::vector_index::vector_index_path(temp.path(), "embed-fast");
20863        if let Some(parent) = index_path.parent() {
20864            std::fs::create_dir_all(parent)?;
20865        }
20866
20867        let hash_a = "00".repeat(32);
20868        let hash_b = "11".repeat(32);
20869        let doc_a = format!("m|101|0|1|10|100|1|1700000000001|{hash_a}");
20870        let doc_b = format!("m|202|0|2|20|200|1|1700000000002|{hash_b}");
20871
20872        let mut writer = VectorIndex::create_with_revision(
20873            &index_path,
20874            "embed-fast",
20875            "rev-1",
20876            2,
20877            frankensearch::index::Quantization::F16,
20878        )
20879        .map_err(|err| anyhow!("create fsvi index failed: {err}"))?;
20880        writer
20881            .write_record(&doc_a, &[1.0, 0.0])
20882            .map_err(|err| anyhow!("write_record failed: {err}"))?;
20883        writer
20884            .write_record(&doc_b, &[0.0, 1.0])
20885            .map_err(|err| anyhow!("write_record failed: {err}"))?;
20886        writer
20887            .finish()
20888            .map_err(|err| anyhow!("finish fsvi index failed: {err}"))?;
20889
20890        let fs_index =
20891            VectorIndex::open(&index_path).map_err(|err| anyhow!("open fsvi failed: {err}"))?;
20892        let filter = SemanticFilter {
20893            agents: Some(HashSet::from([1])),
20894            workspaces: None,
20895            sources: None,
20896            roles: None,
20897            created_from: None,
20898            created_to: None,
20899        };
20900        let fs_filter = semantic_filter_as_search_filter(&filter).expect("expected active filter");
20901        let hits = fs_index
20902            .search_top_k(&[1.0, 0.0], 5, Some(fs_filter))
20903            .map_err(|err| anyhow!("frankensearch search failed: {err}"))?;
20904        assert_eq!(hits.len(), 1);
20905        let parsed = parse_semantic_doc_id(&hits[0].doc_id).expect("parse bridged doc_id");
20906        assert_eq!(parsed.message_id, 101);
20907        assert_eq!(parsed.agent_id, 1);
20908        Ok(())
20909    }
20910
20911    // Regression guard for bead coding_agent_session_search-q6xf9
20912    // (`cass search --fields minimal` silently returned zero hits even when
20913    // matches existed). Root cause: the dedup pass called `hit_is_noise`,
20914    // which fell through to `is_search_noise_text("")` when both `content`
20915    // and `snippet` were stripped by the field_mask — treating every
20916    // projection-only hit as tool/acknowledgement noise and dropping it.
20917    //
20918    // Fix: when both fields are empty because the caller explicitly
20919    // requested a minimal projection, we cannot classify noise from text
20920    // alone. Default to "not noise" and let the hit through so downstream
20921    // field filtering emits the requested subset.
20922    #[test]
20923    fn hit_is_noise_returns_false_when_content_and_snippet_both_empty() {
20924        let hit = SearchHit {
20925            title: String::new(),
20926            snippet: String::new(),
20927            content: String::new(),
20928            content_hash: 0,
20929            conversation_id: Some(1),
20930            score: 1.0,
20931            source_path: "/tmp/session.jsonl".to_string(),
20932            agent: "codex".to_string(),
20933            workspace: String::new(),
20934            workspace_original: None,
20935            created_at: Some(1700000000000),
20936            line_number: Some(1),
20937            match_type: MatchType::Exact,
20938            source_id: "local".to_string(),
20939            origin_kind: "local".to_string(),
20940            origin_host: None,
20941        };
20942
20943        // Query text doesn't matter — the point is that a hit stripped of
20944        // content+snippet by --fields minimal must survive the noise filter
20945        // so `cass search --fields minimal` returns the projection.
20946        assert!(
20947            !hit_is_noise(&hit, "anything"),
20948            "hit with empty content AND snippet (projection-only) must NOT be classified as noise"
20949        );
20950        assert!(
20951            !hit_is_noise(&hit, ""),
20952            "noise classifier must not treat an empty-query projection-only hit as noise"
20953        );
20954    }
20955
20956    // Complementary guard: make sure the noise filter still flags legitimate
20957    // empty rows (no content_hash, etc.) when the content is actually empty
20958    // because the underlying message was empty — we don't want this fix to
20959    // re-introduce tool-ack noise into projection-full outputs.
20960    #[test]
20961    fn hit_is_noise_still_drops_tool_acknowledgement_when_content_present() {
20962        let hit = SearchHit {
20963            title: String::new(),
20964            snippet: String::new(),
20965            content: "ok".to_string(),
20966            content_hash: 0,
20967            conversation_id: Some(1),
20968            score: 1.0,
20969            source_path: "/tmp/session.jsonl".to_string(),
20970            agent: "codex".to_string(),
20971            workspace: String::new(),
20972            workspace_original: None,
20973            created_at: Some(1700000000000),
20974            line_number: Some(1),
20975            match_type: MatchType::Exact,
20976            source_id: "local".to_string(),
20977            origin_kind: "local".to_string(),
20978            origin_host: None,
20979        };
20980
20981        assert!(
20982            hit_is_noise(&hit, ""),
20983            "bare tool-ack 'ok' with content present should still be dropped as noise"
20984        );
20985    }
20986}