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
53struct 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
115const 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
126unsafe 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
151fn 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, ¶ms, |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
256pub struct StringInterner {
267 cache: RwLock<LruCache<Arc<str>, Arc<str>>>,
268}
269
270impl StringInterner {
271 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 pub fn intern(&self, s: &str) -> Arc<str> {
286 {
288 let cache = self.cache.read();
289 if let Some(arc) = cache.peek(s) {
292 return Arc::clone(arc);
293 }
294 }
295
296 let mut cache = self.cache.write();
298
299 if let Some(arc) = cache.get(s) {
302 return Arc::clone(arc);
303 }
304
305 let arc: Arc<str> = Arc::from(s);
307 cache.put(Arc::clone(&arc), Arc::clone(&arc));
308 arc
309 }
310
311 #[allow(dead_code)]
313 pub fn len(&self) -> usize {
314 self.cache.read().len()
315 }
316
317 #[allow(dead_code)]
319 pub fn is_empty(&self) -> bool {
320 self.cache.read().is_empty()
321 }
322}
323
324static CACHE_KEY_INTERNER: Lazy<StringInterner> = Lazy::new(|| StringInterner::new(10_000));
327
328#[inline]
330fn intern_cache_key(s: &str) -> Arc<str> {
331 CACHE_KEY_INTERNER.intern(s)
332}
333
334#[inline]
350pub fn sql_placeholders(count: usize) -> String {
351 if count == 0 {
352 return String::new();
353 }
354 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 #[serde(skip_serializing_if = "SourceFilter::is_all")]
374 pub source_filter: SourceFilter,
375 #[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,
385 Semantic,
387 #[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#[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
445pub const NO_LIMIT_RESULT_MIN: usize = 1_000;
466pub const NO_LIMIT_RESULT_MAX: usize = 1_000_000;
467
468const AVG_HIT_BYTES: u64 = 80 * 1024;
473
474const NO_LIMIT_BYTES_CEILING: u64 = 16 * 1024 * 1024 * 1024;
480
481const NO_LIMIT_BYTES_FLOOR: u64 = 256 * 1024 * 1024;
485
486const NO_LIMIT_RAM_DIVISOR: u64 = 16;
490
491const 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
555fn compute_no_limit_result_cap_from(
562 cap_env: Option<String>,
563 bytes_env: Option<String>,
564 available_bytes: Option<u64>,
565) -> usize {
566 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 FsQueryClass::Identifier => (6, 2),
619 FsQueryClass::ShortKeyword => (4, 4),
621 FsQueryClass::NaturalLanguage => (2, 8),
623 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 if requested_limit == 0 {
643 let planning_window = HYBRID_NO_LIMIT_PLANNING_WINDOW.max(offset.saturating_add(1));
644 let lexical = effective_limit.min(total_docs).min(no_limit_result_cap());
649 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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
686#[serde(rename_all = "snake_case")]
687pub enum QueryType {
688 Simple,
690 Phrase,
692 Boolean,
694 Wildcard,
696 Filtered,
698 Empty,
700}
701
702#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
704#[serde(rename_all = "snake_case")]
705pub enum IndexStrategy {
706 EdgeNgram,
708 RegexScan,
710 BooleanCombination,
712 RangeScan,
714 FullScan,
716}
717
718#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
720#[serde(rename_all = "snake_case")]
721pub enum QueryCost {
722 Low,
724 Medium,
726 High,
728}
729
730#[derive(Debug, Clone, serde::Serialize)]
732pub struct ParsedSubTerm {
733 pub text: String,
734 pub pattern: String,
735}
736
737#[derive(Debug, Clone, serde::Serialize)]
739pub struct ParsedTerm {
740 pub text: String,
742 pub negated: bool,
744 pub subterms: Vec<ParsedSubTerm>,
746}
747
748#[derive(Debug, Clone, Default, serde::Serialize)]
750pub struct ParsedQuery {
751 pub terms: Vec<ParsedTerm>,
753 pub phrases: Vec<String>,
755 pub operators: Vec<String>,
757 pub implicit_and: bool,
759}
760
761#[derive(Debug, Clone, serde::Serialize)]
763pub struct QueryExplanation {
764 pub original_query: String,
766 pub sanitized_query: String,
768 pub parsed: ParsedQuery,
770 pub query_type: QueryType,
772 pub index_strategy: IndexStrategy,
774 pub wildcard_applied: bool,
776 pub estimated_cost: QueryCost,
778 pub filters_summary: FiltersSummary,
780 pub warnings: Vec<String>,
782}
783
784#[derive(Debug, Clone, Default, serde::Serialize)]
786pub struct FiltersSummary {
787 pub agent_count: usize,
789 pub workspace_count: usize,
791 pub has_time_filter: bool,
793 pub description: Option<String>,
795}
796
797impl QueryExplanation {
798 pub fn analyze(query: &str, filters: &SearchFilters) -> Self {
800 let sanitized = nfc_sanitize_query(query);
801 let tokens = fs_cass_parse_boolean_query(query);
803
804 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 parsed.implicit_and = !has_explicit_operator && parsed.terms.len() > 1;
871
872 let query_type = Self::classify_query(&parsed, filters, &sanitized);
874
875 let index_strategy = Self::determine_strategy(&parsed, &sanitized);
877
878 let estimated_cost = Self::estimate_cost(&parsed, &index_strategy, filters);
880
881 let filters_summary = Self::summarize_filters(filters);
883
884 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, 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 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 if !parsed.operators.is_empty() {
918 return QueryType::Boolean;
919 }
920
921 if !parsed.phrases.is_empty() {
923 return QueryType::Phrase;
924 }
925
926 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 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 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 IndexStrategy::EdgeNgram
969 }
970
971 fn estimate_cost(
972 parsed: &ParsedQuery,
973 strategy: &IndexStrategy,
974 filters: &SearchFilters,
975 ) -> QueryCost {
976 if matches!(strategy, IndexStrategy::RegexScan) {
978 return QueryCost::High;
979 }
980
981 if matches!(strategy, IndexStrategy::FullScan) {
983 return QueryCost::High;
984 }
985
986 let has_time_filter = filters.created_from.is_some() || filters.created_to.is_some();
988
989 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 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 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 if sanitized.trim().is_empty() {
1077 warnings.push("Empty query will return all documents (expensive)".to_string());
1078 }
1079
1080 if parsed.operators.len() > 3 {
1082 warnings.push("Complex boolean query may have unexpected precedence".to_string());
1083 }
1084
1085 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
1120#[serde(rename_all = "snake_case")]
1121pub enum MatchType {
1122 #[default]
1124 Exact,
1125 Prefix,
1127 Suffix,
1129 Substring,
1131 Wildcard,
1133 ImplicitWildcard,
1135}
1136
1137impl MatchType {
1138 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
1153#[serde(rename_all = "snake_case")]
1154pub enum SuggestionKind {
1155 SpellingFix,
1157 WildcardQuery,
1159 RemoveFilter,
1161 AlternateAgent,
1163 BroaderDateRange,
1165}
1166
1167#[derive(Debug, Clone, serde::Serialize)]
1169pub struct QuerySuggestion {
1170 pub kind: SuggestionKind,
1172 pub message: String,
1174 pub suggested_query: Option<String>,
1176 pub suggested_filters: Option<SearchFilters>,
1178 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 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 #[serde(skip_serializing_if = "Option::is_none")]
1322 pub workspace_original: Option<String>,
1323 pub created_at: Option<i64>,
1324 pub line_number: Option<usize>,
1326 #[serde(default)]
1328 pub match_type: MatchType,
1329 #[serde(default = "default_source_id")]
1332 pub source_id: String,
1333 #[serde(default = "default_source_id")]
1335 pub origin_kind: String,
1336 #[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#[derive(Debug, Clone)]
1412pub struct SearchResult {
1413 pub hits: Vec<SearchHit>,
1415 pub wildcard_fallback: bool,
1417 pub cache_stats: CacheStats,
1419 pub suggestions: Vec<QuerySuggestion>,
1421 pub ann_stats: Option<crate::search::ann_index::AnnSearchStats>,
1423 pub total_count: Option<usize>,
1428}
1429
1430#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1431pub enum ProgressivePhaseKind {
1432 Initial,
1433 Refined,
1434}
1435
1436#[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
1649pub(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 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 use std::fmt::Write as _;
1709 const SEP: char = '\u{1f}';
1710 let capacity = key.source_id.len()
1712 + key.source_path.len()
1713 + key.title.len()
1714 + 6 + 3 * 20 + 20; 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#[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#[cfg(test)]
1764#[allow(dead_code)]
1765const QUICKSELECT_THRESHOLD: usize = 64;
1766
1767#[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 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 if n < QUICKSELECT_THRESHOLD {
1791 hits.sort_by(cmp_fused_hit_desc);
1792 hits.truncate(k);
1793 return hits;
1794 }
1795
1796 hits.select_nth_unstable_by(k - 1, cmp_fused_hit_desc);
1798
1799 hits.truncate(k);
1801
1802 hits.sort_by(cmp_fused_hit_desc);
1804
1805 hits
1806}
1807
1808pub 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 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 let fused = fs_rrf_fuse(
1862 &lexical_scored,
1863 &semantic_scored,
1864 total_candidates,
1865 0,
1866 &FsRrfConfig::default(),
1867 );
1868
1869 #[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
2059fn 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 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 pub eviction_count: u64,
2582 pub approx_bytes: usize,
2584 pub byte_cap: usize,
2586 pub eviction_policy: &'static str,
2588 pub ghost_entries: usize,
2590 pub admission_rejects: u64,
2592 pub prewarm_scheduled: u64,
2594 pub prewarm_skipped_pressure: u64,
2596 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
2623static 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
2633static 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
2648static 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
2671static 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 fn approx_bytes(&self) -> usize {
2739 let base = std::mem::size_of::<Self>();
2741 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 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 shards: HashMap<Arc<str>, LruCache<Arc<str>, Vec<CachedHit>>>,
2770 total_cap: usize,
2771 total_cost: usize,
2772 eviction_count: u64,
2774 total_bytes: usize,
2776 byte_cap: usize,
2778 policy: CacheEvictionPolicy,
2780 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 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 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 while self.total_cost > self.total_cap
2854 || (self.byte_cap > 0 && self.total_bytes > self.byte_cap)
2855 {
2856 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; }
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 }
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
3037fn 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 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) .min(curr_row[j] + 1) .min(prev_row[j] + cost); }
3064 std::mem::swap(&mut prev_row, &mut curr_row);
3065 }
3066
3067 prev_row[b_len]
3068}
3069
3070fn 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
3098fn 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
3121fn 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 if mt.quality_factor() < worst.quality_factor() {
3136 worst = mt;
3137 }
3138 }
3139 worst
3140}
3141
3142pub(crate) fn is_tool_invocation_noise(content: &str) -> bool {
3145 let trimmed = content.trim();
3146
3147 if trimmed.starts_with("[Tool:") {
3149 if let Some(close_idx) = trimmed.find(']') {
3151 let after = &trimmed[close_idx + 1..];
3153 if !after.trim().is_empty() {
3154 return false; }
3156
3157 let inner = &trimmed[6..close_idx]; return inner.trim().is_empty();
3163 }
3164 return true;
3166 }
3167
3168 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 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#[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 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 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 deduped[existing_idx].score < hit.score {
3303 deduped[existing_idx] = hit;
3304 }
3305 } 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 !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 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 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 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 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 self.metrics.inc_cache_shortfall();
3553 self.maybe_log_cache_metrics("shortfall");
3554 } else {
3555 self.metrics.inc_cache_miss();
3557 self.maybe_log_cache_metrics("miss");
3558 }
3559 }
3560
3561 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 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 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, 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 let unsupported_wildcards = sanitized.split_whitespace().any(|t| {
3752 let core = t.trim_end_matches('*');
3753 core.contains('*') });
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, 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 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, ¶ms, |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 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, ¶ms, |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 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 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 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 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 #[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 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 let hits = self.search(query, filters.clone(), limit, offset, field_mask)?;
5622 let baseline_stats = self.cache_stats();
5623 let tantivy_total = self
5625 .last_tantivy_total_count
5626 .lock()
5627 .ok()
5628 .and_then(|guard| *guard);
5629
5630 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 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 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 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 let fallback_tantivy_total = self
5708 .last_tantivy_total_count
5709 .lock()
5710 .ok()
5711 .and_then(|guard| *guard);
5712
5713 if fallback_hits.len() > hits.len() {
5715 for hit in &mut fallback_hits {
5717 hit.match_type = MatchType::ImplicitWildcard;
5718 }
5719 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 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 #[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 #[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 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 if !query.contains('*') && query.len() >= 2 {
5865 suggestions.push(QuerySuggestion::wildcard(query).with_shortcut(1));
5866 }
5867
5868 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 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; }
5898 }
5899
5900 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 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, ¶ms, |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 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 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 ¶ms,
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 ¶ms,
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 ¶ms,
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, ¶ms, |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 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 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 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, ¶ms, |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#[doc(hidden)]
7663pub fn fuzz_transpile_to_fts5(raw_query: &str) -> Option<String> {
7664 transpile_to_fts5(raw_query)
7665}
7666
7667fn 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 continue;
7701 }
7702 in_or_sequence = true;
7705 }
7706 FsCassQueryToken::Not => {
7707 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 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 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 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 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 if fts_clauses.first().is_some_and(|(op, _)| *op == "NOT") {
7824 return None;
7825 }
7826
7827 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 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 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 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 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
8018struct QueryTermsLower {
8028 query_lower: String,
8030 token_ranges: Vec<(usize, usize)>,
8032 bloom_mask: u64,
8034}
8035
8036impl QueryTermsLower {
8037 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 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 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 #[inline]
8080 fn is_empty(&self) -> bool {
8081 self.token_ranges.is_empty()
8082 }
8083
8084 #[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 #[inline]
8094 fn bloom_mask(&self) -> u64 {
8095 self.bloom_mask
8096 }
8097}
8098
8099fn hit_matches_query_cached_precomputed(hit: &CachedHit, terms: &QueryTermsLower) -> bool {
8102 if terms.is_empty() {
8103 return true;
8104 }
8105
8106 if hit.bloom64 & terms.bloom_mask() != terms.bloom_mask() {
8108 return false;
8109 }
8110
8111 terms.tokens().all(|t| {
8113 if token_stream(&hit.lc_content).any(|word| word.starts_with(t)) {
8115 return true;
8116 }
8117 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#[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 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 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 let match_start_char_idx = lc_content[..pos].chars().count();
8163 let query_char_len = lc_query.chars().count();
8164
8165 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 if !matches!(
8284 filters.source_filter,
8285 crate::sources::provenance::SourceFilter::All
8286 ) {
8287 parts.push(format!("src:{:?}", filters.source_filter));
8288 }
8289 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 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 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 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 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 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 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 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 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 #[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 #[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 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 let _ = classified_as_noise_on_real_content;
8978 assert!(!hit.content.is_empty(), "precondition: content populated");
8979 }
8980
8981 #[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 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 #[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 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 let _s4 = interner.intern("query4");
9842
9843 assert_eq!(interner.len(), 3);
9844
9845 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 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 #[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 assert_ne!(terms.bloom_mask(), 0);
9936
9937 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 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 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"); assert_eq!(top[1].key.source_path, "e"); assert_eq!(top[2].key.source_path, "b"); }
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"); assert_eq!(top[1].key.source_path, "a"); }
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"); assert_eq!(top[1].key.source_path, "c"); assert_eq!(top[2].key.source_path, "b"); }
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 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 let hits = vec![
10212 make_fused_hit("a", 2.0, Some(0), None), make_fused_hit("b", 2.0, Some(1), Some(0)), make_fused_hit("c", 2.0, None, Some(1)), ];
10216
10217 let top = top_k_fused(hits, 3);
10218
10219 assert_eq!(top.len(), 3);
10220 assert_eq!(top[0].key.source_path, "b");
10222 }
10223
10224 #[test]
10225 fn top_k_fused_large_input_uses_quickselect() {
10226 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 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 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 let score = ((i * 17 + 7) % 1000) as f32;
10254 make_fused_hit(&format!("hit_{}", i), score, Some(i), None)
10255 })
10256 .collect();
10257
10258 let mut baseline = hits.clone();
10260 baseline.sort_by(cmp_fused_hit_desc);
10261 baseline.truncate(k);
10262
10263 let quickselect = top_k_fused(hits, k);
10265
10266 assert_eq!(quickselect.len(), baseline.len(), "n={}, k={}", n, k);
10268
10269 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 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 #[test]
10298 fn cache_enforces_prefix_matching() {
10299 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, };
10325
10326 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 let dir = TempDir::new().unwrap();
10343 let index_path = dir.path();
10344 let mut index = TantivyIndex::open_or_create(index_path).unwrap();
10345
10346 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), content: "duplicate content".into(), 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(), 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 let page1 = client
10401 .search("duplicate", SearchFilters::default(), 1, 0, FieldMask::FULL)
10402 .unwrap();
10403 assert_eq!(page1.len(), 1);
10404
10405 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 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 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 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 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 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 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 let hits = client.search("vari", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
10944 assert_eq!(hits.len(), 1);
10945
10946 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 let hits = client.search("c++", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
10990 assert_eq!(hits.len(), 1);
10991
10992 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 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 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 {
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 {
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 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 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 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, ¶ms, |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 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 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 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 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 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 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 {
13523 let cache = client.prefix_cache.lock().unwrap();
13524 let shard = cache.shard_opt("global").unwrap();
13525 assert!(shard.contains(&client.cache_key("app", &SearchFilters::default())));
13527 }
13528
13529 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 std::thread::sleep(std::time::Duration::from_millis(350));
13559
13560 let _hits = client.search("app", SearchFilters::default(), 10, 0, FieldMask::FULL)?;
13563 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 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)), 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 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 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 let client = SearchClient {
14044 reader: None,
14045 sqlite: Mutex::new(None),
14046 sqlite_path: None,
14047 prefix_cache: Mutex::new(CacheShards::new(1000, 100)), 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 let content = "c".repeat(100);
14062 let hit = SearchHit {
14063 title: "a".repeat(50),
14064 snippet: "b".repeat(50),
14065 content: content.clone(), 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 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 }
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 #[test]
14169 fn wildcard_pattern_parse_exact() {
14170 assert_eq!(
14172 FsCassWildcardPattern::parse("hello"),
14173 FsCassWildcardPattern::Exact("hello".into())
14174 );
14175 assert_eq!(
14176 FsCassWildcardPattern::parse("HELLO"),
14177 FsCassWildcardPattern::Exact("hello".into()) );
14179 assert_eq!(
14180 FsCassWildcardPattern::parse("FooBar123"),
14181 FsCassWildcardPattern::Exact("foobar123".into())
14182 );
14183 }
14184
14185 #[test]
14186 fn wildcard_pattern_parse_prefix() {
14187 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 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 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 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 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 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 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 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 assert_eq!(MatchType::Exact.quality_factor(), 1.0);
14300 assert_eq!(MatchType::Prefix.quality_factor(), 0.9);
14302 assert_eq!(MatchType::Suffix.quality_factor(), 0.8);
14304 assert_eq!(MatchType::Substring.quality_factor(), 0.7);
14306 assert_eq!(MatchType::ImplicitWildcard.quality_factor(), 0.6);
14308 }
14309
14310 #[test]
14311 fn dominant_match_type_single_terms() {
14312 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 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 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 assert!(!is_tool_invocation_noise("[Tool: Bash]"));
14372 assert!(!is_tool_invocation_noise("[Tool: Read]"));
14373
14374 assert!(is_tool_invocation_noise("[Tool:]"));
14376 assert!(is_tool_invocation_noise("[Tool: ]"));
14377
14378 assert!(!is_tool_invocation_noise("[Tool: Bash - Check status]"));
14380 assert!(!is_tool_invocation_noise(" [Tool: Grep - Search files] "));
14381
14382 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 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 assert!(!is_tool_invocation_noise("[Tool: Bash]"));
14398 assert!(!is_tool_invocation_noise("[Tool: Read]"));
14399
14400 assert!(is_tool_invocation_noise("[Tool:]"));
14402
14403 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(), content_hash: stable_content_hash("hello world"),
14434 score: 0.5, 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(), 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); 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, 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, 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); 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(), 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(), 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); }
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(), 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); }
14894
14895 #[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(), 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(), 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 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 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 let result = client.search_with_fallback(
15043 "apple",
15044 SearchFilters::default(),
15045 10,
15046 0,
15047 3, FieldMask::FULL,
15049 )?;
15050
15051 assert!(!result.wildcard_fallback);
15052 assert!(result.hits.len() >= 3); 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 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 let result = client.search_with_fallback(
15091 "config",
15092 SearchFilters::default(),
15093 10,
15094 0,
15095 5, FieldMask::FULL,
15097 )?;
15098
15099 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 let result = client.search_with_fallback(
15138 "*test*",
15139 SearchFilters::default(),
15140 10,
15141 0,
15142 10, FieldMask::FULL,
15144 )?;
15145
15146 assert!(!result.wildcard_fallback); 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 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 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 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 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 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()); let result = client.search_with_fallback("claud", filters, 5, 0, 3, FieldMask::FULL)?;
15520
15521 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 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 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 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 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 #[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 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 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 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 #[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 let dir = TempDir::new()?;
15941 let mut index = TantivyIndex::open_or_create(dir.path())?;
15942
15943 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 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 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 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 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 let dir = TempDir::new()?;
16020 let mut index = TantivyIndex::open_or_create(dir.path())?;
16021
16022 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 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 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 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 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 let dir = TempDir::new()?;
16102 let mut index = TantivyIndex::open_or_create(dir.path())?;
16103
16104 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 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 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 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 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 assert_eq!(hits.len(), 1, "Should find exactly 1 doc in range");
16194
16195 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 let dir = TempDir::new()?;
16213 let mut index = TantivyIndex::open_or_create(dir.path())?;
16214
16215 let combinations = [
16217 ("codex", "/ws/prod", 100), ("claude", "/ws/prod", 500), ("claude", "/ws/dev", 500), ("claude", "/ws/prod", 900), ];
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 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 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 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 let dir = TempDir::new()?;
16419 let mut index = TantivyIndex::open_or_create(dir.path())?;
16420
16421 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 index.add_conversation(&conv_local)?;
16445 index.commit()?;
16446
16447 let client = SearchClient::open(dir.path(), None)?.expect("index present");
16448
16449 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 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 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 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 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 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 #[test]
16549 fn sanitize_query_preserves_unicode_alphanumeric() {
16550 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 assert_eq!(fs_cass_sanitize_query("a!@#$%^&()b"), "a b");
16561 }
16562
16563 #[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 assert_eq!(FsCassWildcardPattern::Exact("foo".into()).to_regex(), None);
16585 assert_eq!(FsCassWildcardPattern::Prefix("foo".into()).to_regex(), None);
16586 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 #[test]
16601 fn parse_boolean_query_prefix_minus_not() {
16602 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 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 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 #[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 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 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 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 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 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 #[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 #[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 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 #[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 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 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 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 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 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 assert!(
17187 !hits[0].content.contains("exclude"),
17188 "NOT exclude should filter out doc with 'exclude'"
17189 );
17190
17191 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 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 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 #[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 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 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 assert!(exp.sanitized_query.contains("Hello"));
17430 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 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 #[test]
17470 fn search_multi_filter_agent_workspace_time() -> Result<()> {
17471 let dir = TempDir::new()?;
17473 let mut index = TantivyIndex::open_or_create(dir.path())?;
17474
17475 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 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")); Ok(())
17529 }
17530
17531 #[test]
17532 fn search_multi_agent_filter() -> Result<()> {
17533 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 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 #[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 let (hits, miss, shortfall, reloads, _) = client.metrics.snapshot_all();
17605 assert_eq!((hits, miss, shortfall, reloads), (0, 0, 0, 0));
17606
17607 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 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 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 let shard2 = client.shard_name(&filters2);
17657 assert_ne!(
17658 shard1_first, shard2,
17659 "Different filters should produce different shard names"
17660 );
17661
17662 assert_eq!(shard2, client.shard_name(&filters2));
17664 assert_eq!(
17665 client.shard_name(&filters3),
17666 "workspace:/tmp/cass-workspace"
17667 );
17668 }
17669
17670 #[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 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 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 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 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 #[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 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 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 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 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 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 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 assert!(!result.hits.is_empty());
17941
17942 Ok(())
17943 }
17944
17945 #[test]
17950 fn search_session_paths_filter() -> Result<()> {
17951 let dir = TempDir::new()?;
17953 let mut index = TantivyIndex::open_or_create(dir.path())?;
17954
17955 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 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 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 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 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 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 #[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 let result = sql_placeholders(3);
18443 assert_eq!(result.len(), 5);
18444 assert!(result.capacity() >= 5); 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 let result = sql_placeholders(100);
18456 assert_eq!(result.len(), 199); 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 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 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 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 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 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 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 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 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), );
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 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 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 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 assert_eq!(fused.len(), 4);
18687 assert_eq!(fused[0].title, "A"); assert_eq!(fused[1].title, "B"); }
18691
18692 #[test]
18693 fn test_rrf_handles_disjoint_sets() {
18694 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 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 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![]; 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 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 let lexical = vec![
18739 make_test_hit("solo_lex", 10.0), make_test_hit("both", 5.0), ];
18742 let semantic = vec![
18743 make_test_hit("solo_sem", 0.9), make_test_hit("both", 0.5), ];
18746
18747 let fused = rrf_fuse_hits(&lexical, &semantic, "", 10, 0);
18748
18749 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 let fused = rrf_fuse_hits(&lexical, &semantic, "", 2, 0);
18769 assert_eq!(fused.len(), 2);
18770
18771 let fused_offset = rrf_fuse_hits(&lexical, &semantic, "", 10, 1);
18773 assert_eq!(fused_offset.len(), 2); 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 assert!(rrf_fuse_hits(&empty, &empty, "", 10, 0).is_empty());
18787
18788 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 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 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 assert_eq!(fused.len(), 20);
18969
18970 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 #[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 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 #[test]
19051 fn unicode_emoji_treated_as_separator() {
19052 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 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 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 let tokens: Vec<&str> = terms.tokens().collect();
19097 assert!(
19098 tokens.contains(&"launch"),
19099 "Should extract 'launch' from emoji query"
19100 );
19101 }
19102
19103 #[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 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 #[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 let terms = QueryTermsLower::from_query("مرحبا بالعالم");
19173 let tokens: Vec<&str> = terms.tokens().collect();
19174 assert_eq!(tokens, vec!["مرحبا", "بالعالم"]);
19175 }
19176
19177 #[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 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 #[test]
19215 fn unicode_zero_width_joiner_removed() {
19216 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 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 let sanitized = sanitize_query("test\u{200B}query");
19232 assert_eq!(sanitized, "test query");
19233 }
19234
19235 #[test]
19236 fn unicode_bom_removed() {
19237 let sanitized = sanitize_query("\u{FEFF}test");
19239 assert_eq!(sanitized, " test");
19240 }
19241
19242 #[test]
19245 fn unicode_precomposed_accent_preserved() {
19246 let sanitized = sanitize_query("café");
19248 assert_eq!(sanitized, "café");
19249 }
19250
19251 #[test]
19252 fn unicode_combining_accent_becomes_separator() {
19253 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 let nfc = "caf\u{00E9}";
19265 let nfd = "cafe\u{0301}";
19267
19268 let san_nfc = sanitize_query(nfc);
19269 let san_nfd = sanitize_query(nfd);
19270
19271 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 let zalgo = "t\u{0301}\u{0302}\u{0303}e\u{0304}\u{0305}st";
19283 let sanitized = sanitize_query(zalgo);
19284 assert!(sanitized.contains('t'));
19286 assert!(sanitized.contains('s'));
19287 }
19288
19289 #[test]
19292 fn unicode_mathematical_bold_letters_preserved() {
19293 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 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 let input = "test\u{1F600}query";
19317 let sanitized = sanitize_query(input);
19318 assert_eq!(sanitized, "test query");
19319 }
19320
19321 #[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 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 let input = "test\u{200F}content";
19354 let sanitized = sanitize_query(input);
19355 assert_eq!(sanitized, "test content");
19356 }
19357
19358 #[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 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 let explanation = QueryExplanation::analyze("🚀🔥💻", &SearchFilters::default());
19383 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 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 let terms = QueryTermsLower::from_query("STRAßE");
19426 assert_eq!(terms.query_lower, "straße");
19427
19428 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 assert!(parts.contains(&"launch".to_string()));
19445 assert!(parts.contains(&"code".to_string()));
19446 }
19447
19448 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
19731 fn special_char_html_entity_lt() {
19732 let sanitized = sanitize_query("<script>");
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("<script>");
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 #[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 #[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 #[test]
19858 fn stress_query_100k_chars_completes_quickly() {
19859 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 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 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 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 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 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 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 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 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 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 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 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 let large_query = "x".repeat(100000);
20075
20076 let sanitized = sanitize_query(&large_query);
20077 let tokens = parse_boolean_query(&sanitized);
20078
20079 assert!(
20081 sanitized.len() <= large_query.len(),
20082 "Sanitized output should not exceed input size"
20083 );
20084
20085 assert_eq!(tokens.len(), 1);
20087
20088 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 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 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 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 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 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 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 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 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 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 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 #[test]
20350 fn unicode_emoji_mixed_with_alphanumeric() {
20351 let tokens = parse_boolean_query("rocket🚀launch");
20353 assert_eq!(tokens.len(), 1);
20354 let sanitized = sanitize_query("rocket🚀launch");
20356 assert_eq!(sanitized, "rocket launch");
20357
20358 let sanitized2 = sanitize_query("test🔥🎯code");
20360 assert_eq!(sanitized2, "test code");
20361 }
20362
20363 #[test]
20364 fn unicode_emoji_with_boolean_operators() {
20365 let tokens = parse_boolean_query("🚀code AND test");
20367 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 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 let sanitized_start = sanitize_query("🔍search");
20384 assert_eq!(sanitized_start, " search");
20385
20386 let sanitized_end = sanitize_query("complete✅");
20388 assert_eq!(sanitized_end, "complete ");
20389
20390 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 #[test]
20401 fn unicode_arabic_text_preserved() {
20402 let arabic = "مرحبا بالعالم"; 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 let hebrew = "שלום עולם"; 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 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 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 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 #[test]
20461 fn special_chars_backslash_stripped() {
20462 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 let query = r#"say \"hello\""#;
20472 let sanitized = sanitize_query(query);
20473 assert!(sanitized.contains('"'), "Quotes should be preserved");
20475 }
20476
20477 #[test]
20478 fn special_chars_windows_paths() {
20479 let path = r"C:\Users\test\Documents";
20481 let sanitized = sanitize_query(path);
20482 assert_eq!(sanitized, "C Users test Documents");
20483 }
20484
20485 #[test]
20488 fn boolean_deeply_nested_operators() {
20489 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 let tokens = parse_boolean_query("foo AND AND bar");
20514 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 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 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 #[test]
20548 fn numeric_query_digits_only() {
20549 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 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 assert!(term_count >= 3, "Should have at least 3 terms");
20568 }
20569
20570 #[test]
20571 fn numeric_versions_with_dots() {
20572 let sanitized = sanitize_query("version 1.2.3");
20574 assert_eq!(sanitized, "version 1 2 3"); }
20576
20577 #[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 #[test]
20612 fn stress_very_long_single_term() {
20613 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 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 #[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 assert!(!explanation.parsed.terms.is_empty());
20677 }
20678
20679 #[test]
20682 fn query_terms_lower_unicode_normalization() {
20683 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 let terms = QueryTermsLower::from_query("Hello日本語World");
20692 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 #[test]
20706 fn wildcard_pattern_internal_asterisk() {
20707 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 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 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 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 assert_eq!(
20755 transpile_to_fts5("foo bar"),
20756 Some("foo AND bar".to_string())
20757 );
20758
20759 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 assert_eq!(
20774 transpile_to_fts5("A AND B OR C"),
20775 Some("A AND (B OR C)".to_string())
20776 );
20777
20778 assert_eq!(
20780 transpile_to_fts5("A OR B AND C"),
20781 Some("(A OR B) AND C".to_string())
20782 );
20783
20784 assert_eq!(
20786 transpile_to_fts5("A OR B OR C"),
20787 Some("(A OR B OR C)".to_string())
20788 );
20789
20790 assert_eq!(
20792 transpile_to_fts5("\"foo bar\""),
20793 Some("\"foo bar\"".to_string())
20794 );
20795
20796 assert_eq!(transpile_to_fts5("foo*"), Some("foo*".to_string()));
20798
20799 assert_eq!(transpile_to_fts5("*foo"), None);
20801 assert_eq!(transpile_to_fts5("f*o"), None);
20802
20803 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 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 #[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 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 #[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}