Skip to main content

hermes_core/index/
searcher.rs

1//! Searcher - read-only search over pre-built segments
2//!
3//! This module provides `Searcher` for read-only search access to indexes.
4//! It can be used standalone (for wasm/read-only) or via `IndexReader` (for native).
5
6use std::sync::Arc;
7
8use rustc_hash::FxHashMap;
9
10use crate::directories::Directory;
11use crate::dsl::Schema;
12use crate::error::Result;
13use crate::query::LazyGlobalStats;
14use crate::segment::{SegmentId, SegmentReader, TrainedVectorStructures};
15#[cfg(feature = "native")]
16use crate::segment::{SegmentSnapshot, SegmentTracker};
17
18/// Immutable resources that must stay identical across `IndexReader` reloads.
19/// The search pool exists only when synchronous scoring is compiled in; native
20/// async-only builds retain the cache policy without spawning unused threads.
21#[cfg(feature = "native")]
22#[derive(Clone)]
23pub(crate) struct SearcherResources {
24    pub(crate) term_cache_blocks: usize,
25    pub(crate) store_cache_blocks: usize,
26    #[cfg(feature = "sync")]
27    pub(crate) search_pool: Arc<rayon::ThreadPool>,
28}
29
30#[cfg(feature = "native")]
31impl SearcherResources {
32    pub(crate) fn new(
33        term_cache_blocks: usize,
34        store_cache_blocks: usize,
35        num_threads: usize,
36    ) -> Result<Self> {
37        if num_threads == 0 {
38            return Err(crate::Error::Internal(
39                "IndexConfig.num_threads must be greater than zero".into(),
40            ));
41        }
42
43        #[cfg(feature = "sync")]
44        let search_pool = super::shared_search_pool(num_threads)?;
45
46        Ok(Self {
47            term_cache_blocks,
48            store_cache_blocks,
49            #[cfg(feature = "sync")]
50            search_pool,
51        })
52    }
53}
54
55/// Searcher - provides search over loaded segments
56///
57/// For wasm/read-only use, create via `Searcher::open()`.
58/// For native use with Index, this is created via `IndexReader`.
59pub struct Searcher<D: Directory + 'static> {
60    /// Segment snapshot holding refs - prevents deletion during native use
61    #[cfg(feature = "native")]
62    _snapshot: SegmentSnapshot,
63    /// PhantomData for the directory generic
64    _phantom: std::marker::PhantomData<D>,
65    /// Loaded segment readers
66    segments: Vec<Arc<SegmentReader>>,
67    /// Schema
68    schema: Arc<Schema>,
69    /// Default fields for search
70    default_fields: Vec<crate::Field>,
71    /// Tokenizers
72    tokenizers: Arc<crate::tokenizer::TokenizerRegistry>,
73    /// One immutable generation of all index-global ANN artifacts.
74    trained_vectors: Arc<TrainedVectorStructures>,
75    /// Lazy global statistics for cross-segment IDF computation
76    global_stats: Arc<LazyGlobalStats>,
77    /// O(1) segment lookup by segment_id
78    segment_map: FxHashMap<u128, usize>,
79    /// Total document count across all segments
80    total_docs: u32,
81    /// Bounded process-wide-by-width pool for the complete nested search tree.
82    #[cfg(feature = "sync")]
83    search_pool: Arc<rayon::ThreadPool>,
84}
85
86impl<D: Directory + 'static> Searcher<D> {
87    /// Create a Searcher directly from segment IDs
88    ///
89    /// This is a simpler initialization path that doesn't require SegmentManager.
90    /// Use this for read-only access to pre-built indexes.
91    pub async fn open(
92        directory: Arc<D>,
93        schema: Arc<Schema>,
94        segment_ids: &[String],
95        term_cache_blocks: usize,
96    ) -> Result<Self> {
97        Self::open_with_cache_blocks(
98            directory,
99            schema,
100            segment_ids,
101            term_cache_blocks,
102            term_cache_blocks,
103        )
104        .await
105    }
106
107    /// Create a read-only searcher with independent cache capacities.
108    pub async fn open_with_cache_blocks(
109        directory: Arc<D>,
110        schema: Arc<Schema>,
111        segment_ids: &[String],
112        term_cache_blocks: usize,
113        store_cache_blocks: usize,
114    ) -> Result<Self> {
115        Self::create(
116            directory,
117            schema,
118            segment_ids,
119            Arc::new(TrainedVectorStructures::default()),
120            term_cache_blocks,
121            store_cache_blocks,
122        )
123        .await
124    }
125
126    /// Create from a snapshot (for native IndexReader use)
127    #[cfg(feature = "native")]
128    pub(crate) async fn from_snapshot(
129        directory: Arc<D>,
130        schema: Arc<Schema>,
131        snapshot: SegmentSnapshot,
132        trained_vectors: Arc<TrainedVectorStructures>,
133        resources: SearcherResources,
134    ) -> Result<Self> {
135        let (segments, default_fields, global_stats, segment_map, total_docs) = Self::load_common(
136            &directory,
137            &schema,
138            snapshot.segment_ids(),
139            &trained_vectors,
140            resources.term_cache_blocks,
141            resources.store_cache_blocks,
142            &[],
143        )
144        .await?;
145
146        Ok(Self {
147            _snapshot: snapshot,
148            _phantom: std::marker::PhantomData,
149            segments,
150            schema,
151            default_fields,
152            tokenizers: Arc::new(crate::tokenizer::TokenizerRegistry::default()),
153            trained_vectors,
154            global_stats,
155            segment_map,
156            total_docs,
157            #[cfg(feature = "sync")]
158            search_pool: resources.search_pool,
159        })
160    }
161
162    /// Create from a snapshot, reusing existing segment readers for unchanged segments.
163    /// This avoids re-opening mmaps, fast fields, sparse indexes, etc. for segments
164    /// that weren't touched by merge.
165    #[cfg(feature = "native")]
166    pub(crate) async fn from_snapshot_reuse(
167        directory: Arc<D>,
168        schema: Arc<Schema>,
169        snapshot: SegmentSnapshot,
170        trained_vectors: Arc<TrainedVectorStructures>,
171        resources: SearcherResources,
172        existing_segments: &[Arc<SegmentReader>],
173    ) -> Result<Self> {
174        let (segments, default_fields, global_stats, segment_map, total_docs) = Self::load_common(
175            &directory,
176            &schema,
177            snapshot.segment_ids(),
178            &trained_vectors,
179            resources.term_cache_blocks,
180            resources.store_cache_blocks,
181            existing_segments,
182        )
183        .await?;
184
185        Ok(Self {
186            _snapshot: snapshot,
187            _phantom: std::marker::PhantomData,
188            segments,
189            schema,
190            default_fields,
191            tokenizers: Arc::new(crate::tokenizer::TokenizerRegistry::default()),
192            trained_vectors,
193            global_stats,
194            segment_map,
195            total_docs,
196            #[cfg(feature = "sync")]
197            search_pool: resources.search_pool,
198        })
199    }
200
201    /// Internal create method
202    async fn create(
203        directory: Arc<D>,
204        schema: Arc<Schema>,
205        segment_ids: &[String],
206        trained_vectors: Arc<TrainedVectorStructures>,
207        term_cache_blocks: usize,
208        store_cache_blocks: usize,
209    ) -> Result<Self> {
210        let (segments, default_fields, global_stats, segment_map, total_docs) = Self::load_common(
211            &directory,
212            &schema,
213            segment_ids,
214            &trained_vectors,
215            term_cache_blocks,
216            store_cache_blocks,
217            &[],
218        )
219        .await?;
220
221        #[cfg(feature = "native")]
222        let _snapshot = {
223            let tracker = Arc::new(SegmentTracker::new());
224            SegmentSnapshot::new(tracker, segment_ids.to_vec())
225        };
226
227        #[cfg(feature = "sync")]
228        let search_pool = super::shared_search_pool(crate::default_search_threads())?;
229
230        let _ = directory; // suppress unused warning on wasm
231        Ok(Self {
232            #[cfg(feature = "native")]
233            _snapshot,
234            _phantom: std::marker::PhantomData,
235            segments,
236            schema,
237            default_fields,
238            tokenizers: Arc::new(crate::tokenizer::TokenizerRegistry::default()),
239            trained_vectors,
240            global_stats,
241            segment_map,
242            total_docs,
243            #[cfg(feature = "sync")]
244            search_pool,
245        })
246    }
247
248    /// Common loading logic shared by create and from_snapshot
249    async fn load_common(
250        directory: &Arc<D>,
251        schema: &Arc<Schema>,
252        segment_ids: &[String],
253        trained_vectors: &Arc<TrainedVectorStructures>,
254        term_cache_blocks: usize,
255        store_cache_blocks: usize,
256        existing_segments: &[Arc<SegmentReader>],
257    ) -> Result<(
258        Vec<Arc<SegmentReader>>,
259        Vec<crate::Field>,
260        Arc<LazyGlobalStats>,
261        FxHashMap<u128, usize>,
262        u32,
263    )> {
264        let segments = Self::load_segments(
265            directory,
266            schema,
267            segment_ids,
268            trained_vectors,
269            term_cache_blocks,
270            store_cache_blocks,
271            existing_segments,
272        )
273        .await?;
274        let default_fields = Self::build_default_fields(schema);
275        let global_stats = Arc::new(LazyGlobalStats::new(segments.clone()));
276        let (segment_map, total_docs) = Self::build_lookup_tables(&segments);
277        Ok((
278            segments,
279            default_fields,
280            global_stats,
281            segment_map,
282            total_docs,
283        ))
284    }
285
286    /// Load segment readers from IDs (parallel loading for performance).
287    /// Reuses existing segment readers for unchanged segments when `existing_segments`
288    /// is non-empty — avoids re-opening mmaps, fast fields, sparse indexes, etc.
289    async fn load_segments(
290        directory: &Arc<D>,
291        schema: &Arc<Schema>,
292        segment_ids: &[String],
293        trained_vectors: &Arc<TrainedVectorStructures>,
294        term_cache_blocks: usize,
295        store_cache_blocks: usize,
296        existing_segments: &[Arc<SegmentReader>],
297    ) -> Result<Vec<Arc<SegmentReader>>> {
298        // Build lookup from existing segment readers for reuse
299        let existing_map: FxHashMap<u128, Arc<SegmentReader>> = existing_segments
300            .iter()
301            .map(|seg| (seg.meta().id, Arc::clone(seg)))
302            .collect();
303
304        // Parse segment IDs from metadata. A key that fails to parse means the
305        // metadata is corrupt; fail loud instead of silently serving results
306        // without that segment's documents (the merge path errors with
307        // Corruption on the same input — search must not disagree).
308        let mut valid_segments: Vec<(usize, SegmentId)> = Vec::with_capacity(segment_ids.len());
309        for (idx, id_str) in segment_ids.iter().enumerate() {
310            let sid = SegmentId::from_hex(id_str).ok_or_else(|| {
311                crate::error::Error::Corruption(format!(
312                    "Invalid segment ID in metadata: {id_str:?}"
313                ))
314            })?;
315            valid_segments.push((idx, sid));
316        }
317
318        // Separate into reusable and new segments
319        let mut reused: Vec<(usize, Arc<SegmentReader>)> = Vec::new();
320        let mut to_load: Vec<(usize, SegmentId)> = Vec::new();
321        for (idx, sid) in &valid_segments {
322            if let Some(existing) = existing_map.get(&sid.0) {
323                reused.push((*idx, Arc::clone(existing)));
324            } else {
325                to_load.push((*idx, *sid));
326            }
327        }
328
329        if !existing_segments.is_empty() {
330            log::info!(
331                "[searcher] reusing {} segment readers, loading {} new",
332                reused.len(),
333                to_load.len(),
334            );
335        }
336
337        // Load only NEW segments in parallel
338        let futures: Vec<_> = to_load
339            .iter()
340            .map(|(_, segment_id)| {
341                let dir = Arc::clone(directory);
342                let sch = Arc::clone(schema);
343                let sid = *segment_id;
344                async move {
345                    SegmentReader::open_with_cache_blocks(
346                        dir.as_ref(),
347                        sid,
348                        sch,
349                        term_cache_blocks,
350                        store_cache_blocks,
351                    )
352                    .await
353                }
354            })
355            .collect();
356
357        let results = futures::future::join_all(futures).await;
358
359        // Collect newly loaded results — fail fast if any segment fails to open
360        let mut loaded: Vec<(usize, Arc<SegmentReader>)> = Vec::with_capacity(valid_segments.len());
361
362        // Add reused segments
363        loaded.extend(reused);
364
365        // Add newly loaded segments
366        for ((idx, sid), result) in to_load.into_iter().zip(results) {
367            match result {
368                Ok(mut reader) => {
369                    // Inject the single immutable index-level artifact generation.
370                    reader.set_trained_vectors(Arc::clone(trained_vectors));
371                    loaded.push((idx, Arc::new(reader)));
372                }
373                Err(e) => {
374                    return Err(crate::error::Error::Internal(format!(
375                        "Failed to open segment {:016x}: {:?}",
376                        sid.0, e
377                    )));
378                }
379            }
380        }
381
382        // Sort by original index to maintain deterministic ordering
383        loaded.sort_by_key(|(idx, _)| *idx);
384
385        let segments: Vec<Arc<SegmentReader>> = loaded.into_iter().map(|(_, seg)| seg).collect();
386
387        // Keep heap, file-backed address space, and pinned residency separate.
388        // Mapped bytes are not necessarily resident; process RSS is the
389        // authoritative whole-process residency measurement.
390        let total_docs: u64 = segments.iter().map(|s| s.meta().num_docs as u64).sum();
391        let mut total_heap = 0usize;
392        let mut total_file_backed = 0u64;
393        let mut total_pinned = 0u64;
394        let mut total_pin_intended = 0u64;
395        for seg in &segments {
396            let stats = seg.memory_stats();
397            let heap = stats.estimated_heap_bytes();
398            let file_backed = stats.file_backed_bytes();
399            total_heap = total_heap.saturating_add(heap);
400            total_file_backed = total_file_backed.saturating_add(file_backed);
401            total_pinned = total_pinned.saturating_add(stats.pinned_metadata_bytes);
402            total_pin_intended = total_pin_intended.saturating_add(stats.pin_intended_bytes);
403            log::info!(
404                "[searcher] segment {:016x}: docs={}, heap_estimate={} \
405                 (term_cache={}, store_cache={}, sparse_vectors={}, dense_vectors={}), \
406                 file_backed={} (term_bloom={}, sparse_vectors={}, dense_vectors={}), \
407                 pinned_metadata={} of {} eligible \
408                 (sparse_vectors={} of {}, dense_vectors={} of {})",
409                stats.segment_id,
410                stats.num_docs,
411                crate::format_bytes(heap as u64),
412                crate::format_bytes(stats.term_dict_cache_bytes as u64),
413                crate::format_bytes(stats.store_cache_bytes as u64),
414                crate::format_bytes(stats.sparse_heap_bytes as u64),
415                crate::format_bytes(stats.dense_heap_bytes as u64),
416                crate::format_bytes(file_backed),
417                crate::format_bytes(stats.term_bloom_file_bytes),
418                crate::format_bytes(stats.sparse_file_backed_bytes),
419                crate::format_bytes(stats.dense_file_backed_bytes),
420                crate::format_bytes(stats.pinned_metadata_bytes),
421                crate::format_bytes(stats.pin_intended_bytes),
422                crate::format_bytes(stats.sparse_pinned_metadata_bytes),
423                crate::format_bytes(stats.sparse_pin_intended_bytes),
424                crate::format_bytes(stats.dense_pinned_metadata_bytes),
425                crate::format_bytes(stats.dense_pin_intended_bytes),
426            );
427        }
428        // Log process RSS if available (helps diagnose OOM)
429        let rss_bytes = process_rss_bytes();
430        log::info!(
431            "[searcher] loaded {} segments: total_docs={}, heap_estimate={}, \
432             file_backed={}, pinned_metadata={} of {} eligible, process_rss={}",
433            segments.len(),
434            total_docs,
435            crate::format_bytes(total_heap as u64),
436            crate::format_bytes(total_file_backed),
437            crate::format_bytes(total_pinned),
438            crate::format_bytes(total_pin_intended),
439            crate::format_bytes(rss_bytes),
440        );
441
442        Ok(segments)
443    }
444
445    /// Build default fields from schema
446    fn build_default_fields(schema: &Schema) -> Vec<crate::Field> {
447        if !schema.default_fields().is_empty() {
448            schema.default_fields().to_vec()
449        } else {
450            schema
451                .fields()
452                .filter(|(_, entry)| {
453                    entry.indexed && entry.field_type == crate::dsl::FieldType::Text
454                })
455                .map(|(field, _)| field)
456                .collect()
457        }
458    }
459
460    /// Get the schema
461    pub fn schema(&self) -> &Schema {
462        &self.schema
463    }
464
465    /// Get segment readers
466    pub fn segment_readers(&self) -> &[Arc<SegmentReader>] {
467        &self.segments
468    }
469
470    /// Get default fields for search
471    pub fn default_fields(&self) -> &[crate::Field] {
472        &self.default_fields
473    }
474
475    /// Get tokenizer registry
476    pub fn tokenizers(&self) -> &crate::tokenizer::TokenizerRegistry {
477        &self.tokenizers
478    }
479
480    /// Get trained centroids
481    pub fn trained_centroids(&self) -> &FxHashMap<u32, Arc<crate::structures::CoarseCentroids>> {
482        &self.trained_vectors.centroids
483    }
484
485    pub fn trained_binary_quantizers(
486        &self,
487    ) -> &FxHashMap<u32, Arc<crate::structures::BinaryCoarseQuantizer>> {
488        &self.trained_vectors.binary_quantizers
489    }
490
491    /// Get lazy global statistics for cross-segment IDF computation
492    pub fn global_stats(&self) -> &Arc<LazyGlobalStats> {
493        &self.global_stats
494    }
495
496    /// Build O(1) lookup tables from loaded segments
497    fn build_lookup_tables(segments: &[Arc<SegmentReader>]) -> (FxHashMap<u128, usize>, u32) {
498        let mut segment_map = FxHashMap::default();
499        let mut total = 0u32;
500        for (i, seg) in segments.iter().enumerate() {
501            segment_map.insert(seg.meta().id, i);
502            total = total.saturating_add(seg.meta().num_docs);
503        }
504        (segment_map, total)
505    }
506
507    /// Get total document count across all segments
508    pub fn num_docs(&self) -> u32 {
509        self.total_docs
510    }
511
512    /// Get O(1) segment_id → index map (used by reranker)
513    pub fn segment_map(&self) -> &FxHashMap<u128, usize> {
514        &self.segment_map
515    }
516
517    /// Run a bounded piece of CPU work inside this index's shared search pool.
518    #[cfg(feature = "sync")]
519    pub(crate) fn install_search_cpu<R: Send>(&self, operation: impl FnOnce() -> R + Send) -> R {
520        self.search_pool.install(operation)
521    }
522
523    /// Async-only/WASM builds execute inline because Rayon is not available.
524    /// Keeping this overload free of `Send` bounds allows browser-backed file
525    /// handles, whose callbacks are deliberately thread-local, to be scored.
526    #[cfg(not(feature = "sync"))]
527    pub(crate) fn install_search_cpu<R>(&self, operation: impl FnOnce() -> R) -> R {
528        operation()
529    }
530
531    /// Get number of segments
532    pub fn num_segments(&self) -> usize {
533        self.segments.len()
534    }
535
536    /// Get a document by (segment_id, local_doc_id)
537    pub async fn doc(&self, segment_id: u128, doc_id: u32) -> Result<Option<crate::dsl::Document>> {
538        if let Some(&idx) = self.segment_map.get(&segment_id) {
539            return self.segments[idx].doc(doc_id).await;
540        }
541        Ok(None)
542    }
543
544    /// Search across all segments and return aggregated results
545    pub async fn search(
546        &self,
547        query: &dyn crate::query::Query,
548        limit: usize,
549    ) -> Result<Vec<crate::query::SearchResult>> {
550        let (results, _) = self.search_with_count(query, limit).await?;
551        Ok(results)
552    }
553
554    /// Search across all segments and return (results, total_seen)
555    /// total_seen is the number of documents that were scored across all segments
556    pub async fn search_with_count(
557        &self,
558        query: &dyn crate::query::Query,
559        limit: usize,
560    ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
561        self.search_with_offset_and_count(query, limit, 0).await
562    }
563
564    /// Search with offset for pagination
565    pub async fn search_with_offset(
566        &self,
567        query: &dyn crate::query::Query,
568        limit: usize,
569        offset: usize,
570    ) -> Result<Vec<crate::query::SearchResult>> {
571        let (results, _) = self
572            .search_with_offset_and_count(query, limit, offset)
573            .await?;
574        Ok(results)
575    }
576
577    /// Search with offset and return (results, total_seen)
578    pub async fn search_with_offset_and_count(
579        &self,
580        query: &dyn crate::query::Query,
581        limit: usize,
582        offset: usize,
583    ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
584        self.search_internal(query, limit, offset, false).await
585    }
586
587    /// Search with positions (ordinal tracking) and return (results, total_seen)
588    ///
589    /// Use this when you need per-ordinal scores for multi-valued fields.
590    pub async fn search_with_positions(
591        &self,
592        query: &dyn crate::query::Query,
593        limit: usize,
594    ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
595        self.search_internal(query, limit, 0, true).await
596    }
597
598    /// Build the paper's single query-level top-γ superblock set, then project
599    /// it back onto segment-local plans.
600    ///
601    /// Treating every immutable segment as an independent LSP index would
602    /// multiply work by the segment count. The prepass retains one global γ
603    /// while preserving Hermes's streaming segment architecture.
604    fn prepare_global_lsp(
605        &self,
606        query: &dyn crate::query::Query,
607        retrieval_depth: usize,
608        parallel: bool,
609    ) -> Result<Vec<Option<std::sync::Arc<crate::query::bmp::LspSegmentPlan>>>> {
610        let empty = || vec![None; self.segments.len()];
611        if retrieval_depth == 0 {
612            return Ok(empty());
613        }
614        let crate::query::QueryDecomposition::SparseTerms(infos) = query.decompose() else {
615            return Ok(empty());
616        };
617        let Some(first) = infos.first() else {
618            return Ok(empty());
619        };
620        if infos
621            .iter()
622            .any(|info| info.field != first.field || info.lsp_gamma != first.lsp_gamma)
623        {
624            return Ok(empty());
625        }
626        let field = first.field;
627        let (total_superblocks, planning_depth) = self
628            .segments
629            .iter()
630            .filter_map(|segment| segment.bmp_index(field))
631            .fold((0usize, retrieval_depth), |(total, depth), bmp| {
632                (
633                    total.saturating_add(bmp.num_superblocks as usize),
634                    depth.max(crate::query::bmp_executor_limit(
635                        retrieval_depth,
636                        first.over_fetch_factor,
637                        bmp,
638                    )),
639                )
640            });
641        let gamma = first
642            .lsp_gamma
643            .unwrap_or_else(|| crate::query::bmp::recommended_lsp_gamma(planning_depth));
644        if gamma == 0 {
645            return Ok(empty());
646        }
647        if total_superblocks == 0 || gamma >= total_superblocks {
648            // A cap covering the whole index is exhaustive. Let each segment
649            // compute and traverse its local order once instead of building a
650            // query-global heap and retaining an all-superblock plan.
651            return Ok(empty());
652        }
653        let candidate_terms: Vec<(u32, f32)> = infos
654            .iter()
655            .filter(|info| info.candidate)
656            .map(|info| (info.dim_id, info.weight))
657            .collect();
658        if candidate_terms.is_empty() {
659            return Ok(empty());
660        }
661        let scoring_terms: Vec<(u32, f32)> = infos
662            .iter()
663            .map(|info| (info.dim_id, info.weight))
664            .collect();
665        let prepare = |segment: &std::sync::Arc<crate::segment::SegmentReader>| {
666            segment
667                .bmp_index(field)
668                .map(|bmp| {
669                    crate::query::bmp::prepare_lsp_superblock_ubs(
670                        bmp,
671                        &candidate_terms,
672                        &scoring_terms,
673                    )
674                })
675                .transpose()
676        };
677
678        #[cfg(feature = "sync")]
679        let bounds: Vec<Option<Vec<f32>>> = if parallel {
680            use rayon::prelude::*;
681            self.search_pool.install(|| {
682                self.segments
683                    .par_iter()
684                    .map(prepare)
685                    .collect::<Result<Vec<_>>>()
686            })?
687        } else {
688            self.segments
689                .iter()
690                .map(prepare)
691                .collect::<Result<Vec<_>>>()?
692        };
693        #[cfg(not(feature = "sync"))]
694        let bounds: Vec<Option<Vec<f32>>> = {
695            let _ = parallel;
696            self.segments
697                .iter()
698                .map(prepare)
699                .collect::<Result<Vec<_>>>()?
700        };
701
702        let mut selected = select_global_lsp_superblocks(&bounds, gamma);
703
704        let mut plans = Vec::with_capacity(self.segments.len());
705        for (segment, segment_bounds) in bounds.into_iter().enumerate() {
706            let Some(segment_bounds) = segment_bounds else {
707                plans.push(None);
708                continue;
709            };
710            selected[segment].sort_unstable_by(|&left, &right| {
711                segment_bounds[right as usize]
712                    .total_cmp(&segment_bounds[left as usize])
713                    .then_with(|| left.cmp(&right))
714            });
715            let selected_bounds = selected[segment]
716                .iter()
717                .map(|&superblock| segment_bounds[superblock as usize])
718                .collect();
719            plans.push(Some(std::sync::Arc::new(
720                crate::query::bmp::LspSegmentPlan {
721                    sb_ubs: selected_bounds,
722                    sb_order: std::mem::take(&mut selected[segment]),
723                },
724            )));
725        }
726        Ok(plans)
727    }
728
729    /// Internal search implementation
730    async fn search_internal(
731        &self,
732        query: &dyn crate::query::Query,
733        limit: usize,
734        offset: usize,
735        collect_positions: bool,
736    ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
737        let fetch_limit = checked_search_window(limit, offset)?;
738
739        // Use rayon + block_in_place for CPU-bound scoring (sync feature required).
740        // Offloads the scoring loop from tokio workers so search doesn't starve
741        // other async tasks. Works for any segment count (rayon degrades gracefully
742        // to inline execution for a single segment).
743        // Only works on multi-threaded tokio runtime (block_in_place panics on current_thread).
744        #[cfg(feature = "sync")]
745        if !self.segments.is_empty()
746            && tokio::runtime::Handle::current().runtime_flavor()
747                == tokio::runtime::RuntimeFlavor::MultiThread
748        {
749            return self.search_internal_parallel(query, fetch_limit, offset, collect_positions);
750        }
751
752        // No segments, no sync feature, or current_thread runtime: use an
753        // explicitly bounded async stream. Starting every segment at once can
754        // retain `segments × top_k` results while the slowest I/O completes.
755        const MAX_ASYNC_SEGMENT_SEARCHES: usize = 8;
756        use futures::StreamExt;
757        use futures::TryStreamExt;
758        // Cross-segment top-k floor (see search_internal_sync). Concurrent
759        // segments share it via an atomic; ordering is best-effort.
760        let shared = crate::query::SharedThreshold::new();
761        let lsp_plans = self.prepare_global_lsp(query, fetch_limit, false)?;
762        let searches = futures::stream::iter(self.segments.iter().cloned().zip(lsp_plans).map(
763            |(segment, lsp_plan)| {
764                let sid = segment.meta().id;
765                let shared = shared.clone();
766                async move {
767                    let (mut results, segment_seen) = crate::query::search_segment_shared_planned(
768                        segment.as_ref(),
769                        query,
770                        fetch_limit,
771                        collect_positions,
772                        shared.clone(),
773                        lsp_plan,
774                    )
775                    .await?;
776                    if fetch_limit > 0 && results.len() >= fetch_limit {
777                        shared.raise(results[fetch_limit - 1].score);
778                    }
779                    // Stamp segment_id on each result
780                    for r in &mut results {
781                        r.segment_id = sid;
782                    }
783                    Ok::<_, crate::error::Error>((results, segment_seen))
784                }
785            },
786        ))
787        .buffer_unordered(MAX_ASYNC_SEGMENT_SEARCHES);
788        futures::pin_mut!(searches);
789
790        let mut total_seen: u32 = 0;
791        let mut merged = Vec::new();
792        while let Some((batch, segment_seen)) = searches.try_next().await? {
793            total_seen = total_seen.saturating_add(segment_seen);
794            merged = merge_two_ranked(merged, batch, fetch_limit);
795        }
796
797        let results = apply_result_offset(merged, fetch_limit, offset);
798        Ok((results, total_seen))
799    }
800
801    /// Multi-segment parallel search using rayon (CPU-bound scoring on thread pool).
802    ///
803    /// `block_in_place` tells tokio this worker is occupied so it can steal tasks.
804    /// `rayon::par_iter` distributes segment scoring across the rayon thread pool.
805    #[cfg(feature = "sync")]
806    fn search_internal_parallel(
807        &self,
808        query: &dyn crate::query::Query,
809        fetch_limit: usize,
810        offset: usize,
811        collect_positions: bool,
812    ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
813        tokio::task::block_in_place(|| {
814            self.search_internal_sync(query, fetch_limit, offset, collect_positions)
815        })
816    }
817
818    /// Sync body of the parallel search: rayon par_iter over segments.
819    /// Callers must already be off the async reactor (block_in_place or a
820    /// rayon/blocking thread) — safe to nest inside another par_iter
821    /// (rayon work-stealing composes).
822    #[cfg(feature = "sync")]
823    fn search_internal_sync(
824        &self,
825        query: &dyn crate::query::Query,
826        fetch_limit: usize,
827        offset: usize,
828        collect_positions: bool,
829    ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
830        use rayon::prelude::*;
831
832        let lsp_plans = self.prepare_global_lsp(query, fetch_limit, true)?;
833        // Cross-segment top-k floor: each segment seeds its pruning from the
834        // running global k-th score and raises it once it fills its own heap.
835        let shared = crate::query::SharedThreshold::new();
836        let (merged, total_seen) = self.search_pool.install(|| {
837            self.segments
838                .par_iter()
839                .zip(lsp_plans.par_iter())
840                .map(|(segment, lsp_plan)| {
841                    let sid = segment.meta().id;
842                    let (mut results, segment_seen) =
843                        crate::query::search_segment_shared_sync_planned(
844                            segment.as_ref(),
845                            query,
846                            fetch_limit,
847                            collect_positions,
848                            shared.clone(),
849                            lsp_plan.clone(),
850                        )?;
851                    if fetch_limit > 0 && results.len() >= fetch_limit {
852                        shared.raise(results[fetch_limit - 1].score);
853                    }
854                    for r in &mut results {
855                        r.segment_id = sid;
856                    }
857                    Ok::<_, crate::Error>((results, segment_seen))
858                })
859                .try_reduce(
860                    || (Vec::new(), 0u32),
861                    |(left, left_seen), (right, right_seen)| {
862                        Ok((
863                            merge_two_ranked(left, right, fetch_limit),
864                            left_seen.saturating_add(right_seen),
865                        ))
866                    },
867                )
868        })?;
869
870        let results = apply_result_offset(merged, fetch_limit, offset);
871        Ok((results, total_seen))
872    }
873
874    /// Synchronous search across all segments using rayon for parallelism.
875    ///
876    /// This is the async-free boundary — no tokio involvement from here down.
877    #[cfg(feature = "sync")]
878    pub fn search_with_offset_and_count_sync(
879        &self,
880        query: &dyn crate::query::Query,
881        limit: usize,
882        offset: usize,
883    ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
884        use rayon::prelude::*;
885
886        let fetch_limit = checked_search_window(limit, offset)?;
887
888        let lsp_plans = self.prepare_global_lsp(query, fetch_limit, true)?;
889        // Cross-segment top-k floor (see search_internal_sync).
890        let shared = crate::query::SharedThreshold::new();
891        let (merged, total_seen) = self.search_pool.install(|| {
892            self.segments
893                .par_iter()
894                .zip(lsp_plans.par_iter())
895                .map(|(segment, lsp_plan)| {
896                    let sid = segment.meta().id;
897                    let (mut results, segment_seen) =
898                        crate::query::search_segment_shared_sync_planned(
899                            segment.as_ref(),
900                            query,
901                            fetch_limit,
902                            false,
903                            shared.clone(),
904                            lsp_plan.clone(),
905                        )?;
906                    if fetch_limit > 0 && results.len() >= fetch_limit {
907                        shared.raise(results[fetch_limit - 1].score);
908                    }
909                    for r in &mut results {
910                        r.segment_id = sid;
911                    }
912                    Ok::<_, crate::Error>((results, segment_seen))
913                })
914                .try_reduce(
915                    || (Vec::new(), 0u32),
916                    |(left, left_seen), (right, right_seen)| {
917                        Ok((
918                            merge_two_ranked(left, right, fetch_limit),
919                            left_seen.saturating_add(right_seen),
920                        ))
921                    },
922                )
923        })?;
924
925        let results = apply_result_offset(merged, fetch_limit, offset);
926        Ok((results, total_seen))
927    }
928
929    /// Hybrid search: run several queries independently and fuse their
930    /// ranked lists (union) into a single top-`limit` result.
931    ///
932    /// Unlike [`Self::search_and_rerank`] — which can only re-score
933    /// documents the first-stage query already found — fusion keeps
934    /// documents found by *any* of the queries. Typical use is sparse
935    /// (BM25/SPLADE) + dense vector hybrid retrieval with
936    /// `FusionMethod::Rrf { k: 60.0 }`.
937    ///
938    /// Fusion happens at **chunk granularity**: per-ordinal scores are
939    /// collected from each sub-query, fused per `(doc, ordinal)` key, then
940    /// combined into a doc score with `combiner`
941    /// (`MultiValueCombiner::Max` recommended — same-chunk corroboration
942    /// across verticals compounds, scattered noise does not). Fused results
943    /// carry per-chunk `positions`.
944    ///
945    /// Each query is paired with a weight scaling its contribution.
946    /// `fetch_limit` is the per-query candidate depth. Request-facing adapters
947    /// default to at most [`crate::query::MAX_CANDIDATE_OVERSUBSCRIPTION`] times
948    /// the result window and never multiply an existing rerank pool again.
949    pub async fn search_fused(
950        &self,
951        queries: &[(&dyn crate::query::Query, f32)],
952        fetch_limit: usize,
953        limit: usize,
954        method: crate::query::FusionMethod,
955        combiner: crate::query::MultiValueCombiner,
956    ) -> Result<Vec<crate::query::SearchResult>> {
957        let (results, _) = self
958            .search_fused_with_count(queries, fetch_limit, limit, method, combiner)
959            .await?;
960        Ok(results)
961    }
962
963    /// Fusion variant that also returns the aggregate number of documents
964    /// scored by all sub-queries. This lets request-facing callers use the
965    /// parallel fusion path without rerunning sub-queries for observability.
966    pub async fn search_fused_with_count(
967        &self,
968        queries: &[(&dyn crate::query::Query, f32)],
969        fetch_limit: usize,
970        limit: usize,
971        method: crate::query::FusionMethod,
972        combiner: crate::query::MultiValueCombiner,
973    ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
974        if queries.is_empty() {
975            return Err(crate::Error::Query(
976                "fusion requires at least one sub-query".to_string(),
977            ));
978        }
979        if queries.len() > crate::query::MAX_FUSION_SUB_QUERIES {
980            return Err(crate::Error::Query(format!(
981                "fusion supports at most {} sub-queries, got {}",
982                crate::query::MAX_FUSION_SUB_QUERIES,
983                queries.len()
984            )));
985        }
986        if fetch_limit == 0 {
987            return Err(crate::Error::Query(
988                "fusion fetch_limit must be greater than zero".to_string(),
989            ));
990        }
991        let candidate_slots = fetch_limit
992            .checked_mul(queries.len())
993            .ok_or_else(|| crate::Error::Query("fusion candidate budget overflow".to_string()))?;
994        if candidate_slots > crate::query::MAX_FUSION_CANDIDATE_SLOTS {
995            return Err(crate::Error::Query(format!(
996                "fusion candidate budget must not exceed {}, got {candidate_slots}",
997                crate::query::MAX_FUSION_CANDIDATE_SLOTS
998            )));
999        }
1000        for (index, &(_, weight)) in queries.iter().enumerate() {
1001            if !weight.is_finite() || weight < 0.0 {
1002                return Err(crate::Error::Query(format!(
1003                    "fusion query weight at index {index} must be finite and non-negative, \
1004                     got {weight}"
1005                )));
1006            }
1007        }
1008        if let crate::query::FusionMethod::Rrf { k } = method
1009            && (!k.is_finite() || k < 0.0)
1010        {
1011            return Err(crate::Error::Query(format!(
1012                "fusion RRF k must be finite and non-negative, got {k}"
1013            )));
1014        }
1015        combiner.validate().map_err(crate::Error::Query)?;
1016
1017        // Each sub-query already fans out across every segment. Keep fusion
1018        // sequential at the outer level so queries do not contend for the
1019        // same rayon pool, mmap pages, and memory bandwidth, and so each
1020        // query's shared threshold converges as early as possible.
1021        #[cfg(feature = "sync")]
1022        if !self.segments.is_empty()
1023            && tokio::runtime::Handle::current().runtime_flavor()
1024                == tokio::runtime::RuntimeFlavor::MultiThread
1025        {
1026            let lists: Vec<(Vec<crate::query::SearchResult>, f32, u32)> =
1027                tokio::task::block_in_place(|| {
1028                    queries
1029                        .iter()
1030                        .map(|&(query, weight)| {
1031                            let (results, seen) =
1032                                self.search_internal_sync(query, fetch_limit, 0, true)?;
1033                            Ok((results, weight, seen))
1034                        })
1035                        .collect::<Result<Vec<_>>>()
1036                })?;
1037            let mut total_seen = 0u32;
1038            let ranked_lists = lists
1039                .into_iter()
1040                .map(|(results, weight, seen)| {
1041                    total_seen = total_seen.saturating_add(seen);
1042                    (results, weight)
1043                })
1044                .collect();
1045            let fused =
1046                crate::query::try_fuse_ranked_lists_chunked(ranked_lists, method, combiner, limit)
1047                    .map_err(crate::Error::Query)?;
1048            return Ok((fused, total_seen));
1049        }
1050
1051        // Async/current-thread fallback uses the same outer execution shape
1052        // and preserves input list order for deterministic rank ties.
1053        let mut lists = Vec::with_capacity(queries.len());
1054        for &(query, weight) in queries {
1055            let (results, seen) = self.search_with_positions(query, fetch_limit).await?;
1056            lists.push((results, weight, seen));
1057        }
1058        let mut total_seen = 0u32;
1059        let ranked_lists = lists
1060            .into_iter()
1061            .map(|(results, weight, seen)| {
1062                total_seen = total_seen.saturating_add(seen);
1063                (results, weight)
1064            })
1065            .collect();
1066        let fused =
1067            crate::query::try_fuse_ranked_lists_chunked(ranked_lists, method, combiner, limit)
1068                .map_err(crate::Error::Query)?;
1069        Ok((fused, total_seen))
1070    }
1071
1072    /// Two-stage search: L1 retrieval + L2 dense vector reranking
1073    ///
1074    /// Runs the query to get `l1_limit` candidates, then reranks by exact
1075    /// dense vector distance and returns the top `final_limit` results.
1076    pub async fn search_and_rerank(
1077        &self,
1078        query: &dyn crate::query::Query,
1079        l1_limit: usize,
1080        final_limit: usize,
1081        config: &crate::query::RerankerConfig,
1082    ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
1083        let (candidates, total_seen) = self.search_with_count(query, l1_limit).await?;
1084        let reranked = crate::query::rerank(self, &candidates, config, final_limit).await?;
1085        Ok((reranked, total_seen))
1086    }
1087
1088    /// Parse query string and search (convenience method)
1089    pub async fn query(
1090        &self,
1091        query_str: &str,
1092        limit: usize,
1093    ) -> Result<crate::query::SearchResponse> {
1094        self.query_offset(query_str, limit, 0).await
1095    }
1096
1097    /// Parse query string and search with offset (convenience method)
1098    pub async fn query_offset(
1099        &self,
1100        query_str: &str,
1101        limit: usize,
1102        offset: usize,
1103    ) -> Result<crate::query::SearchResponse> {
1104        let parser = self.query_parser();
1105        let query = parser
1106            .parse(query_str)
1107            .map_err(crate::error::Error::Query)?;
1108
1109        let (results, _total_seen) = self
1110            .search_internal(query.as_ref(), limit, offset, false)
1111            .await?;
1112
1113        let total_hits = results.len() as u32;
1114        let hits: Vec<crate::query::SearchHit> = results
1115            .into_iter()
1116            .map(|result| crate::query::SearchHit {
1117                address: crate::query::DocAddress::new(result.segment_id, result.doc_id),
1118                score: result.score,
1119                matched_fields: result.extract_ordinals(),
1120            })
1121            .collect();
1122
1123        Ok(crate::query::SearchResponse { hits, total_hits })
1124    }
1125
1126    /// Get query parser for this searcher
1127    pub fn query_parser(&self) -> crate::dsl::QueryLanguageParser {
1128        let query_routers = self.schema.query_routers();
1129        if !query_routers.is_empty()
1130            && let Ok(router) = crate::dsl::QueryFieldRouter::from_rules(query_routers)
1131        {
1132            return crate::dsl::QueryLanguageParser::with_router(
1133                Arc::clone(&self.schema),
1134                self.default_fields.clone(),
1135                Arc::clone(&self.tokenizers),
1136                router,
1137            );
1138        }
1139
1140        crate::dsl::QueryLanguageParser::new(
1141            Arc::clone(&self.schema),
1142            self.default_fields.clone(),
1143            Arc::clone(&self.tokenizers),
1144        )
1145    }
1146
1147    /// Get a document by address (segment_id + local doc_id)
1148    pub async fn get_document(
1149        &self,
1150        address: &crate::query::DocAddress,
1151    ) -> Result<Option<crate::dsl::Document>> {
1152        self.get_document_with_fields(address, None).await
1153    }
1154
1155    /// Get a document by address, hydrating only the specified field IDs.
1156    ///
1157    /// If `fields` is `None`, all fields are hydrated (including dense vectors).
1158    /// If `fields` is `Some(set)`, only dense vector fields in the set are read
1159    /// from flat storage — skipping expensive mmap reads for unrequested vectors.
1160    pub async fn get_document_with_fields(
1161        &self,
1162        address: &crate::query::DocAddress,
1163        fields: Option<&rustc_hash::FxHashSet<u32>>,
1164    ) -> Result<Option<crate::dsl::Document>> {
1165        let segment_id = address.segment_id_u128().ok_or_else(|| {
1166            crate::error::Error::Query(format!("Invalid segment ID: {}", address.segment_id()))
1167        })?;
1168
1169        if let Some(&idx) = self.segment_map.get(&segment_id) {
1170            return self.segments[idx]
1171                .doc_with_fields(address.doc_id, fields)
1172                .await;
1173        }
1174
1175        Ok(None)
1176    }
1177}
1178
1179/// Select one query-level top-γ set without allocating one tuple per
1180/// superblock. Prepared BMP bounds are finite and non-negative, so their f32
1181/// bit patterns have the same order as their numeric values.
1182fn select_global_lsp_superblocks(bounds: &[Option<Vec<f32>>], gamma: usize) -> Vec<Vec<u32>> {
1183    let mut top =
1184        std::collections::BinaryHeap::<std::cmp::Reverse<(u32, usize, u32)>>::with_capacity(
1185            gamma.min(65_536),
1186        );
1187    for (segment, segment_bounds) in bounds.iter().enumerate() {
1188        let Some(segment_bounds) = segment_bounds else {
1189            continue;
1190        };
1191        for (superblock, &bound) in segment_bounds.iter().enumerate() {
1192            if bound <= 0.0 {
1193                continue;
1194            }
1195            let candidate = (bound.to_bits(), segment, superblock as u32);
1196            if top.len() < gamma {
1197                top.push(std::cmp::Reverse(candidate));
1198            } else if top.peek().is_some_and(|minimum| candidate > minimum.0) {
1199                top.pop();
1200                top.push(std::cmp::Reverse(candidate));
1201            }
1202        }
1203    }
1204
1205    let mut selected = vec![Vec::<u32>::new(); bounds.len()];
1206    for std::cmp::Reverse((_, segment, superblock)) in top {
1207        selected[segment].push(superblock);
1208    }
1209    selected
1210}
1211
1212/// Merge two canonically sorted batches while moving (not cloning) hits.
1213/// Reductions use this eagerly, so retained cross-segment results stay O(k)
1214/// instead of O(number_of_segments × k).
1215fn merge_two_ranked(
1216    left: Vec<crate::query::SearchResult>,
1217    right: Vec<crate::query::SearchResult>,
1218    limit: usize,
1219) -> Vec<crate::query::SearchResult> {
1220    let mut left = left.into_iter().peekable();
1221    let mut right = right.into_iter().peekable();
1222    let mut merged = Vec::with_capacity(limit.min(left.len().saturating_add(right.len())));
1223
1224    while merged.len() < limit {
1225        let take_left = match (left.peek(), right.peek()) {
1226            (Some(left), Some(right)) => {
1227                !crate::query::compare_search_results_desc(left, right).is_gt()
1228            }
1229            (Some(_), None) => true,
1230            (None, Some(_)) => false,
1231            (None, None) => break,
1232        };
1233        if take_left {
1234            merged.push(left.next().expect("peeked left result"));
1235        } else {
1236            merged.push(right.next().expect("peeked right result"));
1237        }
1238    }
1239    merged
1240}
1241
1242fn apply_result_offset(
1243    results: Vec<crate::query::SearchResult>,
1244    fetch_limit: usize,
1245    offset: usize,
1246) -> Vec<crate::query::SearchResult> {
1247    results
1248        .into_iter()
1249        .skip(offset)
1250        .take(fetch_limit.saturating_sub(offset))
1251        .collect()
1252}
1253
1254fn checked_search_window(limit: usize, offset: usize) -> Result<usize> {
1255    offset
1256        .checked_add(limit)
1257        .ok_or_else(|| crate::Error::Query("search offset + limit overflow".into()))
1258}
1259
1260/// Get current process RSS in bytes (best-effort, returns zero on failure).
1261fn process_rss_bytes() -> u64 {
1262    #[cfg(target_os = "linux")]
1263    {
1264        // Read from /proc/self/status — VmRSS line
1265        if let Ok(status) = std::fs::read_to_string("/proc/self/status") {
1266            for line in status.lines() {
1267                if let Some(rest) = line.strip_prefix("VmRSS:") {
1268                    let kib: u64 = rest
1269                        .trim()
1270                        .trim_end_matches("kB")
1271                        .trim()
1272                        .parse()
1273                        .unwrap_or(0);
1274                    return kib.saturating_mul(1024);
1275                }
1276            }
1277        }
1278        0
1279    }
1280    #[cfg(target_os = "macos")]
1281    {
1282        // Use mach_task_self / task_info via raw syscall
1283        use std::mem;
1284        #[repr(C)]
1285        struct TaskBasicInfo {
1286            virtual_size: u64,
1287            resident_size: u64,
1288            resident_size_max: u64,
1289            user_time: [u32; 2],
1290            system_time: [u32; 2],
1291            policy: i32,
1292            suspend_count: i32,
1293        }
1294        unsafe extern "C" {
1295            fn mach_task_self() -> u32;
1296            fn task_info(task: u32, flavor: u32, info: *mut TaskBasicInfo, count: *mut u32) -> i32;
1297        }
1298        const MACH_TASK_BASIC_INFO: u32 = 20;
1299        let mut info: TaskBasicInfo = unsafe { mem::zeroed() };
1300        let mut count = (mem::size_of::<TaskBasicInfo>() / mem::size_of::<u32>()) as u32;
1301        let ret = unsafe {
1302            task_info(
1303                mach_task_self(),
1304                MACH_TASK_BASIC_INFO,
1305                &mut info,
1306                &mut count,
1307            )
1308        };
1309        if ret == 0 { info.resident_size } else { 0 }
1310    }
1311    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1312    {
1313        0
1314    }
1315}
1316
1317#[cfg(test)]
1318mod load_segments_tests {
1319    use super::*;
1320
1321    #[tokio::test]
1322    async fn searcher_open_fails_loud_on_corrupt_metadata_segment_id() {
1323        let directory = Arc::new(crate::directories::RamDirectory::new());
1324        let schema = Arc::new(crate::dsl::SchemaBuilder::default().build());
1325
1326        let result =
1327            Searcher::open(directory, schema, &["not-a-hex-segment-id".to_string()], 8).await;
1328
1329        match result {
1330            Ok(searcher) => panic!(
1331                "corrupt segment ID must fail loud instead of silently serving {} segments",
1332                searcher.segment_readers().len()
1333            ),
1334            Err(crate::error::Error::Corruption(message)) => {
1335                assert!(message.contains("not-a-hex-segment-id"), "{message}");
1336            }
1337            Err(other) => panic!("expected Corruption error for invalid segment ID, got: {other}"),
1338        }
1339    }
1340}
1341
1342#[cfg(test)]
1343mod search_window_tests {
1344    use super::{
1345        apply_result_offset, checked_search_window, merge_two_ranked, select_global_lsp_superblocks,
1346    };
1347    use crate::query::SearchResult;
1348
1349    fn result(segment_id: u128, doc_id: u32, score: f32) -> SearchResult {
1350        SearchResult {
1351            doc_id,
1352            score,
1353            segment_id,
1354            positions: Vec::new(),
1355        }
1356    }
1357
1358    #[test]
1359    fn search_window_is_checked() {
1360        assert_eq!(checked_search_window(7, 5).unwrap(), 12);
1361        assert!(checked_search_window(1, usize::MAX).is_err());
1362    }
1363
1364    #[test]
1365    fn bounded_merge_preserves_canonical_order_and_ties() {
1366        let left = vec![result(2, 9, 10.0), result(2, 3, 7.0)];
1367        let right = vec![result(1, 8, 10.0), result(1, 2, 7.0)];
1368
1369        let merged = merge_two_ranked(left, right, 3);
1370        let keys: Vec<_> = merged
1371            .iter()
1372            .map(|result| (result.score, result.segment_id, result.doc_id))
1373            .collect();
1374        assert_eq!(keys, vec![(10.0, 1, 8), (10.0, 2, 9), (7.0, 1, 2)]);
1375    }
1376
1377    #[test]
1378    fn result_offset_returns_only_the_requested_window() {
1379        let results = (0..8)
1380            .map(|doc_id| result(1, doc_id, 8.0 - doc_id as f32))
1381            .collect();
1382
1383        let page = apply_result_offset(results, 5, 2);
1384        assert_eq!(
1385            page.iter().map(|result| result.doc_id).collect::<Vec<_>>(),
1386            vec![2, 3, 4]
1387        );
1388    }
1389
1390    #[test]
1391    fn lsp_gamma_is_global_not_per_segment() {
1392        let bounds = vec![
1393            Some(vec![9.0, 1.0, 8.0]),
1394            Some(vec![7.0, 6.0, 0.0]),
1395            None,
1396            Some(vec![5.0, 4.0]),
1397        ];
1398        let mut selected = select_global_lsp_superblocks(&bounds, 4);
1399        for segment in &mut selected {
1400            segment.sort_unstable();
1401        }
1402        assert_eq!(selected.iter().map(Vec::len).sum::<usize>(), 4);
1403        assert_eq!(selected[0], vec![0, 2]);
1404        assert_eq!(selected[1], vec![0, 1]);
1405        assert!(selected[2].is_empty());
1406        assert!(selected[3].is_empty());
1407    }
1408}
1409
1410#[cfg(test)]
1411mod fusion_parallelism_tests {
1412    #[test]
1413    fn fusion_keeps_parallelism_at_the_segment_level() {
1414        let source = include_str!("searcher.rs");
1415        let body = source
1416            .split("pub async fn search_fused_with_count")
1417            .nth(1)
1418            .and_then(|tail| tail.split("/// Two-stage search").next())
1419            .expect("bounded fusion search implementation");
1420
1421        assert!(
1422            !body.contains(".par_iter()"),
1423            "sub-query parallelism nests over segment parallelism"
1424        );
1425        assert!(
1426            !body.contains(".buffered("),
1427            "async fusion fallback must preserve the same bounded execution shape"
1428        );
1429    }
1430}