Skip to main content

seyk_core/
engine.rs

1//! The search engine: discovery, segmentation, lazy embedding, ranking.
2
3use std::collections::{HashMap, HashSet};
4use std::path::{Path, PathBuf};
5use std::sync::{
6    Arc, Condvar, Mutex, MutexGuard,
7    atomic::{AtomicBool, Ordering},
8};
9use std::time::Instant;
10
11use serde::{Deserialize, Serialize};
12
13use crate::cache::{
14    CacheProfile, CachedSegment, EmbeddingCache, MemoryCache, ResourceWrite, SegmentWrite,
15};
16use crate::discovery::{DiscoveryOptions, ResourceKind};
17use crate::embedder::{Embedder, EmbeddingRequest, EmbeddingTask, ResourceInput};
18use crate::extractor::{
19    ExtractedContent, Extractor, ResourceBytes, TextExtractor, extract_resource, text_range,
20    unsupported,
21};
22use crate::index::{BruteForceIndex, Scored, VectorIndex};
23use crate::lexical::LexicalQuery;
24use crate::progress::Completion;
25use crate::segment::{TextFormat, TextSegmenter};
26use crate::{Embedding, Error, ResourceId, ResourceLocator, ResourceMetadata, Result};
27use crate::{FsProvider, ProvidedResource, ResourceProvider, ResourceScope, ResourceVersion};
28
29mod image_scan;
30pub use image_scan::ImageScan;
31
32/// Bump when the way segments are turned into model inputs changes.
33const INPUT_FORMAT_VERSION: u32 = 2;
34
35/// How results are found and ranked.
36#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum SearchMode {
39    /// Meaning, keywords and file names, fused by rank. The default.
40    #[default]
41    Hybrid,
42    /// Embedding similarity only; `score` is the cosine similarity.
43    Semantic,
44    /// Keywords and file names only. The query is never embedded.
45    Lexical,
46}
47
48impl std::str::FromStr for SearchMode {
49    type Err = Error;
50    fn from_str(s: &str) -> Result<Self> {
51        match s {
52            "hybrid" => Ok(Self::Hybrid),
53            "semantic" => Ok(Self::Semantic),
54            "lexical" | "keyword" => Ok(Self::Lexical),
55            other => Err(Error::Config(format!(
56                "unknown search mode {other:?}: use hybrid, semantic or lexical"
57            ))),
58        }
59    }
60}
61
62/// Why a response contains only keyword and filename matches.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum DegradedReason {
66    ModelLoading,
67    NotYetEmbedded,
68    NoModel,
69}
70
71/// Additive response metadata, shared by library, CLI JSON and MCP.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73pub struct SearchDegradation {
74    pub mode: DegradedMode,
75    pub reason: DegradedReason,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub enum DegradedMode {
81    LexicalOnly,
82}
83
84impl SearchDegradation {
85    pub fn lexical(reason: DegradedReason) -> Self {
86        Self {
87            mode: DegradedMode::LexicalOnly,
88            reason,
89        }
90    }
91}
92
93/// Which signals found a hit, with their raw scores.
94#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
95pub struct MatchSignals {
96    /// Cosine similarity of the best-matching phrasing, when it ranked.
97    pub semantic: Option<f32>,
98    /// BM25 keyword relevance, when it ranked. Higher is better.
99    pub lexical: Option<f32>,
100    /// The query named this file (`garden.md`, `garden`, `notes/garden.md`).
101    pub filename: bool,
102}
103
104/// Per-leg positions in the candidate lists used to rank a hit.
105/// Ranks are one-based; `None` means the leg did not contribute. These ranks
106/// precede fusion and the final result limit, so can exceed that limit.
107#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
108pub struct MatchReason {
109    pub semantic_rank: Option<usize>,
110    /// The BM25 leg, called `lexical` in [`MatchSignals`].
111    pub keyword_rank: Option<usize>,
112    pub filename_rank: Option<usize>,
113    /// Normalized query tokens from matching BM25 groups, deduplicated in query
114    /// order. Never extracted from source text. Empty without a keyword leg.
115    pub matched_terms: Vec<String>,
116}
117
118/// One ranked match.
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
120pub struct SearchHit {
121    pub resource: ResourceId,
122    pub locator: ResourceLocator,
123    /// Relevance. In hybrid and lexical mode a rank fusion in `(0, 1]`, where 1
124    /// means first on every signal that found anything; in semantic mode the
125    /// cosine similarity. Only comparable within one search.
126    pub score: f32,
127    /// What found this hit. Use it to tell a meaning match from a name match.
128    pub signals: MatchSignals,
129    /// Rank provenance, additive to the existing raw signal scores.
130    #[serde(default)]
131    pub r#match: MatchReason,
132    pub metadata: ResourceMetadata,
133    /// Markdown heading path of the segment, when there is one.
134    pub heading: Option<String>,
135    /// A short excerpt of the matched segment (text resources only).
136    pub preview: Option<String>,
137    /// Index into [`SearchResults::queries`] of the query that scored best here.
138    /// Meaningful only when [`MatchSignals::semantic`] is present.
139    pub query_index: usize,
140}
141
142/// Stable classification independent of dependency error messages.
143#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
144#[serde(rename_all = "snake_case")]
145pub enum SkipCode {
146    #[default]
147    Other,
148    PdfParseFailed,
149    PdfParserPanicked,
150}
151
152/// Number of resources skipped for one typed reason.
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154pub struct SkippedCount {
155    pub code: SkipCode,
156    pub reason: String,
157    pub count: usize,
158}
159
160/// A resource that was found but could not be searched.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct SkippedResource {
163    #[serde(default)]
164    pub code: SkipCode,
165    pub resource: ResourceId,
166    #[serde(
167        default,
168        skip_serializing_if = "Option::is_none",
169        serialize_with = "crate::resource::serialize_optional_path"
170    )]
171    pub path: Option<PathBuf>,
172    pub reason: String,
173}
174
175/// A page omitted after a parse or preparation failure.
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177pub struct PageDiagnostic {
178    #[serde(default)]
179    pub code: SkipCode,
180    pub resource: ResourceId,
181    pub page: u32,
182    pub reason: String,
183}
184
185/// What happened while bringing the cache up to date.
186#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
187pub struct SyncStats {
188    /// Current-pass resource inventory (unavailable when synchronization is disabled).
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub progress: Option<crate::IndexProgress>,
191    pub resources_discovered: usize,
192    /// Reused from cache without re-embedding.
193    pub resources_unchanged: usize,
194    /// New or changed since last seen.
195    pub resources_updated: usize,
196    pub skipped: Vec<SkippedResource>,
197    #[serde(default)]
198    pub skipped_counts: Vec<SkippedCount>,
199    #[serde(default, skip_serializing_if = "Vec::is_empty")]
200    pub page_diagnostics: Vec<PageDiagnostic>,
201    /// Segments newly sent to the model.
202    pub segments_embedded: usize,
203    pub embed_ms: u64,
204}
205
206#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
207pub struct SearchStats {
208    pub sync: SyncStats,
209    pub segments_searched: usize,
210    pub total_ms: u64,
211}
212
213#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
214pub struct SearchResults {
215    /// Every phrasing that was searched, in the order given.
216    pub queries: Vec<String>,
217    pub mode: SearchMode,
218    pub hits: Vec<SearchHit>,
219    /// Present only when semantic retrieval was unavailable.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub degraded: Option<SearchDegradation>,
222    pub stats: SearchStats,
223    /// Counts of returned hits, after ranking and limit, keyed by locator kind.
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub kind_counts: Option<std::collections::BTreeMap<String, usize>>,
226}
227
228#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
229pub struct IndexReport {
230    pub sync: SyncStats,
231    /// Cache entries removed after the provider confirms in-scope deletion.
232    pub resources_removed: usize,
233    pub total_ms: u64,
234}
235
236/// Progress notifications for long operations.
237#[derive(Debug, Clone, PartialEq, Eq)]
238#[non_exhaustive]
239pub enum ProgressEvent {
240    /// Resource counts for this indexing pass; independent of batch-local segments.
241    IndexProgress {
242        progress: crate::IndexProgress,
243    },
244    /// Roots this pass's listing actually walked, resolved once before the walk,
245    /// in scope order. Sent just before `Discovered`, and only when the provider
246    /// can attribute its listing to them; every later count in the pass then
247    /// describes resources found under these roots.
248    Walked {
249        roots: Vec<ResourceId>,
250    },
251    Discovered {
252        resources: usize,
253    },
254    /// `total` segments need embedding; `done` so far.
255    Embedding {
256        done: usize,
257        total: usize,
258    },
259}
260
261/// Called without the cache mutex held. Resource-count and embedding events
262/// hold the indexing lease: callbacks may inspect the cache or search with
263/// `sync(false)`, but
264/// must not synchronously start another indexing pass or call `scrub_cache`
265/// on the same engine, since either would reacquire the indexing lease.
266pub type ProgressFn<'a> = &'a (dyn Fn(ProgressEvent) + Sync);
267
268fn report_walked(progress: Option<ProgressFn<'_>>, roots: Option<Vec<ResourceId>>) {
269    if let (Some(p), Some(roots)) = (progress, roots) {
270        p(ProgressEvent::Walked { roots });
271    }
272}
273
274pub struct SearchEngineBuilder {
275    provider: Arc<dyn ResourceProvider>,
276    extractors: Vec<Arc<dyn Extractor>>,
277    #[cfg(feature = "audio")]
278    audio_extractor: Option<crate::audio::AudioExtractor>,
279    embedder: Option<Arc<dyn Embedder>>,
280    cache: Option<Box<dyn EmbeddingCache>>,
281    segmenter: TextSegmenter,
282    batch_size: usize,
283    max_audio_duration_secs: u64,
284}
285
286impl SearchEngineBuilder {
287    /// Explicit audio extraction only; never registered with preview dispatch.
288    #[cfg(feature = "audio")]
289    pub fn audio_extractor(mut self, extractor: crate::audio::AudioExtractor) -> Self {
290        self.audio_extractor = Some(extractor);
291        self
292    }
293
294    /// Register an extractor before the built-in text fallback. Custom
295    /// extractors are tried in registration order; errors never fall through.
296    pub fn extractor(mut self, extractor: impl Extractor + 'static) -> Self {
297        self.extractors.push(Arc::new(extractor));
298        self
299    }
300
301    /// Resource access; defaults to the local filesystem provider.
302    pub fn provider(mut self, provider: impl ResourceProvider + 'static) -> Self {
303        self.provider = Arc::new(provider);
304        self
305    }
306
307    pub fn shared_provider(mut self, provider: Arc<dyn ResourceProvider>) -> Self {
308        self.provider = provider;
309        self
310    }
311
312    pub fn embedder(mut self, embedder: impl Embedder + 'static) -> Self {
313        self.embedder = Some(Arc::new(embedder));
314        self
315    }
316
317    pub fn shared_embedder(mut self, embedder: Arc<dyn Embedder>) -> Self {
318        self.embedder = Some(embedder);
319        self
320    }
321
322    /// Where vectors are kept. Defaults to an in-memory cache, i.e. ephemeral search.
323    pub fn cache(mut self, cache: impl EmbeddingCache + 'static) -> Self {
324        self.cache = Some(Box::new(cache));
325        self
326    }
327
328    pub fn segmenter(mut self, segmenter: TextSegmenter) -> Self {
329        self.segmenter = segmenter;
330        self
331    }
332
333    /// How many segments are sent to the embedder per call.
334    pub fn batch_size(mut self, n: usize) -> Self {
335        self.batch_size = n.max(1);
336        self
337    }
338
339    /// Inclusive canonical audio duration limit; independent of vector identity.
340    pub fn max_audio_duration_secs(mut self, seconds: u64) -> Self {
341        self.max_audio_duration_secs = seconds;
342        self
343    }
344
345    /// Describe vector identity without constructing an engine or opening a cache.
346    pub fn cache_profile(&self, model: crate::embedder::ModelIdentity) -> CacheProfile {
347        CacheProfile {
348            model,
349            segmenter_version: format!(
350                "{}+input-v{INPUT_FORMAT_VERSION}",
351                self.segmenter.version()
352            ),
353        }
354    }
355
356    pub fn build(mut self) -> Result<SearchEngine> {
357        crate::discovery::audio_byte_limit(self.max_audio_duration_secs)?;
358        #[cfg(feature = "pdf")]
359        self.extractors.push(Arc::new(crate::PdfTextExtractor));
360        self.extractors.push(Arc::new(TextExtractor));
361        let embedder = self
362            .embedder
363            .take()
364            .ok_or_else(|| Error::Config("SearchEngine needs an embedder".into()))?;
365        let profile = self.cache_profile(embedder.identity().clone());
366        let profile_id = profile.id();
367        let mut cache = self.cache.unwrap_or_else(|| Box::new(MemoryCache::new()));
368        cache.register_profile(&profile_id, &profile)?;
369        Ok(SearchEngine {
370            provider: self.provider,
371            extractors: self.extractors,
372            #[cfg(feature = "audio")]
373            audio_extractor: self.audio_extractor,
374            embedder,
375            lexical_only: false,
376            skipped_versions: Mutex::new(HashMap::new()),
377            cache: Arc::new(Mutex::new(cache)),
378            indexing: Arc::new(Mutex::new(())),
379            pdf_diagnostics: Mutex::new(HashMap::new()),
380            scrubber_started: AtomicBool::new(false),
381            #[cfg(all(feature = "test-support", debug_assertions))]
382            lease_observer: Mutex::new(None),
383            scrubber_stop: Arc::new(ScrubStop::default()),
384            segmenter: self.segmenter,
385            profile,
386            profile_id,
387            batch_size: self.batch_size,
388            max_audio_duration_secs: self.max_audio_duration_secs,
389        })
390    }
391}
392
393/// Search over provider resources; local files are the default.
394///
395/// Indexing passes on one engine are serialized through resource commits and
396/// orphan pruning. Separate engines, including two processes sharing one SQLite
397/// cache, are not covered by this engine-local lease. Cached searches do not
398/// acquire it.
399pub struct SearchEngine {
400    provider: Arc<dyn ResourceProvider>,
401    extractors: Vec<Arc<dyn Extractor>>,
402    #[cfg(feature = "audio")]
403    audio_extractor: Option<crate::audio::AudioExtractor>,
404    embedder: Arc<dyn Embedder>,
405    lexical_only: bool,
406    /// Completed skips for this engine lifetime, keyed by source version and
407    /// preparation descriptor. A restart retries them during the initial index;
408    /// no failure state or source content is added to the semantic cache schema.
409    skipped_versions: Mutex<HashMap<ResourceId, (ResourceVersion, String)>>,
410    cache: Arc<Mutex<Box<dyn EmbeddingCache>>>,
411    /// Acquire before `cache`, never while holding it. Covers pending vectors
412    /// until their resource references are committed and pruning is complete.
413    indexing: Arc<Mutex<()>>,
414    pdf_diagnostics: Mutex<HashMap<ResourceId, PdfDiagnostics>>,
415    scrubber_started: AtomicBool,
416    #[cfg(all(feature = "test-support", debug_assertions))]
417    lease_observer: Mutex<Option<LeaseObserver>>,
418    scrubber_stop: Arc<ScrubStop>,
419    segmenter: TextSegmenter,
420    profile: CacheProfile,
421    profile_id: String,
422    batch_size: usize,
423    max_audio_duration_secs: u64,
424}
425
426/// Test-only observations at the indexing lease boundary.
427#[cfg(all(feature = "test-support", debug_assertions))]
428#[derive(Clone, Copy, Debug, PartialEq, Eq)]
429pub enum LeaseEvent {
430    /// A real try_lock failed while another pass held the lease.
431    Contended,
432    Acquired,
433}
434
435#[cfg(all(feature = "test-support", debug_assertions))]
436type LeaseObserver = Arc<dyn Fn(LeaseEvent) + Send + Sync>;
437// Shared only by the engine and worker; contains no engine/cache references.
438#[derive(Default)]
439struct ScrubStop {
440    state: Mutex<ScrubStopState>,
441    wake: Condvar,
442}
443
444#[derive(Default)]
445struct ScrubStopState {
446    stopped: bool,
447    #[cfg(test)]
448    waiting: bool,
449}
450
451impl ScrubStop {
452    fn wait(&self, delay: std::time::Duration) -> bool {
453        let Ok(state) = self.state.lock() else {
454            return false;
455        };
456        #[cfg(test)]
457        let state = {
458            let mut state = state;
459            state.waiting = true;
460            self.wake.notify_all();
461            state
462        };
463        let Ok((state, _)) = self.wake.wait_timeout_while(state, delay, |s| !s.stopped) else {
464            return false;
465        };
466        #[cfg(test)]
467        let state = {
468            let mut state = state;
469            state.waiting = false;
470            state
471        };
472        !state.stopped
473    }
474
475    fn is_stopped(&self) -> bool {
476        self.state.lock().map_or(true, |state| state.stopped)
477    }
478
479    fn stop(&self) {
480        let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
481        state.stopped = true;
482        self.wake.notify_all();
483    }
484}
485
486impl Drop for SearchEngine {
487    fn drop(&mut self) {
488        self.scrubber_stop.stop();
489    }
490}
491
492impl SearchEngine {
493    /// Install a test observer. It must not start indexing on this engine.
494    #[cfg(all(feature = "test-support", debug_assertions))]
495    pub fn observe_indexing_lease(&self, observer: LeaseObserver) {
496        *self
497            .lease_observer
498            .lock()
499            .unwrap_or_else(|e| e.into_inner()) = Some(observer);
500    }
501
502    #[cfg(all(feature = "test-support", debug_assertions))]
503    fn observe_lease(&self, event: LeaseEvent) {
504        let observer = self
505            .lease_observer
506            .lock()
507            .unwrap_or_else(|e| e.into_inner())
508            .clone();
509        if let Some(observer) = observer {
510            observer(event);
511        }
512    }
513
514    fn acquire_indexing_lease(&self) -> Result<MutexGuard<'_, ()>> {
515        // One mutex path in every build; only observations are test-specific.
516        let guard = match self.indexing.try_lock() {
517            Ok(guard) => guard,
518            Err(std::sync::TryLockError::WouldBlock) => {
519                #[cfg(all(feature = "test-support", debug_assertions))]
520                self.observe_lease(LeaseEvent::Contended);
521                self.indexing
522                    .lock()
523                    .map_err(|_| Error::Config("indexing lock poisoned".into()))?
524            }
525            Err(std::sync::TryLockError::Poisoned(_)) => {
526                return Err(Error::Config("indexing lock poisoned".into()));
527            }
528        };
529        #[cfg(all(feature = "test-support", debug_assertions))]
530        self.observe_lease(LeaseEvent::Acquired);
531        Ok(guard)
532    }
533
534    /// Search text and PDF text without model weights or stored vectors.
535    /// Uses an isolated in-memory keyword index; never downloads a model.
536    pub fn lexical() -> Result<Self> {
537        Self::lexical_with_provider(Arc::new(FsProvider))
538    }
539
540    /// Model-free keyword search using the same access policy as the caller.
541    pub fn lexical_with_provider(provider: Arc<dyn ResourceProvider>) -> Result<Self> {
542        let mut engine = Self::builder()
543            .shared_provider(provider)
544            .embedder(crate::lexical_engine::LexicalIdentity::default())
545            .build()?;
546        engine.lexical_only = true;
547        Ok(engine)
548    }
549
550    fn lexical_fallback(&self) -> Result<Self> {
551        let mut engine = Self::lexical_with_provider(self.provider.clone())?;
552        engine.segmenter = self.segmenter.clone();
553        engine.max_audio_duration_secs = self.max_audio_duration_secs;
554        Ok(engine)
555    }
556
557    pub fn builder() -> SearchEngineBuilder {
558        SearchEngineBuilder {
559            provider: Arc::new(FsProvider),
560            extractors: Vec::new(),
561            #[cfg(feature = "audio")]
562            audio_extractor: None,
563            embedder: None,
564            cache: None,
565            segmenter: TextSegmenter::default(),
566            batch_size: 16,
567            max_audio_duration_secs: 7200,
568        }
569    }
570
571    /// Extract readable evidence from a hit, verifying its full-resource hash
572    /// before any extractor receives bytes. Only explicit audio extraction may run STT.
573    pub fn extract(&self, hit: &SearchHit) -> Result<ExtractedContent> {
574        #[cfg(feature = "audio")]
575        if hit.metadata.modality == crate::Modality::Audio {
576            if let Some(extractor) = &self.audio_extractor {
577                return extractor.extract(
578                    self.provider.as_ref(),
579                    &hit.resource,
580                    &hit.locator,
581                    &hit.metadata,
582                    self.max_audio_duration_secs,
583                );
584            }
585            self.extract_audio_samples(hit)?;
586            return Err(unsupported(&hit.resource, &hit.locator, &hit.metadata));
587        }
588        let extractor = self
589            .extractors
590            .iter()
591            .find(|e| e.supports(&hit.resource, &hit.locator, &hit.metadata))
592            .ok_or_else(|| unsupported(&hit.resource, &hit.locator, &hit.metadata))?;
593        extract_resource(
594            self.provider.as_ref(),
595            extractor.as_ref(),
596            &hit.resource,
597            &hit.locator,
598            &hit.metadata,
599        )
600    }
601
602    /// Verified selected audio samples, without STT. At most 30 seconds per slice.
603    #[cfg(feature = "audio")]
604    pub fn extract_audio_samples(&self, hit: &SearchHit) -> Result<Vec<f32>> {
605        crate::audio::extract_samples(
606            self.provider.as_ref(),
607            &hit.resource,
608            &hit.locator,
609            &hit.metadata,
610            self.max_audio_duration_secs,
611        )
612    }
613
614    /// Sync and cached-only search must agree on the prepared audio identity.
615    fn audio_descriptor_hash(&self, resource: &ProvidedResource) -> Result<String> {
616        #[cfg(feature = "audio")]
617        {
618            Ok(input_hash(&[
619                &resource.fingerprint(),
620                crate::audio::PREPARATION,
621                self.embedder.input_identity(crate::Modality::Audio)?,
622            ]))
623        }
624        #[cfg(not(feature = "audio"))]
625        {
626            Ok(resource.fingerprint())
627        }
628    }
629
630    fn audio_eligible(&self, id: &ResourceId) -> Result<bool> {
631        let limit = self.max_audio_duration_secs * 1000;
632        self.with_cache(|c| {
633            let Some(r) = c.resource(&self.profile_id, id)? else {return Ok(false);};
634            Ok(r.metadata.size_bytes <= crate::discovery::audio_byte_limit(self.max_audio_duration_secs)?
635                && !r.segments.is_empty()
636                && r.segments.iter().all(|s| matches!(s.locator, ResourceLocator::Time {start_ms,end_ms} if start_ms<end_ms && end_ms<=limit)))
637        })
638    }
639
640    pub fn profile(&self) -> &CacheProfile {
641        &self.profile
642    }
643
644    pub fn profile_id(&self) -> &str {
645        &self.profile_id
646    }
647
648    pub fn embedder(&self) -> &dyn Embedder {
649        self.embedder.as_ref()
650    }
651
652    /// Start a search request. Add more phrasings of the same need with
653    /// [`SearchRequest::query`].
654    pub fn search<'e>(&'e self, query: impl Into<String>) -> SearchRequest<'e> {
655        SearchRequest {
656            engine: self,
657            queries: vec![query.into()],
658            paths: Vec::new(),
659            roots: Vec::new(),
660            limit: 10,
661            min_score: None,
662            discovery: DiscoveryOptions::default(),
663            preview_chars: Some(240),
664            progress: None,
665            sync: true,
666            mode: SearchMode::default(),
667            image_scan: ImageScan::Exact,
668            lexical_weight: None,
669            lexical_fallback: false,
670            kind_counts: false,
671            listed: None,
672        }
673    }
674
675    /// Start an explicit indexing request (cache warming).
676    pub fn index<'e>(&'e self) -> IndexRequest<'e> {
677        IndexRequest {
678            engine: self,
679            paths: Vec::new(),
680            roots: Vec::new(),
681            discovery: DiscoveryOptions::default(),
682            progress: None,
683        }
684    }
685
686    /// Run a closure with exclusive access to the cache.
687    /// The closure must not start indexing or call `scrub_cache`: both acquire
688    /// the indexing lease before the cache mutex and would self-deadlock.
689    pub fn with_cache<T>(&self, f: impl FnOnce(&mut dyn EmbeddingCache) -> Result<T>) -> Result<T> {
690        let mut guard = self
691            .cache
692            .lock()
693            .map_err(|_| Error::Config("cache lock poisoned".into()))?;
694        f(guard.as_mut())
695    }
696
697    /// Explicit privacy scrub. Index/search requests never invoke this themselves.
698    ///
699    /// Do not call this from `with_cache` closures or indexing progress callbacks
700    /// on this engine: reacquiring the cache mutex or indexing lease would deadlock.
701    pub fn scrub_cache(&self) -> Result<crate::cache::CacheCleanup> {
702        let _lease = self
703            .indexing
704            .lock()
705            .map_err(|_| Error::Config("indexing lock poisoned".into()))?;
706        self.with_cache(|cache| cache.scrub())
707    }
708
709    /// Start one idle-time worker for watcher/daemon/MCP use. It holds weak
710    /// references and never runs in a request. On engine drop it stops after its
711    /// current iteration and wakes any idle deadline wait immediately. Drop does
712    /// not cancel or join an in-flight scrub.
713    pub fn start_cache_scrubber(&self) {
714        self.spawn_cache_scrubber();
715    }
716
717    fn spawn_cache_scrubber(&self) -> Option<std::thread::JoinHandle<()>> {
718        let cache = Arc::downgrade(&self.cache);
719        let stop = self.scrubber_stop.clone();
720        self.start_cache_scrubber_with(
721            move || {
722                let delay = {
723                    let Some(cache) = cache.upgrade() else {
724                        return false;
725                    };
726                    let Ok(cache) = cache.lock() else {
727                        return false;
728                    };
729                    cache.scrub_delay(std::time::Duration::from_secs(60))
730                }; // Release strong references and the mutex before sleeping.
731                stop.wait(delay)
732            },
733            |cache| match cache.scrub_if_idle(std::time::Duration::from_secs(60)) {
734                Ok(Some(report)) if report.pending => eprintln!(
735                    "warning: cache scrub pending; run seyk cache scrub once other readers close"
736                ),
737                Err(error) => eprintln!("warning: cache scrub pending: {error}"),
738                _ => {}
739            },
740        )
741    }
742
743    // Injectable ticks and work make worker ownership/locking tests deterministic.
744    // Production sleeps to the quiet/retry deadline (60 seconds when idle).
745    fn start_cache_scrubber_with(
746        &self,
747        mut tick: impl FnMut() -> bool + Send + 'static,
748        mut scrub: impl FnMut(&mut dyn EmbeddingCache) + Send + 'static,
749    ) -> Option<std::thread::JoinHandle<()>> {
750        if self.scrubber_started.swap(true, Ordering::Relaxed) {
751            return None;
752        }
753        let cache = Arc::downgrade(&self.cache);
754        let indexing = Arc::downgrade(&self.indexing);
755        let stop = self.scrubber_stop.clone();
756        Some(std::thread::spawn(move || {
757            while !stop.is_stopped() && tick() {
758                if stop.is_stopped() {
759                    break;
760                }
761                let (Some(cache), Some(indexing)) = (cache.upgrade(), indexing.upgrade()) else {
762                    break;
763                };
764                // Do not scrub in the middle of an indexing pass, even a slow embed batch.
765                let Ok(_lease) = indexing.try_lock() else {
766                    continue;
767                };
768                let Ok(mut cache) = cache.lock() else { break };
769                scrub(cache.as_mut());
770            }
771        }))
772    }
773
774    /// Make sure every resource has up-to-date vectors in the cache.
775    /// The caller keeps the indexing lease through any subsequent deletions.
776    fn sync(
777        &self,
778        _indexing: &MutexGuard<'_, ()>,
779        resources: &[ProvidedResource],
780        progress: Option<ProgressFn>,
781    ) -> Result<SyncStats> {
782        let _phase = seyk_embed::diagnostics::Phase::new("index_pass");
783        let stats = self.sync_resources(resources, progress)?;
784        let skipped: HashSet<_> = stats.skipped.iter().map(|s| &s.resource).collect();
785        // A descriptor can depend on model identity (audio). If identity itself
786        // failed, there is no stable preparation version to remember: the normal
787        // eligibility check must be allowed to retry when weights are restored.
788        let outcomes: Vec<_> = resources
789            .iter()
790            .map(|r| {
791                let outcome = if skipped.contains(&r.id) {
792                    self.preparation_descriptor(r)
793                        .ok()
794                        .map(|descriptor| (r.version.clone(), descriptor))
795                } else {
796                    None
797                };
798                (r.id.clone(), outcome)
799            })
800            .collect();
801        let mut remembered = self
802            .skipped_versions
803            .lock()
804            .map_err(|_| Error::Config("skip outcomes lock poisoned".into()))?;
805        for (id, outcome) in outcomes {
806            if let Some(outcome) = outcome {
807                remembered.insert(id, outcome);
808            } else {
809                remembered.remove(&id);
810            }
811        }
812        Ok(stats)
813    }
814
815    fn skipped_at_current_version(&self, resource: &ProvidedResource) -> Result<bool> {
816        let outcome = self
817            .skipped_versions
818            .lock()
819            .map_err(|_| Error::Config("skip outcomes lock poisoned".into()))?
820            .get(&resource.id)
821            .cloned();
822        Ok(outcome.is_some_and(|(version, descriptor)| {
823            version.matches(&resource.version)
824                && self
825                    .preparation_descriptor(resource)
826                    .is_ok_and(|current| current == descriptor)
827        }))
828    }
829
830    fn preparation_descriptor(&self, r: &ProvidedResource) -> Result<String> {
831        if r.kind == ResourceKind::Audio {
832            return self.audio_descriptor_hash(r);
833        }
834        #[cfg(feature = "pdf")]
835        if r.kind == ResourceKind::Pdf {
836            return Ok(input_hash(&[
837                &r.fingerprint(),
838                crate::pdf::TEXT_VERSION,
839                crate::pdf::PREPARATION_VERSION,
840                &self.pdf_capabilities(),
841            ]));
842        }
843        Ok(r.fingerprint())
844    }
845
846    fn sync_resources(
847        &self,
848        resources: &[ProvidedResource],
849        progress: Option<ProgressFn>,
850    ) -> Result<SyncStats> {
851        let mut stats = SyncStats {
852            resources_discovered: resources.len(),
853            ..Default::default()
854        };
855        let pid = self.profile_id.as_str();
856        let known = self.with_cache(|c| c.resource_index(pid))?;
857
858        let telemetry = crate::progress::PassProgress::new(resources, progress);
859        let mut pending: Vec<Pending> = Vec::new();
860        let mut reported_skips = 0;
861        for r in resources {
862            for skipped in &stats.skipped[reported_skips..] {
863                telemetry.complete(&skipped.resource, Completion::Skipped);
864            }
865            reported_skips = stats.skipped.len();
866            let modality = r.kind.modality();
867            if !self.supports_kind(r.kind) {
868                stats.skipped.push(skip(
869                    &r.id,
870                    if r.kind == ResourceKind::Pdf && !cfg!(feature = "pdf") {
871                        "PDF support is not compiled in (feature `pdf`)".into()
872                    } else if r.kind == ResourceKind::Audio && !cfg!(feature = "audio") {
873                        "audio support is not compiled in (feature `audio`)".into()
874                    } else {
875                        self.embedder
876                            .unavailable_reason(modality)
877                            .unwrap_or_else(|| {
878                                format!("{modality} search is not supported by this embedder")
879                            })
880                    },
881                ));
882                continue;
883            }
884            let descriptor_hash = match self.preparation_descriptor(r) {
885                Ok(hash) => hash,
886                Err(error) => {
887                    stats.skipped.push(skip(&r.id, error.to_string()));
888                    continue;
889                }
890            };
891            let cached = known.get(&r.id);
892            // Listing already supplies a freshness token; only stat again before a read.
893            if cached.is_some_and(|m| {
894                m.version.matches(&r.version)
895                    && m.descriptor_hash.as_ref() == Some(&descriptor_hash)
896            }) {
897                if r.kind == ResourceKind::Audio && !self.audio_eligible(&r.id)? {
898                    stats.skipped.push(skip(
899                        &r.id,
900                        "audio exceeds configured duration limit or is not validated",
901                    ));
902                } else {
903                    stats.resources_unchanged += 1;
904                    self.replay_pdf_diagnostics(&r.id, &descriptor_hash, &mut stats)?;
905                    telemetry.complete(
906                        &r.id,
907                        if stats.skipped.len() == reported_skips {
908                            Completion::Reused
909                        } else {
910                            Completion::Skipped
911                        },
912                    );
913                }
914                continue;
915            }
916            let version = match self.provider.version(&r.id) {
917                Ok(v) => v,
918                Err(e) => {
919                    stats.skipped.push(skip(&r.id, e.to_string()));
920                    continue;
921                }
922            };
923            // A list/stat race must not attach old metadata to newly read bytes.
924            if r.version.is_known() && !r.version.matches(&version) {
925                stats
926                    .skipped
927                    .push(skip(&r.id, "resource changed after discovery"));
928                continue;
929            }
930            #[cfg(feature = "audio")]
931            if r.kind == ResourceKind::Audio {
932                let source = self.provider.open(&r.id).and_then(|source| {
933                    crate::audio::VerifiedReader::new(
934                        source,
935                        &r.id,
936                        crate::discovery::audio_byte_limit(self.max_audio_duration_secs)?,
937                    )
938                });
939                let mut source = match source {
940                    Ok(s) => s,
941                    Err(e) => {
942                        stats.skipped.push(skip(&r.id, e.to_string()));
943                        continue;
944                    }
945                };
946                let after = match self.provider.version(&r.id) {
947                    Ok(v) => v,
948                    Err(e) => {
949                        stats.skipped.push(skip(&r.id, e.to_string()));
950                        continue;
951                    }
952                };
953                if version.is_known() && !version.matches(&after) {
954                    stats
955                        .skipped
956                        .push(skip(&r.id, "resource changed while reading"));
957                    continue;
958                }
959                let content_hash = source.content_hash().to_owned();
960                if let Some(m) = cached.filter(|m| {
961                    m.metadata.content_hash == content_hash
962                        && m.descriptor_hash.as_ref() == Some(&descriptor_hash)
963                }) {
964                    if !self.audio_eligible(&r.id)? {
965                        stats.skipped.push(skip(
966                            &r.id,
967                            "audio exceeds configured duration limit or is not validated",
968                        ));
969                        continue;
970                    }
971                    let mut metadata = m.metadata.clone();
972                    metadata.size_bytes = source.len();
973                    metadata.modified_ns = r.modified_ns;
974                    self.with_cache(|c| c.touch_resource(pid, &r.id, &metadata, &version))?;
975                    stats.resources_unchanged += 1;
976                    telemetry.complete(&r.id, Completion::Reused);
977                    continue;
978                }
979                use std::io::{Read, Seek};
980                let mut header = [0; 12];
981                let header_result = source.read(&mut header).and_then(|n| {
982                    source.rewind()?;
983                    Ok(n)
984                });
985                let n = match header_result {
986                    Ok(n) => n,
987                    Err(_) => {
988                        stats
989                            .skipped
990                            .push(skip(&r.id, "audio changed while reading"));
991                        continue;
992                    }
993                };
994                let target = Pending {
995                    resource: r.id.clone(),
996                    metadata: ResourceMetadata {
997                        media_type: Some(crate::audio::media_type(&header[..n]).into()),
998                        modality,
999                        size_bytes: source.len(),
1000                        modified_ns: r.modified_ns,
1001                        content_hash,
1002                    },
1003                    version,
1004                    descriptor_hash,
1005                    jobs: Vec::new(),
1006                    page_diagnostics: Vec::new(),
1007                };
1008                if let Some(reason) = self.sync_audio(r, source, target, &mut stats, progress)? {
1009                    stats.skipped.push(skip(&r.id, reason));
1010                } else {
1011                    telemetry.complete(&r.id, Completion::NewlyIndexed);
1012                }
1013                continue;
1014            }
1015            let bytes = match self.provider.read(&r.id) {
1016                Ok(b) => b,
1017                Err(e) => {
1018                    stats.skipped.push(skip(&r.id, e.to_string()));
1019                    continue;
1020                }
1021            };
1022            let after = match self.provider.version(&r.id) {
1023                Ok(v) => v,
1024                Err(e) => {
1025                    stats.skipped.push(skip(&r.id, e.to_string()));
1026                    continue;
1027                }
1028            };
1029            if version.is_known() && !version.matches(&after) {
1030                stats
1031                    .skipped
1032                    .push(skip(&r.id, "resource changed while reading"));
1033                continue;
1034            }
1035            let content_hash = blake3::hash(&bytes).to_hex().to_string();
1036            if let Some(m) = cached.filter(|m| {
1037                m.metadata.content_hash == content_hash
1038                    && m.descriptor_hash.as_ref() == Some(&descriptor_hash)
1039            }) {
1040                let mut metadata = m.metadata.clone();
1041                metadata.size_bytes = bytes.len() as u64;
1042                metadata.modified_ns = r.modified_ns;
1043                self.with_cache(|c| c.touch_resource(pid, &r.id, &metadata, &version))?;
1044                stats.resources_unchanged += 1;
1045                self.replay_pdf_diagnostics(&r.id, &descriptor_hash, &mut stats)?;
1046                telemetry.complete(
1047                    &r.id,
1048                    if stats.skipped.len() == reported_skips {
1049                        Completion::Reused
1050                    } else {
1051                        Completion::Skipped
1052                    },
1053                );
1054                continue;
1055            }
1056            let metadata = ResourceMetadata {
1057                media_type: match r.kind {
1058                    ResourceKind::Pdf => Some("application/pdf".into()),
1059                    #[cfg(feature = "audio")]
1060                    ResourceKind::Audio => Some(crate::audio::media_type(&bytes).into()),
1061                    _ => None,
1062                },
1063                modality,
1064                size_bytes: bytes.len() as u64,
1065                modified_ns: r.modified_ns,
1066                content_hash,
1067            };
1068            #[cfg(feature = "pdf")]
1069            if r.kind == ResourceKind::Pdf {
1070                let target = Pending {
1071                    resource: r.id.clone(),
1072                    metadata,
1073                    version,
1074                    descriptor_hash,
1075                    jobs: Vec::new(),
1076                    page_diagnostics: Vec::new(),
1077                };
1078                if let Some(entry) = self.sync_pdf(r, bytes, target, &mut stats, progress)? {
1079                    stats.skipped.push(entry);
1080                } else {
1081                    telemetry.complete(&r.id, Completion::NewlyIndexed);
1082                }
1083                continue;
1084            }
1085            let prepared = self.prepare(r, bytes);
1086            match prepared {
1087                Ok((jobs, page_diagnostics)) => pending.push(Pending {
1088                    resource: r.id.clone(),
1089                    metadata,
1090                    version,
1091                    descriptor_hash,
1092                    jobs,
1093                    page_diagnostics,
1094                }),
1095                Err(reason) => stats.skipped.push(skip(&r.id, reason)),
1096            }
1097        }
1098
1099        for skipped in &stats.skipped[reported_skips..] {
1100            telemetry.complete(&skipped.resource, Completion::Skipped);
1101        }
1102
1103        if self.lexical_only {
1104            self.write_resources(
1105                &pending,
1106                0,
1107                pending.len(),
1108                &HashMap::new(),
1109                &mut stats,
1110                &telemetry,
1111            )?;
1112            stats.progress = Some(telemetry.snapshot());
1113            stats.skipped_counts = skipped_counts(&stats.skipped);
1114            seyk_embed::diagnostics::counts(
1115                "index_work",
1116                &[("missing_inputs", 0), ("resources", pending.len())],
1117            );
1118            return Ok(stats);
1119        }
1120
1121        // Only inputs the cache has never seen go to the model.
1122        let all_hashes: Vec<String> = pending
1123            .iter()
1124            .flat_map(|p| p.jobs.iter().map(|j| j.segment.input_hash.clone()))
1125            .collect();
1126        let already = self.with_cache(|c| c.known_inputs(pid, &all_hashes))?;
1127        let mut queued: HashSet<&str> = HashSet::new();
1128        let mut work: Vec<(usize, &Job)> = Vec::new();
1129        for (ri, p) in pending.iter().enumerate() {
1130            for j in &p.jobs {
1131                if !already.contains(&j.segment.input_hash)
1132                    && queued.insert(j.segment.input_hash.as_str())
1133                {
1134                    work.push((ri, j));
1135                }
1136            }
1137        }
1138
1139        seyk_embed::diagnostics::counts(
1140            "index_work",
1141            &[("missing_inputs", work.len()), ("resources", pending.len())],
1142        );
1143        let total = work.len();
1144        let started = Instant::now();
1145        // Keyed by input, not resource: a deduplicated input belongs to every
1146        // resource that contains it, and all of them are incomplete if it fails.
1147        let mut failed: HashMap<String, String> = HashMap::new();
1148        let mut done = 0;
1149        let mut written = 0;
1150        if let Some(p) = progress {
1151            p(ProgressEvent::Embedding { done: 0, total });
1152        }
1153        for batch in work.chunks(self.batch_size) {
1154            let results = self.embed_batch(batch);
1155            let mut store = Vec::with_capacity(batch.len());
1156            for ((_, job), res) in batch.iter().zip(results) {
1157                match res {
1158                    Ok(v) => store.push((job.segment.input_hash.clone(), v)),
1159                    Err(e) => {
1160                        failed
1161                            .entry(job.segment.input_hash.clone())
1162                            .or_insert_with(|| e.to_string());
1163                    }
1164                }
1165            }
1166            self.with_cache(|c| c.put_embeddings(pid, &store))?;
1167            done += batch.len();
1168            stats.segments_embedded += store.len();
1169            if let Some(p) = progress {
1170                p(ProgressEvent::Embedding { done, total });
1171            }
1172            // Persist every resource whose segments are now all embedded, so an
1173            // interrupted run keeps its progress.
1174            let ready_upto = batch.last().map(|(ri, _)| *ri).unwrap_or(0);
1175            written = self.write_resources(
1176                &pending, written, ready_upto, &failed, &mut stats, &telemetry,
1177            )?;
1178        }
1179        self.write_resources(
1180            &pending,
1181            written,
1182            pending.len(),
1183            &failed,
1184            &mut stats,
1185            &telemetry,
1186        )?;
1187        stats.embed_ms += started.elapsed().as_millis() as u64;
1188        if stats.resources_updated > 0 {
1189            self.with_cache(|c| c.prune_orphans(pid))?;
1190        }
1191        for skipped in &stats.skipped {
1192            telemetry.complete(&skipped.resource, Completion::Skipped);
1193        }
1194        stats.progress = Some(telemetry.snapshot());
1195        stats.skipped_counts = skipped_counts(&stats.skipped);
1196        Ok(stats)
1197    }
1198
1199    /// Write `pending[from..upto]` to the cache. Returns the new `written` cursor.
1200    fn write_resources(
1201        &self,
1202        pending: &[Pending],
1203        from: usize,
1204        upto: usize,
1205        failed: &HashMap<String, String>,
1206        stats: &mut SyncStats,
1207        telemetry: &crate::progress::PassProgress<'_>,
1208    ) -> Result<usize> {
1209        let pid = self.profile_id.as_str();
1210        for p in pending.iter().take(upto).skip(from) {
1211            if let Some(reason) = p
1212                .jobs
1213                .iter()
1214                .find_map(|j| failed.get(&j.segment.input_hash))
1215            {
1216                stats.skipped.push(skip(&p.resource, reason.clone()));
1217                telemetry.complete(&p.resource, Completion::Skipped);
1218                continue;
1219            }
1220            let resource = ResourceWrite {
1221                metadata: &p.metadata,
1222                version: &p.version,
1223                descriptor_hash: Some(&p.descriptor_hash),
1224                segments: p
1225                    .jobs
1226                    .iter()
1227                    .map(|j| SegmentWrite {
1228                        segment: &j.segment,
1229                        lexical_body: &j.lexical_body,
1230                    })
1231                    .collect(),
1232            };
1233            self.with_cache(|c| c.put_resource(pid, &p.resource, &resource))?;
1234            stats.resources_updated += 1;
1235            telemetry.complete(&p.resource, Completion::NewlyIndexed);
1236            stats.page_diagnostics.extend(p.page_diagnostics.clone());
1237        }
1238        Ok(upto.max(from))
1239    }
1240
1241    /// Validate backend output before associating vectors with requests or caching them.
1242    /// Preserve the backend's order and exact vector bytes without normalization.
1243    fn checked_embed_batch(&self, requests: &[EmbeddingRequest<'_>]) -> Result<Vec<Embedding>> {
1244        let vectors = self.embedder.embed_batch(requests)?;
1245        if vectors.len() != requests.len() {
1246            return Err(Error::Embedding(
1247                "embedder returned the wrong number of vectors".into(),
1248            ));
1249        }
1250        let dimension = self.embedder.dimension().get();
1251        if let Some(vector) = vectors.iter().find(|v| v.dim() != dimension) {
1252            return Err(Error::DimensionMismatch {
1253                expected: dimension,
1254                actual: vector.dim(),
1255            });
1256        }
1257        Ok(vectors)
1258    }
1259
1260    /// Embed a batch; on failure retry one by one so a single bad input only skips its resource.
1261    fn embed_batch(&self, batch: &[(usize, &Job)]) -> Vec<Result<Vec<f32>>> {
1262        let inputs: Vec<EmbeddingRequest> = batch
1263            .iter()
1264            .map(|(_, j)| EmbeddingRequest {
1265                input: j.input.as_input(),
1266                task: EmbeddingTask::SearchDocument,
1267            })
1268            .collect();
1269        match self.checked_embed_batch(&inputs) {
1270            Ok(vs) => vs.into_iter().map(|v| Ok(v.into_vec())).collect(),
1271            Err(_) if inputs.len() > 1 => inputs
1272                .iter()
1273                .map(|i| {
1274                    self.checked_embed_batch(std::slice::from_ref(i))
1275                        .map(|mut v| v.remove(0).into_vec())
1276                })
1277                .collect(),
1278            Err(e) => vec![Err(e)],
1279        }
1280    }
1281
1282    fn supports_kind(&self, kind: ResourceKind) -> bool {
1283        match kind {
1284            ResourceKind::Pdf => cfg!(feature = "pdf"),
1285            ResourceKind::Audio => {
1286                cfg!(feature = "audio") && self.embedder.supports(crate::Modality::Audio)
1287            }
1288            _ => self.embedder.supports(kind.modality()),
1289        }
1290    }
1291
1292    fn supports_locator(&self, metadata: &ResourceMetadata, locator: &ResourceLocator) -> bool {
1293        if metadata.modality == crate::Modality::Audio {
1294            return cfg!(feature = "audio") && self.embedder.supports(crate::Modality::Audio);
1295        }
1296        if metadata.media_type.as_deref() != Some("application/pdf") {
1297            return true;
1298        }
1299        match locator {
1300            ResourceLocator::Page { .. } => {
1301                cfg!(feature = "pdf-render") && self.embedder.supports(crate::Modality::Image)
1302            }
1303            ResourceLocator::PageText { .. } => {
1304                cfg!(feature = "pdf") && self.embedder.supports(crate::Modality::Text)
1305            }
1306            _ => false,
1307        }
1308    }
1309
1310    #[cfg(feature = "pdf")]
1311    fn pdf_capabilities(&self) -> String {
1312        #[cfg(feature = "pdf-render")]
1313        let renderer = crate::pdf::RENDER_VERSION;
1314        #[cfg(not(feature = "pdf-render"))]
1315        let renderer = "no-render";
1316        format!(
1317            "pdf-cap-v1;text={};image={};{renderer}",
1318            self.embedder.supports(crate::Modality::Text),
1319            self.pdf_images()
1320        )
1321    }
1322
1323    #[cfg(feature = "pdf")]
1324    fn pdf_images(&self) -> bool {
1325        cfg!(feature = "pdf-render") && self.embedder.supports(crate::Modality::Image)
1326    }
1327
1328    fn replay_pdf_diagnostics(
1329        &self,
1330        resource: &ResourceId,
1331        descriptor: &str,
1332        stats: &mut SyncStats,
1333    ) -> Result<()> {
1334        let diagnostics = self
1335            .pdf_diagnostics
1336            .lock()
1337            .map_err(|_| Error::Config("PDF diagnostics lock poisoned".into()))?;
1338        if let Some(record) = diagnostics
1339            .get(resource)
1340            .filter(|record| record.descriptor_hash == descriptor)
1341        {
1342            stats.page_diagnostics.extend(record.pages.clone());
1343            if let Some(skipped) = &record.skipped {
1344                stats.skipped.push(skipped.clone());
1345            }
1346        }
1347        Ok(())
1348    }
1349
1350    #[cfg(feature = "pdf")]
1351    fn remember_pdf_diagnostics(
1352        &self,
1353        target: &Pending,
1354        skipped: Option<SkippedResource>,
1355    ) -> Result<()> {
1356        let mut diagnostics = self
1357            .pdf_diagnostics
1358            .lock()
1359            .map_err(|_| Error::Config("PDF diagnostics lock poisoned".into()))?;
1360        if !target.page_diagnostics.is_empty() || skipped.is_some() {
1361            diagnostics.insert(
1362                target.resource.clone(),
1363                PdfDiagnostics {
1364                    descriptor_hash: target.descriptor_hash.clone(),
1365                    pages: target.page_diagnostics.clone(),
1366                    skipped,
1367                },
1368            );
1369        } else {
1370            diagnostics.remove(&target.resource);
1371        }
1372        Ok(())
1373    }
1374
1375    #[cfg(feature = "pdf")]
1376    fn sync_pdf(
1377        &self,
1378        r: &ProvidedResource,
1379        bytes: Vec<u8>,
1380        mut target: Pending,
1381        stats: &mut SyncStats,
1382        progress: Option<ProgressFn>,
1383    ) -> Result<Option<SkippedResource>> {
1384        let pages = match crate::pdf::read_pages(&bytes, &r.id) {
1385            Ok(pages) => pages,
1386            Err(error) => {
1387                let entry = pdf_skip(&r.id, error);
1388                self.commit_media_resource(&target, &[], stats)?;
1389                self.remember_pdf_diagnostics(&target, Some(entry.clone()))?;
1390                return Ok(Some(entry));
1391            }
1392        };
1393        // Failed pages never shift later page numbers or become rendered text.
1394        // If every page failed, retain resource-level failure semantics as well.
1395        let all_failed = pages.iter().all(|page| page.error.is_some());
1396        let mut first_failure = None;
1397        let mut valid_pages = Vec::new();
1398        for page in pages {
1399            if let Some(error) = page.error {
1400                let entry = pdf_skip(&r.id, error);
1401                let reason = entry.reason.clone();
1402                target.page_diagnostics.push(PageDiagnostic {
1403                    code: entry.code,
1404                    resource: r.id.clone(),
1405                    page: page.page,
1406                    reason: reason.clone(),
1407                });
1408                first_failure.get_or_insert(entry);
1409            } else {
1410                valid_pages.push(page);
1411            }
1412        }
1413        if all_failed {
1414            self.commit_media_resource(&target, &[], stats)?;
1415            self.remember_pdf_diagnostics(&target, first_failure.clone())?;
1416            stats.page_diagnostics.extend(target.page_diagnostics);
1417            return Ok(first_failure);
1418        }
1419        #[cfg(feature = "pdf-render")]
1420        let mut renderer = None;
1421        let capacity = self.batch_size.min(4);
1422        let mut batch = Vec::with_capacity(capacity);
1423        let mut records = Vec::new();
1424        for page in valid_pages {
1425            if crate::pdf::usable(&page.text) {
1426                if !self.embedder.supports(crate::Modality::Text) {
1427                    target.page_diagnostics.push(PageDiagnostic {
1428                        code: SkipCode::Other,
1429                        resource: r.id.clone(),
1430                        page: page.page,
1431                        reason: "embedded text is not supported by this embedder".into(),
1432                    });
1433                    continue;
1434                }
1435                let digest = blake3::hash(page.text.as_bytes()).to_hex().to_string();
1436                for segment in self.segmenter.segment(&page.text, TextFormat::Plain) {
1437                    batch.push(Job::text(
1438                        ResourceLocator::PageText {
1439                            page: page.page,
1440                            byte_start: segment.byte_range.start as u64,
1441                            byte_end: segment.byte_range.end as u64,
1442                            text_version: crate::pdf::TEXT_VERSION.into(),
1443                            page_text_hash: digest.clone(),
1444                        },
1445                        None,
1446                        segment.text.clone(),
1447                        segment_title(r.title.as_deref(), None),
1448                        segment.text,
1449                    ));
1450                    if batch.len() == capacity
1451                        && let Some(reason) =
1452                            self.flush_media_batch(&mut batch, &mut records, stats, progress)?
1453                    {
1454                        return Ok(Some(skip(&r.id, reason)));
1455                    }
1456                }
1457            } else if self.pdf_images() {
1458                #[cfg(feature = "pdf-render")]
1459                {
1460                    if renderer.is_none() {
1461                        renderer = match crate::pdf::render::PageRenderer::new(&bytes, &r.id) {
1462                            Ok(renderer) => Some(renderer),
1463                            Err(error) => return Ok(Some(pdf_skip(&r.id, error))),
1464                        };
1465                    }
1466                    match renderer
1467                        .as_ref()
1468                        .expect("renderer initialized")
1469                        .page(page.page)
1470                    {
1471                        Ok(png) => {
1472                            batch.push(Job::image(ResourceLocator::Page { page: page.page }, png))
1473                        }
1474                        Err(Error::PdfRender { reason, .. }) => {
1475                            target.page_diagnostics.push(PageDiagnostic {
1476                                code: SkipCode::Other,
1477                                resource: r.id.clone(),
1478                                page: page.page,
1479                                reason: reason.into(),
1480                            });
1481                            continue;
1482                        }
1483                        Err(error) => return Ok(Some(pdf_skip(&r.id, error))),
1484                    }
1485                }
1486            } else {
1487                target.page_diagnostics.push(PageDiagnostic {
1488                    code: SkipCode::Other,
1489                    resource: r.id.clone(),
1490                    page: page.page,
1491                    reason: if cfg!(feature = "pdf-render") {
1492                        self.embedder.unavailable_reason(crate::Modality::Image).unwrap_or_else(||
1493                            "no usable embedded text; image embedding is disabled".into())
1494                    } else {
1495                        "no usable embedded text; PDF rendering is not compiled in (feature `pdf-render`)".into()
1496                    },
1497                });
1498            }
1499            if batch.len() == capacity
1500                && let Some(reason) =
1501                    self.flush_media_batch(&mut batch, &mut records, stats, progress)?
1502            {
1503                return Ok(Some(skip(&r.id, reason)));
1504            }
1505        }
1506        if let Some(reason) = self.flush_media_batch(&mut batch, &mut records, stats, progress)? {
1507            return Ok(Some(skip(&r.id, reason)));
1508        }
1509        self.commit_media_resource(&target, &records, stats)?;
1510        self.remember_pdf_diagnostics(&target, None)?;
1511        stats.page_diagnostics.extend(target.page_diagnostics);
1512        Ok(None)
1513    }
1514
1515    /// Publish only after preparation and the final embedding flush succeed.
1516    /// Earlier cached vectors may survive failure, but the prior binding must not change.
1517    #[cfg(any(feature = "pdf", feature = "audio"))]
1518    fn commit_media_resource(
1519        &self,
1520        target: &Pending,
1521        records: &[(CachedSegment, String)],
1522        stats: &mut SyncStats,
1523    ) -> Result<()> {
1524        let write = ResourceWrite {
1525            metadata: &target.metadata,
1526            version: &target.version,
1527            descriptor_hash: Some(&target.descriptor_hash),
1528            segments: records
1529                .iter()
1530                .map(|(segment, body)| SegmentWrite {
1531                    segment,
1532                    lexical_body: body,
1533                })
1534                .collect(),
1535        };
1536        self.with_cache(|c| c.put_resource(&self.profile_id, &target.resource, &write))?;
1537        stats.resources_updated += 1;
1538        Ok(())
1539    }
1540
1541    #[cfg(any(feature = "pdf", feature = "audio"))]
1542    fn flush_media_batch(
1543        &self,
1544        batch: &mut Vec<Job>,
1545        records: &mut Vec<(CachedSegment, String)>,
1546        stats: &mut SyncStats,
1547        progress: Option<ProgressFn>,
1548    ) -> Result<Option<String>> {
1549        if batch.is_empty() {
1550            return Ok(None);
1551        }
1552        if self.lexical_only {
1553            records.extend(batch.drain(..).map(|j| (j.segment, j.lexical_body)));
1554            return Ok(None);
1555        }
1556        let hashes: Vec<_> = batch.iter().map(|j| j.segment.input_hash.clone()).collect();
1557        let already = self.with_cache(|c| c.known_inputs(&self.profile_id, &hashes))?;
1558        let mut queued = HashSet::new();
1559        let work: Vec<_> = batch
1560            .iter()
1561            .filter(|j| {
1562                !already.contains(&j.segment.input_hash) && queued.insert(&j.segment.input_hash)
1563            })
1564            .map(|j| (0, j))
1565            .collect();
1566        let mut store = Vec::new();
1567        let mut failure = None;
1568        if !work.is_empty() {
1569            if let Some(p) = progress {
1570                p(ProgressEvent::Embedding {
1571                    done: 0,
1572                    total: work.len(),
1573                });
1574            }
1575            let started = Instant::now();
1576            for ((_, job), result) in work.iter().zip(self.embed_batch(&work)) {
1577                match result {
1578                    Ok(vector) => store.push((job.segment.input_hash.clone(), vector)),
1579                    Err(error) => {
1580                        failure.get_or_insert_with(|| error.to_string());
1581                    }
1582                }
1583            }
1584            stats.embed_ms += started.elapsed().as_millis() as u64;
1585            self.with_cache(|c| c.put_embeddings(&self.profile_id, &store))?;
1586            stats.segments_embedded += store.len();
1587            if let Some(p) = progress {
1588                p(ProgressEvent::Embedding {
1589                    done: work.len(),
1590                    total: work.len(),
1591                });
1592            }
1593        }
1594        // Retain only compact locators and transient lexical text. Drop every
1595        // PNG and model input here before the next bounded batch is rendered.
1596        records.extend(batch.drain(..).map(|j| (j.segment, j.lexical_body)));
1597        Ok(failure)
1598    }
1599
1600    #[cfg(feature = "audio")]
1601    fn sync_audio(
1602        &self,
1603        r: &ProvidedResource,
1604        source: crate::audio::VerifiedReader,
1605        target: Pending,
1606        stats: &mut SyncStats,
1607        progress: Option<ProgressFn>,
1608    ) -> Result<Option<String>> {
1609        let mut batch = Vec::new();
1610        let mut records = Vec::new();
1611        let capacity = self.batch_size.min(4);
1612        let identity = self.embedder.input_identity(crate::Modality::Audio)?;
1613        let result = crate::audio::windows_verified(
1614            source,
1615            &r.id,
1616            self.max_audio_duration_secs,
1617            |locator, samples| {
1618                let mut hash = blake3::Hasher::new();
1619                hash.update(b"audio-input-v1");
1620                hash.update(crate::audio::PREPARATION.as_bytes());
1621                hash.update(&(identity.len() as u64).to_le_bytes());
1622                hash.update(identity.as_bytes());
1623                hash.update(&(samples.len() as u64).to_le_bytes());
1624                for sample in &samples {
1625                    hash.update(&sample.to_le_bytes());
1626                }
1627                batch.push(Job {
1628                    segment: CachedSegment {
1629                        locator,
1630                        heading: None,
1631                        input_hash: hash.finalize().to_hex().to_string(),
1632                    },
1633                    lexical_body: String::new(),
1634                    input: JobInput::Audio { samples },
1635                });
1636                if batch.len() == capacity
1637                    && let Some(reason) =
1638                        self.flush_media_batch(&mut batch, &mut records, stats, progress)?
1639                {
1640                    return Err(Error::Embedding(reason));
1641                }
1642                Ok(())
1643            },
1644        );
1645        if let Err(error) = result {
1646            return match error {
1647                Error::ChangedWhileReading { .. }
1648                | Error::UnsupportedAudio { .. }
1649                | Error::InvalidLocator { .. }
1650                | Error::Embedding(_)
1651                | Error::ModelNotFound(_) => Ok(Some(error.to_string())),
1652                other => Err(other),
1653            };
1654        }
1655        if let Some(reason) = self.flush_media_batch(&mut batch, &mut records, stats, progress)? {
1656            return Ok(Some(reason));
1657        }
1658        let after = match self.provider.version(&r.id) {
1659            Ok(v) => v,
1660            Err(e) => return Ok(Some(e.to_string())),
1661        };
1662        if target.version.is_known() && !target.version.matches(&after) {
1663            return Ok(Some("resource changed while reading".into()));
1664        }
1665        self.commit_media_resource(&target, &records, stats)?;
1666        Ok(None)
1667    }
1668
1669    /// Turn a resource into embedding jobs. Errors are reasons to skip the resource.
1670    fn prepare(
1671        &self,
1672        r: &ProvidedResource,
1673        bytes: Vec<u8>,
1674    ) -> std::result::Result<(Vec<Job>, Vec<PageDiagnostic>), String> {
1675        match r.kind {
1676            ResourceKind::Audio => {
1677                Err("audio requires the streaming preparation path (feature `audio`)".into())
1678            }
1679            ResourceKind::Pdf => {
1680                #[cfg(feature = "pdf")]
1681                {
1682                    Err("PDF resources require the streaming preparation path".into())
1683                }
1684                #[cfg(not(feature = "pdf"))]
1685                {
1686                    Err("PDF feature is disabled".into())
1687                }
1688            }
1689            ResourceKind::Text(format) => {
1690                if bytes.iter().take(8192).any(|&b| b == 0) {
1691                    return Err("looks like a binary file".into());
1692                }
1693                let text = String::from_utf8(bytes).map_err(|_| "not valid UTF-8".to_string())?;
1694                let stem = &r.title;
1695                Ok((
1696                    self.segmenter
1697                        .segment(&text, format)
1698                        .into_iter()
1699                        .map(|s| {
1700                            let title = segment_title(stem.as_deref(), s.heading.as_deref());
1701                            let model_text = match format {
1702                                TextFormat::Markdown => strip_link_targets(&s.text),
1703                                TextFormat::Plain | TextFormat::Code => s.text.clone(),
1704                            };
1705                            Job::text(
1706                                ResourceLocator::Text {
1707                                    byte_start: s.byte_range.start as u64,
1708                                    byte_end: s.byte_range.end as u64,
1709                                    line_start: Some(s.line_start),
1710                                    line_end: Some(s.line_end),
1711                                },
1712                                s.heading,
1713                                // Index the original source, including link targets.
1714                                s.text,
1715                                title,
1716                                model_text,
1717                            )
1718                        })
1719                        .collect(),
1720                    Vec::new(),
1721                ))
1722            }
1723            ResourceKind::Image => Ok((
1724                vec![Job::image(ResourceLocator::WholeResource, bytes)],
1725                Vec::new(),
1726            )),
1727        }
1728    }
1729}
1730
1731#[cfg(feature = "pdf")]
1732fn pdf_skip(resource: &ResourceId, error: Error) -> SkippedResource {
1733    let code = match &error {
1734        Error::ParserPanicked { .. } => SkipCode::PdfParserPanicked,
1735        Error::PdfParse { .. } | Error::PdfPageDecode { .. } => SkipCode::PdfParseFailed,
1736        _ => SkipCode::Other,
1737    };
1738    let reason = match error {
1739        Error::PdfParse { reason, .. } | Error::PdfPageDecode { reason, .. } => {
1740            format!("PDF parse failed: {reason}")
1741        }
1742        Error::PdfRender { reason, .. } => format!("PDF rendering failed: {reason}"),
1743        Error::ParserPanicked { reason, .. } => format!("PDF parser panicked: {reason}"),
1744        other => other.to_string(),
1745    };
1746    SkippedResource {
1747        code,
1748        resource: resource.clone(),
1749        path: resource.to_file_path(),
1750        reason,
1751    }
1752}
1753
1754struct PdfDiagnostics {
1755    descriptor_hash: String,
1756    pages: Vec<PageDiagnostic>,
1757    skipped: Option<SkippedResource>,
1758}
1759
1760struct Pending {
1761    descriptor_hash: String,
1762    resource: ResourceId,
1763    version: ResourceVersion,
1764    metadata: ResourceMetadata,
1765    jobs: Vec<Job>,
1766    page_diagnostics: Vec<PageDiagnostic>,
1767}
1768
1769struct Job {
1770    /// `segment.input_hash` identifies the model input; identical inputs in
1771    /// different resources are embedded once.
1772    segment: CachedSegment,
1773    lexical_body: String,
1774    input: JobInput,
1775}
1776
1777impl Job {
1778    fn image(locator: ResourceLocator, bytes: Vec<u8>) -> Self {
1779        Self {
1780            segment: CachedSegment {
1781                locator,
1782                heading: None,
1783                input_hash: input_hash(&["image", &blake3::hash(&bytes).to_hex()]),
1784            },
1785            lexical_body: String::new(),
1786            input: JobInput::Image { bytes },
1787        }
1788    }
1789
1790    /// Derive cache identity from exactly the title and text sent to the model.
1791    /// Lexical text may retain source syntax that model preparation removes.
1792    fn text(
1793        locator: ResourceLocator,
1794        heading: Option<String>,
1795        lexical_body: String,
1796        title: Option<String>,
1797        text: String,
1798    ) -> Self {
1799        Self {
1800            segment: CachedSegment {
1801                locator,
1802                heading,
1803                input_hash: input_hash(&["text", title.as_deref().unwrap_or(""), &text]),
1804            },
1805            lexical_body,
1806            input: JobInput::Text { title, text },
1807        }
1808    }
1809}
1810
1811enum JobInput {
1812    Text {
1813        title: Option<String>,
1814        text: String,
1815    },
1816    Image {
1817        bytes: Vec<u8>,
1818    },
1819    #[cfg(feature = "audio")]
1820    Audio {
1821        samples: Vec<f32>,
1822    },
1823}
1824
1825impl JobInput {
1826    fn as_input(&self) -> ResourceInput<'_> {
1827        match self {
1828            JobInput::Text { title, text } => ResourceInput::Text {
1829                title: title.as_deref(),
1830                text,
1831            },
1832            JobInput::Image { bytes } => ResourceInput::Image { bytes },
1833            #[cfg(feature = "audio")]
1834            JobInput::Audio { samples } => ResourceInput::Audio {
1835                samples,
1836                sample_rate: 16000,
1837            },
1838        }
1839    }
1840}
1841
1842/// Replace Markdown links with their visible text before embedding.
1843///
1844/// `[dossier](../people/Ada.md)` becomes `dossier`, and
1845/// `[[Ada - old|old notes]]` becomes `old notes`. Link targets are paths and
1846/// URLs, not prose; left in, they made link-heavy lines match unrelated queries.
1847/// Only the model input changes: locators still point at the original bytes.
1848fn strip_link_targets(text: &str) -> String {
1849    let mut out = String::with_capacity(text.len());
1850    let mut rest = text;
1851    while let Some(i) = rest.find('[') {
1852        out.push_str(&rest[..i]);
1853        let after = &rest[i..];
1854        if let Some(inner) = after.strip_prefix("[[")
1855            && let Some(end) = inner.find("]]")
1856            && !inner[..end].contains('\n')
1857        {
1858            let target = &inner[..end];
1859            out.push_str(target.rsplit('|').next().unwrap_or(target));
1860            rest = &inner[end + 2..];
1861            continue;
1862        }
1863        if let Some(close) = after.find("](")
1864            && !after[..close].contains('\n')
1865            && let Some(end) = after[close + 2..].find(')')
1866            && !after[close + 2..close + 2 + end].contains(char::is_whitespace)
1867        {
1868            out.push_str(&after[1..close]);
1869            rest = &after[close + 2 + end + 1..];
1870            continue;
1871        }
1872        out.push('[');
1873        rest = &after[1..];
1874    }
1875    out.push_str(rest);
1876    out
1877}
1878
1879fn skip(resource: &ResourceId, reason: impl Into<String>) -> SkippedResource {
1880    let reason = reason.into();
1881    SkippedResource {
1882        code: SkipCode::Other,
1883        resource: resource.clone(),
1884        path: resource.to_file_path(),
1885        reason,
1886    }
1887}
1888
1889fn skipped_counts(skipped: &[SkippedResource]) -> Vec<SkippedCount> {
1890    let mut counts = std::collections::BTreeMap::new();
1891    for entry in skipped {
1892        *counts
1893            .entry((entry.code, entry.reason.clone()))
1894            .or_insert(0) += 1;
1895    }
1896    counts
1897        .into_iter()
1898        .map(|((code, reason), count)| SkippedCount {
1899            code,
1900            reason,
1901            count,
1902        })
1903        .collect()
1904}
1905
1906fn input_hash(parts: &[&str]) -> String {
1907    let mut h = blake3::Hasher::new();
1908    for p in parts {
1909        h.update(&(p.len() as u64).to_le_bytes());
1910        h.update(p.as_bytes());
1911    }
1912    h.finalize().to_hex().to_string()
1913}
1914
1915/// The document title sent to the model: the heading path, prefixed by the file
1916/// name unless the heading already starts with it.
1917fn segment_title(stem: Option<&str>, heading: Option<&str>) -> Option<String> {
1918    match (stem, heading) {
1919        (Some(s), Some(h)) if h.to_lowercase().starts_with(&s.to_lowercase()) => {
1920            Some(h.to_string())
1921        }
1922        (Some(s), Some(h)) => Some(format!("{s} > {h}")),
1923        (Some(s), None) => Some(s.to_string()),
1924        (None, h) => h.map(str::to_string),
1925    }
1926}
1927
1928pub struct SearchRequest<'e> {
1929    engine: &'e SearchEngine,
1930    queries: Vec<String>,
1931    paths: Vec<PathBuf>,
1932    roots: Vec<ResourceId>,
1933    limit: usize,
1934    min_score: Option<f32>,
1935    discovery: DiscoveryOptions,
1936    preview_chars: Option<usize>,
1937    progress: Option<ProgressFn<'e>>,
1938    sync: bool,
1939    mode: SearchMode,
1940    image_scan: ImageScan,
1941    lexical_weight: Option<f32>,
1942    lexical_fallback: bool,
1943    kind_counts: bool,
1944    /// Resources the outer request already listed and authorized. A nested
1945    /// fallback prepares exactly these, so it never widens scope or re-walks.
1946    listed: Option<Vec<ProvidedResource>>,
1947}
1948
1949impl<'e> SearchRequest<'e> {
1950    /// Opt into experimental Photo Anchor candidate scanning for standalone images.
1951    /// Exact f32 reranking preserves returned score semantics. Requires SQLite;
1952    /// a bounded shortlist does not guarantee recall on arbitrary libraries.
1953    pub fn image_scan(mut self, scan: ImageScan) -> Self {
1954        self.image_scan = scan;
1955        self
1956    }
1957
1958    /// Include counts of returned hits by locator kind; no second search is run.
1959    pub fn kind_counts(mut self, enabled: bool) -> Self {
1960        self.kind_counts = enabled;
1961        self
1962    }
1963
1964    /// Add provider-specific roots without filesystem path conversion.
1965    pub fn roots(mut self, roots: impl IntoIterator<Item = ResourceId>) -> Self {
1966        self.roots.extend(roots);
1967        self
1968    }
1969
1970    /// Search for another phrasing too. Each segment is ranked by its best score
1971    /// over all queries, so rephrasing widens recall without diluting scores.
1972    pub fn query(mut self, query: impl Into<String>) -> Self {
1973        self.queries.push(query.into());
1974        self
1975    }
1976
1977    /// Add filesystem-only paths, canonicalized locally at execution even with
1978    /// a custom provider. Use `roots()` for generic provider identities.
1979    pub fn paths<I, P>(mut self, paths: I) -> Self
1980    where
1981        I: IntoIterator<Item = P>,
1982        P: AsRef<Path>,
1983    {
1984        self.paths
1985            .extend(paths.into_iter().map(|p| expand_tilde(p.as_ref())));
1986        self
1987    }
1988
1989    pub fn limit(mut self, n: usize) -> Self {
1990        self.limit = n;
1991        self
1992    }
1993
1994    /// Override how much keyword rank counts against semantic rank in hybrid
1995    /// mode (semantic is 1). By default it depends on the query, see
1996    /// [`keyword_weight`]. Must be finite and non-negative; zero disables the
1997    /// keyword signal in hybrid mode. Valid overrides are ignored in other modes.
1998    pub fn lexical_weight(mut self, w: f32) -> Self {
1999        self.lexical_weight = Some(w);
2000        self
2001    }
2002
2003    pub fn mode(mut self, mode: SearchMode) -> Self {
2004        self.mode = mode;
2005        self
2006    }
2007
2008    /// Hide semantic matches below this cosine similarity. Keyword and file
2009    /// name matches are kept: their relevance is not a cosine.
2010    pub fn min_score(mut self, s: f32) -> Self {
2011        self.min_score = Some(s);
2012        self
2013    }
2014
2015    pub fn include(mut self, glob: impl Into<String>) -> Self {
2016        self.discovery.include.push(glob.into());
2017        self
2018    }
2019
2020    pub fn exclude(mut self, glob: impl Into<String>) -> Self {
2021        self.discovery.exclude.push(glob.into());
2022        self
2023    }
2024
2025    pub fn discovery(mut self, opts: DiscoveryOptions) -> Self {
2026        self.discovery = opts;
2027        self
2028    }
2029
2030    /// Maximum preview length in characters, or `None` to skip reading previews.
2031    pub fn previews(mut self, chars: Option<usize>) -> Self {
2032        self.preview_chars = chars;
2033        self
2034    }
2035
2036    pub fn progress(mut self, f: ProgressFn<'e>) -> Self {
2037        self.progress = Some(f);
2038        self
2039    }
2040
2041    /// With `false`, search only what is already cached and embed nothing. For
2042    /// callers where a separate indexer (see `watch`) keeps the cache fresh, so a
2043    /// search never waits for a large first index. Changed files may be stale.
2044    pub fn sync(mut self, sync: bool) -> Self {
2045        self.sync = sync;
2046        self
2047    }
2048
2049    /// Allow a cached search to prepare an ephemeral keyword index when any
2050    /// supported resource is new or changed. Never waits for embedding or writes
2051    /// to the semantic cache. Providers opting into cached listing retain their
2052    /// indexed-only scope; their background indexer discovers new resources.
2053    /// Without this opt-in, `sync(false)` stays cache-only.
2054    pub fn lexical_fallback(mut self, enabled: bool) -> Self {
2055        self.lexical_fallback = enabled;
2056        self
2057    }
2058
2059    pub fn execute(mut self) -> Result<SearchResults> {
2060        let started = Instant::now();
2061        let engine = self.engine;
2062        if engine.lexical_only {
2063            self.sync = true;
2064            self.mode = SearchMode::Lexical;
2065        }
2066        if self.image_scan == ImageScan::PhotoAnchorQ4 {
2067            if self.mode == SearchMode::Lexical {
2068                return Err(Error::Config(
2069                    "q4 image scan is unavailable in lexical-only mode".into(),
2070                ));
2071            }
2072            if !engine.with_cache(|c| Ok(c.supports_image_codes()))? {
2073                return Err(Error::Config(
2074                    "q4 image scan requires SQLite; --no-cache is unsupported".into(),
2075                ));
2076            }
2077            crate::cache::image_codes::check_profile(&engine.profile)?;
2078        }
2079        if self
2080            .lexical_weight
2081            .is_some_and(|w| !w.is_finite() || w < 0.0)
2082        {
2083            return Err(Error::Config(
2084                "lexical weight must be finite and non-negative".into(),
2085            ));
2086        }
2087        if self.paths.is_empty() && self.roots.is_empty() {
2088            return Err(Error::Config("no paths to search".into()));
2089        }
2090        let mut discovery = self.discovery.clone();
2091        discovery.max_audio_duration_secs = engine.max_audio_duration_secs;
2092        let scope = ResourceScope::from_paths(&self.paths, self.roots.clone(), discovery)?;
2093        let pid = engine.profile_id.as_str();
2094        // Only opted-in providers authorize the indexed subset without walking
2095        // uncached descendants. Default providers retain their original list path.
2096        let listed = self.listed.take();
2097        let cached_metadata =
2098            if listed.is_none() && !self.sync && engine.provider.supports_cached_listing() {
2099                Some(engine.with_cache(|c| c.resource_index(pid))?)
2100            } else {
2101                None
2102            };
2103        let resources = if let Some(listed) = listed {
2104            // The outer request already reported Walked/Discovered.
2105            listed
2106        } else {
2107            let resources = if let Some(metadata) = &cached_metadata {
2108                let ids: Vec<_> = metadata.keys().cloned().collect();
2109                engine.provider.list_cached(&scope, &ids)?
2110            } else {
2111                let listing = engine.provider.list_walked(&scope)?;
2112                report_walked(self.progress, listing.walked_roots);
2113                listing.resources
2114            };
2115            if let Some(p) = self.progress {
2116                p(ProgressEvent::Discovered {
2117                    resources: resources.len(),
2118                });
2119            }
2120            resources
2121        };
2122        if self.lexical_fallback && !self.sync && !engine.lexical_only {
2123            let cached = engine.with_cache(|c| c.resource_index(&engine.profile_id))?;
2124            let mut pending = false;
2125            for r in &resources {
2126                if engine.supports_kind(r.kind)
2127                    && !engine.skipped_at_current_version(r)?
2128                    && !cached
2129                        .get(&r.id)
2130                        .is_some_and(|m| !r.version.is_known() || m.version.matches(&r.version))
2131                {
2132                    pending = true;
2133                    break;
2134                }
2135            }
2136            if pending {
2137                let lexical = engine.lexical_fallback()?;
2138                let mut results = SearchRequest {
2139                    engine: &lexical,
2140                    sync: true,
2141                    image_scan: ImageScan::Exact,
2142                    listed: Some(resources),
2143                    ..self
2144                }
2145                .execute()?;
2146                results.degraded = Some(SearchDegradation::lexical(DegradedReason::NotYetEmbedded));
2147                results.stats.total_ms = started.elapsed().as_millis() as u64;
2148                return Ok(results);
2149            }
2150        }
2151        let mut sync = if self.sync {
2152            let indexing = engine.acquire_indexing_lease()?;
2153            let report = engine.sync(&indexing, &resources, self.progress)?;
2154            if self.image_scan == ImageScan::PhotoAnchorQ4 {
2155                let skipped: HashSet<_> = report.skipped.iter().map(|s| &s.resource).collect();
2156                let paths = resources
2157                    .iter()
2158                    .filter(|r| r.kind == ResourceKind::Image && !skipped.contains(&r.id))
2159                    .map(|r| r.id.clone())
2160                    .collect();
2161                engine.with_cache(|c| c.sync_image_codes(pid, &paths))?;
2162            }
2163            report
2164        } else {
2165            SyncStats {
2166                resources_discovered: resources.len(),
2167                ..Default::default()
2168            }
2169        };
2170
2171        let mut wanted: HashSet<ResourceId> = resources
2172            .iter()
2173            .filter(|r| engine.supports_kind(r.kind))
2174            .map(|r| r.id.clone())
2175            .collect();
2176        let metadata = match cached_metadata {
2177            Some(metadata) => metadata,
2178            None => engine.with_cache(|c| c.resource_index(pid))?,
2179        };
2180        // Failed preparation must never expose a previous version's evidence,
2181        // including filename-only hits and requests without previews. Apply this
2182        // to normal synchronized searches as well as the model-free engine.
2183        let skipped: HashSet<_> = sync.skipped.iter().map(|s| s.resource.clone()).collect();
2184        for r in &resources {
2185            if skipped.contains(&r.id)
2186                || engine.skipped_at_current_version(r)?
2187                || (self.lexical_fallback
2188                    && r.version.is_known()
2189                    && !metadata
2190                        .get(&r.id)
2191                        .is_some_and(|m| m.version.matches(&r.version)))
2192            {
2193                wanted.remove(&r.id);
2194            }
2195        }
2196        for r in resources.iter().filter(|r| r.kind == ResourceKind::Audio) {
2197            let failed = skipped.contains(&r.id);
2198            if failed {
2199                wanted.remove(&r.id);
2200                continue;
2201            }
2202            let descriptor = match engine.audio_descriptor_hash(r) {
2203                Ok(hash) => hash,
2204                Err(error) => {
2205                    sync.skipped.push(skip(&r.id, error.to_string()));
2206                    wanted.remove(&r.id);
2207                    continue;
2208                }
2209            };
2210            let current = metadata.get(&r.id).is_some_and(|m| {
2211                // Unknown/changed cached sources cannot establish eligibility in cached-only search.
2212                (self.sync || m.version.matches(&r.version))
2213                    && m.metadata.size_bytes == r.size_bytes
2214                    && m.descriptor_hash.as_ref() == Some(&descriptor)
2215            });
2216            if !current || !engine.audio_eligible(&r.id)? {
2217                wanted.remove(&r.id);
2218            }
2219        }
2220
2221        sync.skipped_counts = skipped_counts(&sync.skipped);
2222
2223        // Each signal contributes a deeper list than asked for, so a hit that is
2224        // only moderately good on two signals can still surface.
2225        let depth = self.limit.saturating_mul(4).max(50);
2226
2227        let mut segments = SegmentTable::default();
2228        let mut semantic: Vec<(SegmentKey, f32, usize)> = Vec::new();
2229        let mut searched = 0;
2230        if self.mode != SearchMode::Lexical {
2231            let dim = engine.embedder.dimension().get();
2232            let mut query_vectors = Vec::with_capacity(self.queries.len());
2233            for q in &self.queries {
2234                let v = engine.embedder.embed_text(q, EmbeddingTask::SearchQuery)?;
2235                if v.dim() != dim {
2236                    return Err(Error::DimensionMismatch {
2237                        expected: dim,
2238                        actual: v.dim(),
2239                    });
2240                }
2241                query_vectors.push(v);
2242            }
2243            let min_score = self.min_score.unwrap_or(f32::MIN);
2244            let take = if self.mode == SearchMode::Semantic {
2245                self.limit
2246            } else {
2247                depth
2248            };
2249            if self.image_scan == ImageScan::PhotoAnchorQ4 {
2250                let mut rows = engine.with_cache(|c| c.image_scan_rows(pid, &wanted))?;
2251                rows.retain(|v| {
2252                    metadata
2253                        .get(&v.resource)
2254                        .is_some_and(|m| engine.supports_locator(&m.metadata, &v.segment.locator))
2255                });
2256                rows.sort_by_cached_key(|v| (crate::cache::resource_key(&v.resource), v.ordinal));
2257                searched = rows.len();
2258                let ranked = image_scan::search(engine, &rows, &query_vectors, take, min_score)?;
2259                let keys: Vec<_> = rows
2260                    .into_iter()
2261                    .map(|v| segments.insert(v.resource, v.ordinal, v.segment))
2262                    .collect();
2263                semantic = fuse_max(ranked.into_iter(), take)
2264                    .into_iter()
2265                    .map(|f| (keys[f.scored.id].clone(), f.scored.score, f.query_index))
2266                    .collect();
2267            } else {
2268                let mut stored = engine.with_cache(|c| c.vectors_for(pid, &wanted))?;
2269                // Vector IDs break cosine ties, including at the top-k cutoff.
2270                stored.sort_by_cached_key(|v| (crate::cache::resource_key(&v.resource), v.ordinal));
2271                let mut index = BruteForceIndex::with_capacity(dim, stored.len())?;
2272                let mut keys = Vec::with_capacity(stored.len());
2273                for v in stored {
2274                    // A wrong-sized vector means a corrupt cache; never compare it.
2275                    if v.vector.len() == dim
2276                        && metadata.get(&v.resource).is_some_and(|m| {
2277                            engine.supports_locator(&m.metadata, &v.segment.locator)
2278                        })
2279                    {
2280                        index.add(&v.vector);
2281                        keys.push(segments.insert(v.resource, v.ordinal, v.segment));
2282                    }
2283                }
2284                searched = keys.len();
2285                semantic = fuse_max(
2286                    query_vectors
2287                        .iter()
2288                        .map(|q| index.search(q.as_slice(), take, min_score)),
2289                    take,
2290                )
2291                .into_iter()
2292                .map(|f| (keys[f.scored.id].clone(), f.scored.score, f.query_index))
2293                .collect();
2294            }
2295        }
2296
2297        let mut lexical: Vec<(SegmentKey, f32)> = Vec::new();
2298        let mut named: Vec<SegmentKey> = Vec::new();
2299        if self.mode != SearchMode::Semantic {
2300            // Semantic candidates already carry locators in vectors_for; only
2301            // lexical search needs this separate eligibility lookup.
2302            // Lexical caches limit their result list before the engine sees it.
2303            // Reserve room for every ineligible PDF segment, then filter before
2304            // restoring the candidate depth. A disabled scan cannot exhaust it.
2305            let mut unsupported = HashSet::new();
2306            // These are the two locator kinds emitted by PDF preparation. Both
2307            // feature gates AND model capabilities must hold, including when a
2308            // different model configuration reuses this profile with sync(false).
2309            let all_pdf_locators_supported = cfg!(feature = "pdf-render")
2310                && engine.embedder.supports(crate::Modality::Image)
2311                && cfg!(feature = "pdf")
2312                && engine.embedder.supports(crate::Modality::Text);
2313            if !all_pdf_locators_supported {
2314                for resource in &wanted {
2315                    if let Some(state) = metadata.get(resource)
2316                        && state.metadata.media_type.as_deref() == Some("application/pdf")
2317                    {
2318                        for &ordinal in &segments.load_resource(engine, pid, resource)?.unsupported
2319                        {
2320                            unsupported.insert((resource.clone(), ordinal));
2321                        }
2322                    }
2323                }
2324            }
2325            let query = LexicalQuery::new(self.queries.iter().map(String::as_str));
2326            for hit in engine.with_cache(|c| {
2327                c.lexical_search(
2328                    pid,
2329                    &query,
2330                    &wanted,
2331                    depth.saturating_add(unsupported.len()),
2332                )
2333            })? {
2334                let key = (hit.resource, hit.ordinal);
2335                if unsupported.contains(&key) {
2336                    continue;
2337                }
2338                if metadata
2339                    .get(&key.0)
2340                    .is_some_and(|m| m.metadata.media_type.as_deref() == Some("application/pdf"))
2341                {
2342                    let Some(segment) = segments.get_or_load(engine, pid, &key)? else {
2343                        continue;
2344                    };
2345                    if !engine.supports_locator(&metadata[&key.0].metadata, &segment.locator) {
2346                        continue;
2347                    }
2348                }
2349                lexical.push((key, hit.score));
2350                if lexical.len() == depth {
2351                    break;
2352                }
2353            }
2354            for resource in resources.iter().filter(|r| {
2355                wanted.contains(&r.id)
2356                    && self
2357                        .queries
2358                        .iter()
2359                        .any(|q| names_file(q, Path::new(&r.id.labels().1)))
2360            }) {
2361                if !metadata.contains_key(&resource.id) {
2362                    continue;
2363                }
2364                // A filename identifies the first supported cached location,
2365                // which need not be ordinal zero in a mixed PDF.
2366                if let Some(ordinal) = segments
2367                    .load_resource(engine, pid, &resource.id)?
2368                    .first_supported
2369                {
2370                    named.push((resource.id.clone(), ordinal));
2371                }
2372            }
2373            if self.mode == SearchMode::Lexical {
2374                searched = lexical.len();
2375            }
2376        }
2377
2378        let weight = match self.mode {
2379            SearchMode::Hybrid => self
2380                .lexical_weight
2381                .unwrap_or_else(|| keyword_weight(&self.queries)),
2382            _ => keyword_weight(&self.queries),
2383        };
2384        let ranked = rank(self.mode, &semantic, &lexical, weight, &named, self.limit);
2385
2386        // Annotate only after ranking. Keep the scoring arithmetic, candidate
2387        // order, cutoffs and tie-breaking completely independent of provenance.
2388        let semantic_ranks: HashMap<_, _> = semantic
2389            .iter()
2390            .enumerate()
2391            .map(|(i, (key, _, _))| (key, i + 1))
2392            .collect();
2393        let keyword_ranks: HashMap<_, _> = lexical
2394            .iter()
2395            .enumerate()
2396            .map(|(i, (key, _))| (key, i + 1))
2397            .collect();
2398        let filename_ranks: HashMap<_, _> = named
2399            .iter()
2400            .enumerate()
2401            .map(|(i, key)| (key, i + 1))
2402            .collect();
2403        let mut previews = PreviewReader::default();
2404        let mut hits = Vec::with_capacity(ranked.len());
2405        let mut keyword_keys = Vec::new();
2406        let mut keyword_positions = Vec::new();
2407        for (key, score, signals, query_index) in ranked {
2408            let Some(metadata) = metadata.get(&key.0).map(|s| s.metadata.clone()) else {
2409                continue;
2410            };
2411            let Some(segment) = segments.get_or_load(engine, pid, &key)? else {
2412                continue;
2413            };
2414            if !engine.supports_locator(&metadata, &segment.locator) {
2415                continue;
2416            }
2417            let preview = self.preview_chars.and_then(|n| {
2418                previews.preview(
2419                    engine.provider.as_ref(),
2420                    &key.0,
2421                    &segment.locator,
2422                    &metadata,
2423                    n,
2424                )
2425            });
2426            let keyword_rank = signals
2427                .lexical
2428                .and_then(|_| keyword_ranks.get(&key).copied());
2429            let reason = MatchReason {
2430                semantic_rank: signals
2431                    .semantic
2432                    .and_then(|_| semantic_ranks.get(&key).copied()),
2433                keyword_rank,
2434                filename_rank: signals.filename.then(|| filename_ranks[&key]),
2435                matched_terms: Vec::new(),
2436            };
2437            if keyword_rank.is_some() {
2438                keyword_keys.push(key.clone());
2439                keyword_positions.push(hits.len());
2440            }
2441            hits.push(SearchHit {
2442                resource: key.0,
2443                locator: segment.locator.clone(),
2444                score,
2445                signals,
2446                r#match: reason,
2447                metadata,
2448                heading: segment.heading.clone(),
2449                preview,
2450                query_index,
2451            });
2452        }
2453
2454        if !keyword_keys.is_empty() {
2455            let query = LexicalQuery::new(self.queries.iter().map(String::as_str));
2456            let terms =
2457                engine.with_cache(|c| c.lexical_matched_terms(pid, &query, &keyword_keys))?;
2458            for (position, terms) in keyword_positions.into_iter().zip(terms) {
2459                hits[position].r#match.matched_terms = terms;
2460            }
2461        }
2462
2463        let kind_counts = self.kind_counts.then(|| {
2464            let mut counts = std::collections::BTreeMap::new();
2465            for hit in &hits {
2466                *counts.entry(hit.locator.kind().to_owned()).or_insert(0) += 1;
2467            }
2468            counts
2469        });
2470        Ok(SearchResults {
2471            kind_counts,
2472            queries: self.queries,
2473            mode: self.mode,
2474            hits,
2475            degraded: engine
2476                .lexical_only
2477                .then(|| SearchDegradation::lexical(DegradedReason::NoModel)),
2478            stats: SearchStats {
2479                sync,
2480                segments_searched: searched,
2481                total_ms: started.elapsed().as_millis() as u64,
2482            },
2483        })
2484    }
2485}
2486
2487/// A segment's identity within a search: its resource and position.
2488type SegmentKey = (ResourceId, usize);
2489
2490/// Segments seen so far, filled from the vectors already loaded and, for hits
2491/// found only by keyword or name, from the cache on demand.
2492#[derive(Default)]
2493struct SegmentTable {
2494    segments: HashMap<ResourceId, HashMap<usize, CachedSegment>>,
2495    // Vector rows are only a partial view: keyword-only ordinals may be absent.
2496    complete: HashMap<ResourceId, CompleteResource>,
2497}
2498
2499/// Eligibility from the complete lookup, independent of earlier vector evidence.
2500/// A missing resource has no filename candidate, even if scored rows survive.
2501#[derive(Default)]
2502struct CompleteResource {
2503    unsupported: Vec<usize>,
2504    first_supported: Option<usize>,
2505}
2506
2507impl SegmentTable {
2508    fn insert(&mut self, path: ResourceId, ordinal: usize, segment: CachedSegment) -> SegmentKey {
2509        self.segments
2510            .entry(path.clone())
2511            .or_default()
2512            .insert(ordinal, segment);
2513        (path, ordinal)
2514    }
2515
2516    /// Bulk-ingest one complete lookup, including a missing/empty resource.
2517    /// Errors leave completion unset so a later attempt can retry.
2518    fn load_resource(
2519        &mut self,
2520        engine: &SearchEngine,
2521        pid: &str,
2522        resource: &ResourceId,
2523    ) -> Result<&CompleteResource> {
2524        if !self.complete.contains_key(resource) {
2525            let cached = engine.with_cache(|c| c.resource(pid, resource))?;
2526            let mut complete = CompleteResource::default();
2527            let seen = self.segments.entry(resource.clone()).or_default();
2528            if let Some(cached) = cached {
2529                for (ordinal, segment) in cached.segments.into_iter().enumerate() {
2530                    if engine.supports_locator(&cached.metadata, &segment.locator) {
2531                        complete.first_supported.get_or_insert(ordinal);
2532                    } else {
2533                        complete.unsupported.push(ordinal);
2534                    }
2535                    // A concurrent indexer can replace/remove a resource after
2536                    // vectors_for. Never relabel or discard already-scored evidence.
2537                    seen.entry(ordinal).or_insert(segment);
2538                }
2539            }
2540            self.complete.insert(resource.clone(), complete);
2541        }
2542        Ok(&self.complete[resource])
2543    }
2544
2545    fn get_or_load(
2546        &mut self,
2547        engine: &SearchEngine,
2548        pid: &str,
2549        key: &SegmentKey,
2550    ) -> Result<Option<&CachedSegment>> {
2551        if !self
2552            .segments
2553            .get(&key.0)
2554            .is_some_and(|s| s.contains_key(&key.1))
2555        {
2556            self.load_resource(engine, pid, &key.0)?;
2557        }
2558        Ok(self.segments.get(&key.0).and_then(|s| s.get(&key.1)))
2559    }
2560}
2561
2562/// Whether `query` names this file: its name (`garden.md`), its stem (`garden`)
2563/// or a trailing part of its path (`notes/garden.md`), ignoring case.
2564fn names_file(query: &str, path: &Path) -> bool {
2565    let q = query
2566        .trim()
2567        .trim_matches(['"', '\'', '`'])
2568        .trim()
2569        .replace('\\', "/")
2570        .to_lowercase();
2571    if q.is_empty() {
2572        return false;
2573    }
2574    let full = path.to_string_lossy().replace('\\', "/").to_lowercase();
2575    let path = Path::new(&full);
2576    let name = path.file_name().map(|n| n.to_string_lossy().to_lowercase());
2577    let stem = path.file_stem().map(|n| n.to_string_lossy().to_lowercase());
2578    if name.as_deref() == Some(q.as_str()) || stem.as_deref() == Some(q.as_str()) {
2579        return true;
2580    }
2581    q.contains('/') && {
2582        full == q || (full.ends_with(&q) && full[..full.len() - q.len()].ends_with('/'))
2583    }
2584}
2585
2586/// How much keyword rank counts, relative to semantic rank, for these queries.
2587///
2588/// Names and identifiers (`garden.md`, `INC-18372`, `Session::restore()`) or a
2589/// word or two are what keywords are for, so they count fully. In a question
2590/// in prose, shared words are mostly incidental ("old", "match"), so they only
2591/// break ties between hits of similar meaning.
2592pub fn keyword_weight(queries: &[String]) -> f32 {
2593    let identifier = |w: &str| {
2594        w.chars().any(|c| c.is_ascii_digit())
2595            || w.trim_matches(|c: char| !c.is_alphanumeric())
2596                .contains(['.', '_', ':', '-', '/', '#'])
2597    };
2598    let keywordish = queries.iter().any(|q| {
2599        let words: Vec<&str> = q.split_whitespace().collect();
2600        words.iter().any(|w| identifier(w)) || LexicalQuery::new([q.as_str()]).groups.len() <= 2
2601    });
2602    if keywordish {
2603        1.0
2604    } else {
2605        PROSE_KEYWORD_WEIGHT
2606    }
2607}
2608
2609/// Keyword weight for questions in prose; see [`keyword_weight`].
2610pub const PROSE_KEYWORD_WEIGHT: f32 = 0.3;
2611
2612/// Reciprocal rank fusion damping. A full SciFact/NFCorpus/IRPAPERS sweep
2613/// improved hybrid nDCG@10 on all three datasets with k=5 versus k=60,
2614/// keeping lexical weights, filename weight and candidate depth unchanged.
2615const RRF_K: f32 = 5.0;
2616/// A file the query names outweighs a semantic and a keyword first place.
2617const FILENAME_WEIGHT: f32 = 3.0;
2618
2619/// Merge the signals into the final ranking.
2620fn rank(
2621    mode: SearchMode,
2622    semantic: &[(SegmentKey, f32, usize)],
2623    lexical: &[(SegmentKey, f32)],
2624    lexical_weight: f32,
2625    named: &[SegmentKey],
2626    limit: usize,
2627) -> Vec<(SegmentKey, f32, MatchSignals, usize)> {
2628    if mode == SearchMode::Semantic {
2629        return semantic
2630            .iter()
2631            .take(limit)
2632            .map(|(k, cos, q)| {
2633                let signals = MatchSignals {
2634                    semantic: Some(*cos),
2635                    ..Default::default()
2636                };
2637                (k.clone(), *cos, signals, *q)
2638            })
2639            .collect();
2640    }
2641    let rrf = |rank: usize, weight: f32| weight / (RRF_K + rank as f32 + 1.0);
2642    let mut fused: HashMap<SegmentKey, (f32, MatchSignals, usize)> = HashMap::new();
2643    for (rank, (k, cos, q)) in semantic.iter().enumerate() {
2644        let e = fused.entry(k.clone()).or_default();
2645        e.0 += rrf(rank, 1.0);
2646        e.1.semantic = Some(*cos);
2647        e.2 = *q;
2648    }
2649    if lexical_weight > 0.0 {
2650        for (rank, (k, bm25)) in lexical.iter().enumerate() {
2651            let e = fused.entry(k.clone()).or_default();
2652            e.0 += rrf(rank, lexical_weight);
2653            e.1.lexical = Some(*bm25);
2654        }
2655    }
2656    for (rank, k) in named.iter().enumerate() {
2657        let e = fused.entry(k.clone()).or_default();
2658        e.0 += rrf(rank, FILENAME_WEIGHT);
2659        e.1.filename = true;
2660    }
2661    // The best possible score: first place on every signal that found anything.
2662    let best = [
2663        (!semantic.is_empty(), 1.0),
2664        (!lexical.is_empty(), lexical_weight),
2665        (!named.is_empty(), FILENAME_WEIGHT),
2666    ]
2667    .iter()
2668    .filter(|(found, w)| *found && *w > 0.0)
2669    .map(|(_, w)| rrf(0, *w))
2670    .sum::<f32>();
2671    if best == 0.0 {
2672        return Vec::new();
2673    }
2674    let mut out: Vec<_> = fused
2675        .into_iter()
2676        .map(|(k, (score, signals, q))| (k, score / best, signals, q))
2677        .collect();
2678    out.sort_by(|a, b| {
2679        b.1.total_cmp(&a.1)
2680            .then_with(|| {
2681                b.2.semantic
2682                    .unwrap_or(f32::MIN)
2683                    .total_cmp(&a.2.semantic.unwrap_or(f32::MIN))
2684            })
2685            .then_with(|| a.0.cmp(&b.0))
2686    });
2687    out.truncate(limit);
2688    out
2689}
2690
2691struct Fused {
2692    scored: Scored,
2693    query_index: usize,
2694}
2695
2696/// Merge per-query top-k lists, keeping each segment's best score.
2697///
2698/// Exact for the fused top-k: a segment whose best score comes from query q is
2699/// beaten on q by fewer than `limit` segments, so it is already in q's top-k.
2700fn fuse_max(per_query: impl Iterator<Item = Vec<Scored>>, limit: usize) -> Vec<Fused> {
2701    let mut best: HashMap<usize, Fused> = HashMap::new();
2702    for (query_index, hits) in per_query.enumerate() {
2703        for scored in hits {
2704            match best.get(&scored.id) {
2705                Some(b) if b.scored.score >= scored.score => {}
2706                _ => {
2707                    best.insert(
2708                        scored.id,
2709                        Fused {
2710                            scored,
2711                            query_index,
2712                        },
2713                    );
2714                }
2715            }
2716        }
2717    }
2718    let mut fused: Vec<Fused> = best.into_values().collect();
2719    fused.sort_by(|a, b| {
2720        b.scored
2721            .score
2722            .total_cmp(&a.scored.score)
2723            .then(a.scored.id.cmp(&b.scored.id))
2724    });
2725    fused.truncate(limit);
2726    fused
2727}
2728
2729pub struct IndexRequest<'e> {
2730    engine: &'e SearchEngine,
2731    paths: Vec<PathBuf>,
2732    roots: Vec<ResourceId>,
2733    discovery: DiscoveryOptions,
2734    progress: Option<ProgressFn<'e>>,
2735}
2736
2737impl<'e> IndexRequest<'e> {
2738    /// Add provider-specific roots without filesystem path conversion.
2739    pub fn roots(mut self, roots: impl IntoIterator<Item = ResourceId>) -> Self {
2740        self.roots.extend(roots);
2741        self
2742    }
2743
2744    /// Add filesystem-only paths, canonicalized locally at execution even with
2745    /// a custom provider. Use `roots()` for generic provider identities.
2746    pub fn paths<I, P>(mut self, paths: I) -> Self
2747    where
2748        I: IntoIterator<Item = P>,
2749        P: AsRef<Path>,
2750    {
2751        self.paths
2752            .extend(paths.into_iter().map(|p| expand_tilde(p.as_ref())));
2753        self
2754    }
2755
2756    pub fn discovery(mut self, opts: DiscoveryOptions) -> Self {
2757        self.discovery = opts;
2758        self
2759    }
2760
2761    pub fn progress(mut self, f: ProgressFn<'e>) -> Self {
2762        self.progress = Some(f);
2763        self
2764    }
2765
2766    /// Embed everything not yet cached and drop cache entries for files that
2767    /// were deleted under the indexed directories.
2768    pub fn execute(self) -> Result<IndexReport> {
2769        let started = Instant::now();
2770        let engine = self.engine;
2771        if self.paths.is_empty() && self.roots.is_empty() {
2772            return Err(Error::Config("no paths to index".into()));
2773        }
2774        let mut discovery = self.discovery;
2775        discovery.max_audio_duration_secs = engine.max_audio_duration_secs;
2776        let scope = ResourceScope::from_paths(&self.paths, self.roots, discovery)?;
2777        let listing = engine.provider.list_walked(&scope)?;
2778        report_walked(self.progress, listing.walked_roots);
2779        let resources = listing.resources;
2780        if let Some(p) = self.progress {
2781            p(ProgressEvent::Discovered {
2782                resources: resources.len(),
2783            });
2784        }
2785        let indexing = engine.acquire_indexing_lease()?;
2786        let mut sync = engine.sync(&indexing, &resources, self.progress)?;
2787
2788        let pid = engine.profile_id.as_str();
2789        let present: HashSet<&ResourceId> = resources.iter().map(|r| &r.id).collect();
2790        let known = engine.with_cache(|c| c.resource_index(pid))?;
2791        // A skipped resource may never have acquired a cache row. Include those
2792        // identities in the same conservative, scope-aware deletion checks.
2793        let mut candidates: HashSet<_> = known.keys().cloned().collect();
2794        candidates.extend(
2795            engine
2796                .skipped_versions
2797                .lock()
2798                .map_err(|_| Error::Config("skip outcomes lock poisoned".into()))?
2799                .keys()
2800                .cloned(),
2801        );
2802        let mut gone = Vec::new();
2803        match engine.provider.deletion_scope(&scope) {
2804            Ok(deletion_scope) => {
2805                for id in candidates {
2806                    if present.contains(&id) || !deletion_scope.contains(&id) {
2807                        continue;
2808                    }
2809                    match engine.provider.is_deleted(&id, &deletion_scope) {
2810                        Ok(true) => gone.push(id),
2811                        Ok(false) => {}
2812                        Err(e) => sync.skipped.push(skip(&id, format!("deletion check: {e}"))),
2813                    }
2814                }
2815            }
2816            Err(e) => {
2817                for root in &scope.roots {
2818                    sync.skipped
2819                        .push(skip(root, format!("deletion scope: {e}")));
2820                }
2821            }
2822        }
2823        let removed = engine.with_cache(|c| {
2824            c.remove_resources(pid, &gone)?;
2825            if !gone.is_empty() {
2826                c.prune_orphans(pid)?;
2827            }
2828            Ok(gone.iter().filter(|id| known.contains_key(*id)).count())
2829        })?;
2830
2831        if !gone.is_empty() {
2832            let gone: HashSet<_> = gone.iter().collect();
2833            engine
2834                .skipped_versions
2835                .lock()
2836                .map_err(|_| Error::Config("skip outcomes lock poisoned".into()))?
2837                .retain(|resource, _| !gone.contains(resource));
2838            engine
2839                .pdf_diagnostics
2840                .lock()
2841                .map_err(|_| Error::Config("PDF diagnostics lock poisoned".into()))?
2842                .retain(|resource, _| !gone.contains(resource));
2843        }
2844        sync.skipped_counts = skipped_counts(&sync.skipped);
2845        Ok(IndexReport {
2846            sync,
2847            resources_removed: removed,
2848            total_ms: started.elapsed().as_millis() as u64,
2849        })
2850    }
2851}
2852
2853/// Reads previews, at most once per file per search.
2854#[derive(Default)]
2855struct PreviewReader {
2856    /// Each file read once per search, with the digest of exactly those bytes.
2857    files: HashMap<ResourceId, Option<ResourceBytes>>,
2858    #[cfg(feature = "pdf")]
2859    pages: HashMap<(ResourceId, u32), Option<String>>,
2860    #[cfg(feature = "pdf")]
2861    page_bytes: usize,
2862    #[cfg(all(test, feature = "pdf"))]
2863    page_decodes: usize,
2864}
2865
2866impl PreviewReader {
2867    fn preview(
2868        &mut self,
2869        provider: &dyn ResourceProvider,
2870        path: &ResourceId,
2871        locator: &ResourceLocator,
2872        meta: &ResourceMetadata,
2873        max_chars: usize,
2874    ) -> Option<String> {
2875        if !matches!(
2876            locator,
2877            ResourceLocator::Text { .. } | ResourceLocator::PageText { .. }
2878        ) {
2879            return None;
2880        }
2881        let read = self
2882            .files
2883            .entry(path.clone())
2884            .or_insert_with(|| ResourceBytes::read(provider, path).ok())
2885            .as_ref()?;
2886        let bytes = read.verify(path, meta).ok()?;
2887        let text = match locator {
2888            ResourceLocator::Text { .. } => text_range(bytes, path, locator).ok()?,
2889            #[cfg(feature = "pdf")]
2890            ResourceLocator::PageText { page, .. }
2891                if meta.media_type.as_deref() == Some("application/pdf") =>
2892            {
2893                let key = (path.clone(), *page);
2894                if !self.pages.contains_key(&key) {
2895                    #[cfg(test)]
2896                    {
2897                        self.page_decodes += 1;
2898                    }
2899                    let text = crate::pdf::read_page(bytes, path, *page).ok();
2900                    if let Some(t) = &text {
2901                        if self.page_bytes + t.len() > crate::pdf::MAX_DOCUMENT_TEXT_BYTES {
2902                            self.pages.insert(key, None);
2903                            return None;
2904                        }
2905                        self.page_bytes += t.len();
2906                    }
2907                    self.pages.insert(key.clone(), text);
2908                }
2909                crate::pdf::slice(self.pages.get(&key)?.as_deref()?, path, locator).ok()?
2910            }
2911            _ => return None,
2912        };
2913        Some(make_preview(text, max_chars))
2914    }
2915}
2916
2917/// Collapse whitespace, drop leading heading lines and cut at a word boundary.
2918pub fn make_preview(text: &str, max_chars: usize) -> String {
2919    let body: Vec<&str> = text
2920        .lines()
2921        .skip_while(|l| l.trim_start().starts_with('#') || l.trim().is_empty())
2922        .collect();
2923    let source = if body.is_empty() {
2924        text.to_string()
2925    } else {
2926        body.join(" ")
2927    };
2928    let collapsed = source.split_whitespace().collect::<Vec<_>>().join(" ");
2929    if collapsed.chars().count() <= max_chars {
2930        return collapsed;
2931    }
2932    let cut: String = collapsed.chars().take(max_chars).collect();
2933    let cut = cut.rsplit_once(' ').map(|(a, _)| a).unwrap_or(&cut);
2934    format!("{cut}…")
2935}
2936
2937/// Expand a leading `~/` to the home directory (the shell does not do this inside quotes).
2938pub fn expand_tilde(p: &Path) -> PathBuf {
2939    match p.strip_prefix("~") {
2940        Ok(rest) => dirs::home_dir()
2941            .map(|h| h.join(rest))
2942            .unwrap_or_else(|| p.to_path_buf()),
2943        Err(_) => p.to_path_buf(),
2944    }
2945}
2946
2947#[cfg(test)]
2948mod tests {
2949    use super::*;
2950
2951    #[cfg(feature = "pdf")]
2952    #[test]
2953    fn clean_pdf_replacement_is_reused_without_stale_diagnostic_skips() {
2954        use crate::pdf::fixture::{Kind, pdf};
2955        let dir = tempfile::tempdir().unwrap();
2956        let path = dir.path().join("changing.pdf");
2957        let engine = SearchEngine::lexical().unwrap();
2958        std::fs::write(&path, crate::pdf::fixture::malformed_cmap(false)).unwrap();
2959        let a = engine.index().paths([&path]).execute().unwrap();
2960        assert_eq!(a.sync.skipped.len(), 1);
2961        assert!(!a.sync.page_diagnostics.is_empty());
2962        assert_eq!(a.sync.progress.unwrap().by_kind["pdf"].skipped, 1);
2963        assert_eq!(engine.pdf_diagnostics.lock().unwrap().len(), 1);
2964        std::fs::write(&path, pdf(&["Clean version B with basil."], Kind::Plain)).unwrap();
2965        let b = engine.index().paths([&path]).execute().unwrap();
2966        assert!(b.sync.skipped.is_empty());
2967        assert!(b.sync.page_diagnostics.is_empty());
2968        assert!(engine.pdf_diagnostics.lock().unwrap().is_empty());
2969        let warm = engine.index().paths([&path]).execute().unwrap();
2970        assert_eq!(warm.sync.resources_unchanged, 1);
2971        assert_eq!(warm.sync.segments_embedded, 0);
2972        assert!(warm.sync.skipped.is_empty());
2973        assert!(warm.sync.page_diagnostics.is_empty());
2974        let progress = warm.sync.progress.unwrap();
2975        let counts = &progress.by_kind["pdf"];
2976        assert_eq!((counts.indexed, counts.waiting, counts.skipped), (1, 0, 0));
2977        assert_eq!(progress.eta_seconds, None);
2978    }
2979
2980    #[cfg(feature = "pdf")]
2981    fn diagnostic_target(resource: &ResourceId, descriptor: &str, page: u32) -> Pending {
2982        Pending {
2983            descriptor_hash: descriptor.into(),
2984            resource: resource.clone(),
2985            version: ResourceVersion::unknown(),
2986            metadata: ResourceMetadata {
2987                media_type: Some("application/pdf".into()),
2988                modality: crate::Modality::Document,
2989                size_bytes: 0,
2990                modified_ns: None,
2991                content_hash: String::new(),
2992            },
2993            jobs: Vec::new(),
2994            page_diagnostics: vec![PageDiagnostic {
2995                code: SkipCode::PdfParseFailed,
2996                resource: resource.clone(),
2997                page,
2998                reason: descriptor.into(),
2999            }],
3000        }
3001    }
3002
3003    #[cfg(feature = "pdf")]
3004    #[test]
3005    fn pdf_diagnostics_replace_descriptors_and_clear_empty_results() {
3006        let engine = SearchEngine::lexical().unwrap();
3007        let resource = "mem://pdf/first".parse().unwrap();
3008        let other = "mem://pdf/other".parse().unwrap();
3009        let a = diagnostic_target(&resource, "A", 1);
3010        let mut b = diagnostic_target(&resource, "B", 2);
3011        let unrelated = diagnostic_target(&other, "other", 3);
3012        engine
3013            .remember_pdf_diagnostics(&a, Some(skip(&resource, "A skip")))
3014            .unwrap();
3015        engine.remember_pdf_diagnostics(&unrelated, None).unwrap();
3016        let b_skip = skip(&resource, "B skip");
3017        engine
3018            .remember_pdf_diagnostics(&b, Some(b_skip.clone()))
3019            .unwrap();
3020        let mut stats = SyncStats::default();
3021        engine
3022            .replay_pdf_diagnostics(&resource, "A", &mut stats)
3023            .unwrap();
3024        assert!(stats.page_diagnostics.is_empty());
3025        assert!(stats.skipped.is_empty());
3026        engine
3027            .replay_pdf_diagnostics(&resource, "B", &mut stats)
3028            .unwrap();
3029        assert_eq!(stats.page_diagnostics, b.page_diagnostics);
3030        assert_eq!(stats.skipped, vec![b_skip]);
3031        b.page_diagnostics.clear();
3032        engine.remember_pdf_diagnostics(&b, None).unwrap();
3033        let mut stats = SyncStats::default();
3034        engine
3035            .replay_pdf_diagnostics(&resource, "B", &mut stats)
3036            .unwrap();
3037        assert!(stats.page_diagnostics.is_empty());
3038        assert!(stats.skipped.is_empty());
3039        engine
3040            .replay_pdf_diagnostics(&other, "other", &mut stats)
3041            .unwrap();
3042        assert_eq!(stats.page_diagnostics, unrelated.page_diagnostics);
3043        assert!(stats.skipped.is_empty());
3044    }
3045
3046    #[cfg(feature = "pdf")]
3047    #[test]
3048    fn deletion_clears_pdf_diagnostics_only_for_deleted_resources() {
3049        let root = tempfile::tempdir().unwrap();
3050        let deleted = root.path().join("deleted.pdf");
3051        let kept = root.path().join("kept.pdf");
3052        for path in [&deleted, &kept] {
3053            std::fs::write(path, b"%PDF invalid").unwrap();
3054        }
3055        let engine = SearchEngine::lexical().unwrap();
3056        engine.index().paths([root.path()]).execute().unwrap();
3057        let deleted_id = ResourceId::from_path(deleted.canonicalize().unwrap()).unwrap();
3058        let kept_id = ResourceId::from_path(kept.canonicalize().unwrap()).unwrap();
3059        assert_eq!(engine.pdf_diagnostics.lock().unwrap().len(), 2);
3060        std::fs::remove_file(deleted).unwrap();
3061        engine.index().paths([root.path()]).execute().unwrap();
3062        let diagnostics = engine.pdf_diagnostics.lock().unwrap();
3063        assert!(!diagnostics.contains_key(&deleted_id));
3064        assert!(diagnostics.contains_key(&kept_id));
3065    }
3066
3067    #[test]
3068    fn skipped_versions_stay_bounded_after_delete_cycles() {
3069        // Based on the review allocator probe: new identities in every cycle,
3070        // including skipped-only resources that never had cache bindings.
3071        let root = tempfile::tempdir().unwrap();
3072        let engine = SearchEngine::lexical().unwrap();
3073        for batch in 0..3 {
3074            for i in 0..1000 {
3075                std::fs::write(root.path().join(format!("bad-{batch}-{i}.txt")), [0xff]).unwrap();
3076            }
3077            let report = engine.index().paths([root.path()]).execute().unwrap();
3078            assert_eq!(report.sync.skipped.len(), 1000);
3079            assert_eq!(engine.skipped_versions.lock().unwrap().len(), 1000);
3080            for i in 0..1000 {
3081                std::fs::remove_file(root.path().join(format!("bad-{batch}-{i}.txt"))).unwrap();
3082            }
3083            let report = engine.index().paths([root.path()]).execute().unwrap();
3084            assert_eq!(report.sync.resources_discovered, 0);
3085            assert_eq!(report.resources_removed, 0, "no cached resources existed");
3086            assert!(engine.skipped_versions.lock().unwrap().is_empty());
3087        }
3088    }
3089
3090    #[test]
3091    fn skipped_versions_prune_only_confirmed_deletions_in_scope() {
3092        let first = tempfile::tempdir().unwrap();
3093        let second = tempfile::tempdir().unwrap();
3094        let deleted = first.path().join("deleted.txt");
3095        let excluded = first.path().join("excluded.txt");
3096        let outside = second.path().join("outside.txt");
3097        for path in [&deleted, &excluded, &outside] {
3098            std::fs::write(path, [0xff]).unwrap();
3099        }
3100        let engine = SearchEngine::lexical().unwrap();
3101        engine
3102            .index()
3103            .paths([first.path(), second.path()])
3104            .execute()
3105            .unwrap();
3106        let excluded_id = ResourceId::from_path(excluded.canonicalize().unwrap()).unwrap();
3107        let outside_id = ResourceId::from_path(outside.canonicalize().unwrap()).unwrap();
3108        std::fs::remove_file(deleted).unwrap();
3109        std::fs::remove_file(outside).unwrap();
3110        let report = engine
3111            .index()
3112            .paths([first.path()])
3113            .discovery(DiscoveryOptions {
3114                exclude: vec!["excluded.txt".into()],
3115                ..Default::default()
3116            })
3117            .execute()
3118            .unwrap();
3119        assert_eq!(report.sync.resources_discovered, 0);
3120        {
3121            let skipped = engine.skipped_versions.lock().unwrap();
3122            assert_eq!(skipped.len(), 2);
3123            assert!(
3124                skipped.contains_key(&excluded_id),
3125                "excluded is not deleted"
3126            );
3127            assert!(
3128                skipped.contains_key(&outside_id),
3129                "other scopes must survive"
3130            );
3131        }
3132        engine.index().paths([second.path()]).execute().unwrap();
3133        let skipped = engine.skipped_versions.lock().unwrap();
3134        assert_eq!(skipped.len(), 1);
3135        assert!(skipped.contains_key(&excluded_id));
3136    }
3137
3138    fn worker_engine() -> SearchEngine {
3139        struct NoInference(crate::ModelIdentity);
3140        impl Embedder for NoInference {
3141            fn identity(&self) -> &crate::ModelIdentity {
3142                &self.0
3143            }
3144            fn supports(&self, _: crate::Modality) -> bool {
3145                true
3146            }
3147            fn embed_batch(
3148                &self,
3149                _: &[EmbeddingRequest<'_>],
3150            ) -> seyk_embed::Result<Vec<Embedding>> {
3151                panic!("worker must not embed")
3152            }
3153        }
3154        SearchEngine::builder()
3155            .embedder(NoInference(crate::ModelIdentity {
3156                model_id: "test/worker".into(),
3157                revision: "1".into(),
3158                variant: "test".into(),
3159                dimension: crate::Dimension::D128,
3160                normalization: "l2".into(),
3161                query_prompt: String::new(),
3162                document_prompt: String::new(),
3163                max_tokens: 512,
3164            }))
3165            .build()
3166            .unwrap()
3167    }
3168
3169    // A waiting notification is also a barrier: the previous iteration has dropped
3170    // its strong references and guards. No wall-clock sleeps are needed in tests.
3171    fn worker_ticks() -> (
3172        std::sync::mpsc::Sender<()>,
3173        std::sync::mpsc::Receiver<()>,
3174        impl FnMut() -> bool + Send,
3175    ) {
3176        let (ticks, receive) = std::sync::mpsc::channel();
3177        let (waiting, waits) = std::sync::mpsc::channel();
3178        (ticks, waits, move || {
3179            waiting.send(()).unwrap();
3180            receive.recv().is_ok()
3181        })
3182    }
3183
3184    fn worker_wait(waits: &std::sync::mpsc::Receiver<()>) {
3185        waits
3186            .recv_timeout(std::time::Duration::from_secs(5))
3187            .unwrap();
3188    }
3189
3190    #[cfg(feature = "pdf")]
3191    #[test]
3192    fn pdf_skip_codes_come_from_variants_not_display_text() {
3193        let resource = "mem://typed".parse().unwrap();
3194        assert_eq!(
3195            pdf_skip(
3196                &resource,
3197                Error::ParserPanicked {
3198                    resource: resource.clone(),
3199                    reason: "arbitrary sanitized reason"
3200                }
3201            )
3202            .code,
3203            SkipCode::PdfParserPanicked
3204        );
3205        assert_eq!(
3206            pdf_skip(
3207                &resource,
3208                Error::PdfPageDecode {
3209                    resource: resource.clone(),
3210                    reason: "arbitrary sanitized reason"
3211                }
3212            )
3213            .code,
3214            SkipCode::PdfParseFailed
3215        );
3216        assert_eq!(
3217            skip(&resource, "PDF parser panicked: forged string").code,
3218            SkipCode::Other
3219        );
3220    }
3221
3222    #[test]
3223    fn cache_worker_duplicate_start_runs_only_one_worker() {
3224        use std::sync::atomic::AtomicUsize;
3225        let engine = worker_engine();
3226        let (ticks, waits, clock) = worker_ticks();
3227        let runs = Arc::new(AtomicUsize::new(0));
3228        let observed = runs.clone();
3229        let worker = engine
3230            .start_cache_scrubber_with(clock, move |_| {
3231                observed.fetch_add(1, Ordering::SeqCst);
3232            })
3233            .unwrap();
3234        worker_wait(&waits);
3235        assert!(
3236            engine
3237                .start_cache_scrubber_with(
3238                    || panic!("duplicate worker started"),
3239                    |_| panic!("duplicate scrub")
3240                )
3241                .is_none()
3242        );
3243        engine.start_cache_scrubber();
3244        ticks.send(()).unwrap();
3245        worker_wait(&waits);
3246        assert_eq!(runs.load(Ordering::SeqCst), 1);
3247        drop(ticks);
3248        worker.join().unwrap();
3249    }
3250
3251    #[test]
3252    fn cache_worker_excludes_active_indexing() {
3253        use std::sync::atomic::AtomicUsize;
3254        let engine = worker_engine();
3255        let (ticks, waits, clock) = worker_ticks();
3256        let runs = Arc::new(AtomicUsize::new(0));
3257        let observed = runs.clone();
3258        let worker = engine
3259            .start_cache_scrubber_with(clock, move |_| {
3260                observed.fetch_add(1, Ordering::SeqCst);
3261            })
3262            .unwrap();
3263        worker_wait(&waits);
3264        let indexing = engine.indexing.lock().unwrap();
3265        ticks.send(()).unwrap();
3266        worker_wait(&waits);
3267        assert_eq!(runs.load(Ordering::SeqCst), 0);
3268        drop(indexing);
3269        ticks.send(()).unwrap();
3270        worker_wait(&waits);
3271        assert_eq!(runs.load(Ordering::SeqCst), 1);
3272        drop(ticks);
3273        worker.join().unwrap();
3274    }
3275
3276    fn join_worker_promptly(worker: std::thread::JoinHandle<()>) {
3277        let (completed, completion) = std::sync::mpsc::channel();
3278        std::thread::spawn(move || {
3279            let _ = completed.send(worker.join());
3280        });
3281        completion
3282            .recv_timeout(std::time::Duration::from_secs(2))
3283            .expect("worker did not exit within two seconds")
3284            .unwrap();
3285    }
3286
3287    #[test]
3288    fn cache_worker_drop_interrupts_real_production_deadline_wait() {
3289        let engine = worker_engine();
3290        let weak_cache = Arc::downgrade(&engine.cache);
3291        let weak_indexing = Arc::downgrade(&engine.indexing);
3292        let stop = engine.scrubber_stop.clone();
3293        let worker = engine.spawn_cache_scrubber().unwrap();
3294        // The same production waiter will sleep for 60 seconds. Observing its
3295        // state under the mutex proves it entered the condvar wait; no tick or
3296        // wake is injected by this test. Only engine Drop may interrupt it.
3297        assert_eq!(
3298            MemoryCache::new().scrub_delay(std::time::Duration::from_secs(60)),
3299            std::time::Duration::from_secs(60)
3300        );
3301        let state = stop.state.lock().unwrap();
3302        let (state, _) = stop
3303            .wake
3304            .wait_timeout_while(state, std::time::Duration::from_secs(5), |state| {
3305                !state.waiting
3306            })
3307            .unwrap();
3308        assert!(
3309            state.waiting,
3310            "production worker did not enter its deadline wait"
3311        );
3312        assert!(!state.stopped);
3313        drop(state);
3314        drop(engine);
3315        assert!(weak_cache.upgrade().is_none());
3316        assert!(weak_indexing.upgrade().is_none());
3317        join_worker_promptly(worker);
3318    }
3319
3320    #[test]
3321    fn cache_worker_drop_while_active_finishes_only_current_iteration() {
3322        let engine = worker_engine();
3323        let weak_cache = Arc::downgrade(&engine.cache);
3324        let weak_indexing = Arc::downgrade(&engine.indexing);
3325        let (ticks, waits, clock) = worker_ticks();
3326        let (entered, active) = std::sync::mpsc::channel();
3327        let (finish, release) = std::sync::mpsc::channel();
3328        let worker = engine
3329            .start_cache_scrubber_with(clock, move |_| {
3330                entered.send(()).unwrap();
3331                release
3332                    .recv_timeout(std::time::Duration::from_secs(5))
3333                    .unwrap();
3334            })
3335            .unwrap();
3336        worker_wait(&waits);
3337        ticks.send(()).unwrap();
3338        worker_wait(&active);
3339        drop(engine);
3340        assert!(
3341            weak_cache.upgrade().is_some(),
3342            "in-flight iteration retains its cache"
3343        );
3344        assert!(weak_indexing.upgrade().is_some());
3345        finish.send(()).unwrap();
3346        join_worker_promptly(worker);
3347        assert!(
3348            weak_cache.upgrade().is_none(),
3349            "released after current iteration"
3350        );
3351        assert!(weak_indexing.upgrade().is_none());
3352        assert!(
3353            waits.try_recv().is_err(),
3354            "must not start another wait after drop"
3355        );
3356    }
3357
3358    #[cfg(any(feature = "pdf", feature = "audio"))]
3359    #[test]
3360    fn media_commit_failure_preserves_binding_and_update_count() {
3361        struct NoInference(crate::ModelIdentity);
3362        impl Embedder for NoInference {
3363            fn identity(&self) -> &crate::ModelIdentity {
3364                &self.0
3365            }
3366            fn supports(&self, _: crate::Modality) -> bool {
3367                true
3368            }
3369            fn embed_batch(
3370                &self,
3371                _: &[EmbeddingRequest<'_>],
3372            ) -> seyk_embed::Result<Vec<Embedding>> {
3373                panic!("committing prepared records must not run inference")
3374            }
3375        }
3376        let dir = tempfile::tempdir().unwrap();
3377        let path = dir.path().join("cache.db");
3378        let engine = SearchEngine::builder()
3379            .embedder(NoInference(crate::ModelIdentity {
3380                model_id: "test/media-commit".into(),
3381                revision: "1".into(),
3382                variant: "test".into(),
3383                dimension: crate::Dimension::D128,
3384                normalization: "l2".into(),
3385                query_prompt: String::new(),
3386                document_prompt: String::new(),
3387                max_tokens: 512,
3388            }))
3389            .cache(crate::SqliteCache::open(&path).unwrap())
3390            .build()
3391            .unwrap();
3392        let mut target = Pending {
3393            resource: "test://media/document".parse().unwrap(),
3394            metadata: ResourceMetadata {
3395                media_type: Some("application/pdf".into()),
3396                modality: crate::Modality::Document,
3397                size_bytes: 10,
3398                modified_ns: None,
3399                content_hash: "old-content".into(),
3400            },
3401            version: ResourceVersion::known("old-version"),
3402            descriptor_hash: "old-descriptor".into(),
3403            jobs: Vec::new(),
3404            page_diagnostics: Vec::new(),
3405        };
3406        let records = vec![(
3407            CachedSegment {
3408                locator: ResourceLocator::Page { page: 1 },
3409                heading: None,
3410                input_hash: "old-input".into(),
3411            },
3412            String::new(),
3413        )];
3414        let mut stats = SyncStats::default();
3415        engine
3416            .commit_media_resource(&target, &records, &mut stats)
3417            .unwrap();
3418        assert_eq!(stats.resources_updated, 1);
3419        let binding = || {
3420            engine
3421                .with_cache(|cache| cache.resource(engine.profile_id(), &target.resource))
3422                .unwrap()
3423                .unwrap()
3424        };
3425        let old = binding();
3426        target.metadata.content_hash = "new-content".into();
3427        target.version = ResourceVersion::known("new-version");
3428        target.descriptor_hash = "new-descriptor".into();
3429        let conn = rusqlite::Connection::open(&path).unwrap();
3430        // Fail after put_resource has deleted the old binding inside its transaction.
3431        conn.execute_batch(
3432            "CREATE TRIGGER fail_media_commit BEFORE INSERT ON resources
3433             BEGIN SELECT RAISE(ABORT, 'injected media commit failure'); END;",
3434        )
3435        .unwrap();
3436        let error = engine
3437            .commit_media_resource(&target, &[], &mut stats)
3438            .unwrap_err();
3439        assert!(matches!(error, Error::Cache(_)));
3440        assert!(error.to_string().contains("injected media commit failure"));
3441        assert_eq!(stats.resources_updated, 1);
3442        assert_eq!(binding(), old);
3443
3444        conn.execute_batch("DROP TRIGGER fail_media_commit;")
3445            .unwrap();
3446        engine
3447            .commit_media_resource(&target, &[], &mut stats)
3448            .unwrap();
3449        assert_eq!(stats.resources_updated, 2);
3450        let new = binding();
3451        assert_eq!(new.metadata, target.metadata);
3452        assert_eq!(new.version, target.version);
3453        assert_eq!(new.descriptor_hash.as_deref(), Some("new-descriptor"));
3454        assert!(new.segments.is_empty());
3455    }
3456
3457    #[test]
3458    fn keywords_count_fully_for_names_and_identifiers_only() {
3459        let w = |q: &str| keyword_weight(&[q.to_string()]);
3460        for q in [
3461            "garden.md",
3462            "Example Person",
3463            "INC-18372",
3464            "where is Session::restore() called",
3465            "invoice_38192",
3466            "pricing page",
3467        ] {
3468            assert_eq!(w(q), 1.0, "{q}");
3469        }
3470        for q in [
3471            "how can I make tomato soup for dinner",
3472            "how to grow vegetables in a small garden",
3473        ] {
3474            assert_eq!(w(q), PROSE_KEYWORD_WEIGHT, "{q}");
3475        }
3476    }
3477
3478    #[test]
3479    fn names_file_matches_name_stem_and_path_suffix_only() {
3480        let p = Path::new("/book/plants/garden.md");
3481        for q in ["garden.md", "Garden", "plants/garden.md", " `garden.md` "] {
3482            assert!(names_file(q, p), "{q}");
3483        }
3484        for q in ["arden.md", "lants/garden.md", "my garden", "garden.txt", ""] {
3485            assert!(!names_file(q, p), "{q}");
3486        }
3487    }
3488
3489    #[test]
3490    fn names_file_normalizes_both_paths_and_queries_and_preserves_boundaries() {
3491        for path in [
3492            "/book/my people/My Notes.md",
3493            r"C:\book\my people\My Notes.md",
3494        ] {
3495            let path = Path::new(path);
3496            for query in [
3497                "MY NOTES.MD",
3498                "my notes",
3499                " \"My Notes.md\" ",
3500                "`my notes`",
3501                "my people/My Notes.md",
3502                r"my people\My Notes.md",
3503            ] {
3504                assert!(names_file(query, path), "{query}: {path:?}");
3505            }
3506            for query in [
3507                "notes.md",
3508                "my notes today",
3509                "people/My Notes.md",
3510                r"people\My Notes.md",
3511                "  ``  ",
3512            ] {
3513                assert!(!names_file(query, path), "{query}: {path:?}");
3514            }
3515        }
3516        assert!(names_file(
3517            r"plants\garden.md",
3518            Path::new("/book/plants/garden.md")
3519        ));
3520        assert!(names_file(
3521            "plants/garden.md",
3522            Path::new(r"C:\book\plants\garden.md")
3523        ));
3524        assert!(!names_file("my garden", Path::new(r"C:\book\garden.md")));
3525    }
3526
3527    #[test]
3528    fn hybrid_rrf_k5_preserves_a_strong_semantic_lead() {
3529        assert_eq!(RRF_K, 5.0);
3530        let semantic: Vec<_> = (0..10)
3531            .map(|i| {
3532                let key = (format!("mem://rrf/{i}").parse().unwrap(), 0);
3533                (key, 1.0 - i as f32 * 0.05, 0)
3534            })
3535            .collect();
3536        // At k=60, the tenth semantic hit's lexical vote overtakes rank one.
3537        // At k=5, a prose keyword match still contributes without erasing that lead.
3538        let lexical = vec![(semantic[9].0.clone(), 1.0)];
3539        let hits = rank(SearchMode::Hybrid, &semantic, &lexical, 0.3, &[], 10);
3540        assert_eq!(hits[0].0, semantic[0].0);
3541        assert_eq!(hits[3].0, semantic[9].0);
3542        assert!((hits[0].1 - 10.0 / 13.0).abs() < 1e-6);
3543        assert!((hits[3].1 - 7.0 / 13.0).abs() < 1e-6);
3544    }
3545
3546    #[test]
3547    fn fusion_keeps_best_score_and_its_query() {
3548        let s = |id, score| Scored { id, score };
3549        let fused = fuse_max(
3550            [vec![s(1, 0.9), s(2, 0.5)], vec![s(2, 0.8), s(3, 0.7)]].into_iter(),
3551            2,
3552        );
3553        let got: Vec<_> = fused
3554            .iter()
3555            .map(|f| (f.scored.id, f.scored.score, f.query_index))
3556            .collect();
3557        assert_eq!(got, [(1, 0.9, 0), (2, 0.8, 1)]);
3558    }
3559
3560    #[test]
3561    fn titles() {
3562        assert_eq!(
3563            segment_title(Some("garden"), None).as_deref(),
3564            Some("garden")
3565        );
3566        assert_eq!(
3567            segment_title(Some("garden"), Some("Garden > Spring")).as_deref(),
3568            Some("Garden > Spring")
3569        );
3570        assert_eq!(
3571            segment_title(Some("notes"), Some("Kitchen")).as_deref(),
3572            Some("notes > Kitchen")
3573        );
3574    }
3575
3576    #[test]
3577    fn previews() {
3578        assert_eq!(
3579            make_preview("## Head\n\nSome   text\nhere.", 100),
3580            "Some text here."
3581        );
3582        assert_eq!(make_preview("alpha beta gamma delta", 12), "alpha beta…");
3583    }
3584
3585    #[test]
3586    fn link_targets_are_not_embedded() {
3587        assert_eq!(
3588            strip_link_targets("Sources: [main dossier](../people/Ada.md), ![map](img/map.png)."),
3589            "Sources: main dossier, !map."
3590        );
3591        assert_eq!(
3592            strip_link_targets("see [[Ada - old]] and [[Marlow|him]]"),
3593            "see Ada - old and him"
3594        );
3595        assert_eq!(
3596            strip_link_targets("an [unclosed bracket and [x] plain"),
3597            "an [unclosed bracket and [x] plain"
3598        );
3599        assert_eq!(
3600            strip_link_targets("[spaced](not a url)"),
3601            "[spaced](not a url)"
3602        );
3603    }
3604
3605    #[test]
3606    fn input_hash_is_unambiguous() {
3607        assert_ne!(
3608            input_hash(&["text", "ab", "c"]),
3609            input_hash(&["text", "a", "bc"])
3610        );
3611    }
3612}
3613
3614#[cfg(test)]
3615mod pre_split_golden {
3616    use super::*;
3617
3618    struct IdentityOnly(crate::ModelIdentity);
3619    impl Embedder for IdentityOnly {
3620        fn identity(&self) -> &crate::ModelIdentity {
3621            &self.0
3622        }
3623        fn supports(&self, _: crate::Modality) -> bool {
3624            true
3625        }
3626        fn embed_batch(
3627            &self,
3628            _: &[EmbeddingRequest<'_>],
3629        ) -> seyk_embed::Result<Vec<crate::Embedding>> {
3630            panic!("preparation must not run inference")
3631        }
3632    }
3633
3634    #[test]
3635    fn production_profile_and_prepared_input_hashes_match_pre_split() {
3636        // Captured by loading Gemma and calling this engine's build/prepare
3637        // functions in base 00c1038; see the fixture provenance README.
3638        let golden: serde_json::Value =
3639            serde_json::from_str(include_str!("../tests/fixtures/pre_split_preparation.json"))
3640                .unwrap();
3641        let identity = serde_json::from_value(golden["profile"]["model"].clone()).unwrap();
3642        let engine = SearchEngine::builder()
3643            .embedder(IdentityOnly(identity))
3644            .build()
3645            .unwrap();
3646        assert_eq!(
3647            serde_json::to_value(engine.profile()).unwrap(),
3648            golden["profile"]
3649        );
3650        assert_eq!(engine.profile_id(), golden["profile_id"].as_str().unwrap());
3651        for (name, title, kind, bytes) in [
3652            (
3653                "titled",
3654                Some("Guide"),
3655                ResourceKind::Text(TextFormat::Markdown),
3656                b"# Setup\n\nRead the [manual](https://example.com/manual) before installing.\n"
3657                    .as_slice(),
3658            ),
3659            (
3660                "untitled",
3661                None,
3662                ResourceKind::Text(TextFormat::Plain),
3663                b"A short note with no document title.\n".as_slice(),
3664            ),
3665            (
3666                "image",
3667                Some("diagram"),
3668                ResourceKind::Image,
3669                b"\x89PNG\r\n\x1a\nsynthetic golden bytes".as_slice(),
3670            ),
3671        ] {
3672            let resource = ProvidedResource {
3673                id: format!("test://golden/{name}").parse().unwrap(),
3674                kind,
3675                size_bytes: bytes.len() as u64,
3676                modified_ns: None,
3677                version: ResourceVersion::unknown(),
3678                title: title.map(String::from),
3679            };
3680            let (jobs, _) = engine.prepare(&resource, bytes.to_vec()).unwrap();
3681            assert_eq!(jobs.len(), 1);
3682            assert_eq!(
3683                jobs[0].segment.input_hash,
3684                golden["input_hashes"][name].as_str().unwrap(),
3685                "{name}"
3686            );
3687        }
3688    }
3689    #[cfg(feature = "pdf")]
3690    #[test]
3691    fn preview_decodes_a_budget_rejected_page_once_and_keeps_smaller_pages_available() {
3692        use crate::pdf::fixture::{Kind, pdf};
3693
3694        let dir = tempfile::tempdir().unwrap();
3695        // Content ids are canonical; macOS temp directories sit behind /var.
3696        let root = dir.path().canonicalize().unwrap();
3697        let fixture = |name: &str, texts: &[&str]| {
3698            let path = root.join(name);
3699            let source = ResourceId::from_path(&path).unwrap();
3700            let bytes = pdf(texts, Kind::Plain);
3701            std::fs::write(&path, &bytes).unwrap();
3702            let meta = ResourceMetadata {
3703                media_type: Some("application/pdf".into()),
3704                modality: crate::Modality::Document,
3705                size_bytes: bytes.len() as u64,
3706                modified_ns: None,
3707                content_hash: blake3::hash(&bytes).to_hex().to_string(),
3708            };
3709            let pages = crate::pdf::read_pages(&bytes, &source).unwrap();
3710            (source, meta, pages)
3711        };
3712        let (large, large_meta, large_pages) = fixture(
3713            "large.pdf",
3714            &["This page exceeds the remaining preview budget.", "basil"],
3715        );
3716        let (small, small_meta, small_pages) = fixture("small.pdf", &["mint"]);
3717        let locator = |page: &crate::pdf::PageText| ResourceLocator::PageText {
3718            page: page.page,
3719            byte_start: 0,
3720            byte_end: page.text.len() as u64,
3721            text_version: crate::pdf::TEXT_VERSION.into(),
3722            page_text_hash: blake3::hash(page.text.as_bytes()).to_hex().to_string(),
3723        };
3724        // Simulate earlier admitted pages leaving room for just the two small pages.
3725        let remaining = large_pages[1].text.len() + small_pages[0].text.len();
3726        assert!(large_pages[0].text.len() > remaining);
3727        let initial_bytes = crate::pdf::MAX_DOCUMENT_TEXT_BYTES - remaining;
3728        let mut reader = PreviewReader {
3729            page_bytes: initial_bytes,
3730            ..Default::default()
3731        };
3732        for _ in 0..2 {
3733            assert!(
3734                reader
3735                    .preview(
3736                        &crate::FsProvider,
3737                        &large,
3738                        &locator(&large_pages[0]),
3739                        &large_meta,
3740                        200
3741                    )
3742                    .is_none()
3743            );
3744        }
3745        assert_eq!(reader.page_decodes, 1);
3746        assert_eq!(reader.page_bytes, initial_bytes);
3747
3748        // Reject only the page, not the whole resource or the remaining request.
3749        for (source, meta, page, expected) in [
3750            (&large, &large_meta, &large_pages[1], "basil"),
3751            (&small, &small_meta, &small_pages[0], "mint"),
3752        ] {
3753            let valid = locator(page);
3754            let mut invalid = valid.clone();
3755            if let ResourceLocator::PageText { byte_end, .. } = &mut invalid {
3756                *byte_end += 1;
3757            }
3758            assert!(
3759                reader
3760                    .preview(&crate::FsProvider, source, &invalid, meta, 200)
3761                    .is_none()
3762            );
3763            assert_eq!(
3764                reader
3765                    .preview(&crate::FsProvider, source, &valid, meta, 200)
3766                    .as_deref(),
3767                Some(expected)
3768            );
3769        }
3770        assert_eq!(reader.page_decodes, 3);
3771        assert_eq!(reader.page_bytes, crate::pdf::MAX_DOCUMENT_TEXT_BYTES);
3772        assert!(
3773            reader
3774                .preview(
3775                    &crate::FsProvider,
3776                    &large,
3777                    &locator(&large_pages[0]),
3778                    &large_meta,
3779                    200
3780                )
3781                .is_none()
3782        );
3783        assert_eq!(reader.page_decodes, 3);
3784    }
3785
3786    #[cfg(feature = "pdf")]
3787    #[test]
3788    fn preview_rejects_a_valid_page_in_a_corrupt_resource() {
3789        use crate::pdf::fixture::{Kind, pdf};
3790        let dir = tempfile::tempdir().unwrap();
3791        let path = dir.path().canonicalize().unwrap().join("broken.pdf");
3792        let source = ResourceId::from_path(&path).unwrap();
3793        let text =
3794            crate::pdf::read_page(&pdf(&["garden basil", "second"], Kind::Plain), &source, 1)
3795                .unwrap();
3796        let locator = ResourceLocator::PageText {
3797            page: 1,
3798            byte_start: 0,
3799            byte_end: text.len() as u64,
3800            text_version: crate::pdf::TEXT_VERSION.into(),
3801            page_text_hash: blake3::hash(text.as_bytes()).to_hex().to_string(),
3802        };
3803        for kind in [Kind::MissingContents, Kind::TruncatedContents] {
3804            let bytes = pdf(&["garden basil", "second"], kind);
3805            std::fs::write(&path, &bytes).unwrap();
3806            let meta = ResourceMetadata {
3807                media_type: Some("application/pdf".into()),
3808                modality: crate::Modality::Document,
3809                size_bytes: bytes.len() as u64,
3810                modified_ns: None,
3811                content_hash: blake3::hash(&bytes).to_hex().to_string(),
3812            };
3813            assert!(
3814                PreviewReader::default()
3815                    .preview(&crate::FsProvider, &source, &locator, &meta, 200)
3816                    .is_none()
3817            );
3818        }
3819    }
3820}