Skip to main content

ripvec_core/encoder/ripvec/
index.rs

1//! `RipvecIndex` orchestrator and PageRank-layered ranking.
2//!
3//! Port of `~/src/semble/src/semble/index/index.py:RipvecIndex`. Owns
4//! the corpus state (chunks, file mapping, language mapping, BM25,
5//! dense embeddings, encoder) and dispatches search by mode.
6//!
7//! ## Port-plus-ripvec scope
8//!
9//! Per `docs/PLAN.md`, after the ripvec engine's own `rerank_topk` runs, ripvec's
10//! [`boost_with_pagerank`](crate::hybrid::boost_with_pagerank) is
11//! applied as a final ranking layer. The PageRank lookup is built from
12//! the repo graph and stored alongside the corpus when one is provided
13//! at construction; the layer no-ops when no graph is present.
14
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17
18use crate::chunk::CodeChunk;
19use crate::embed::SearchConfig;
20use crate::encoder::VectorEncoder;
21use crate::encoder::ripvec::bm25::{Bm25Index, search_bm25};
22use crate::encoder::ripvec::dense::StaticEncoder;
23use crate::encoder::ripvec::hybrid::{search_hybrid, search_semantic};
24use crate::encoder::ripvec::manifest::{Diff, FileEntry, Manifest, diff_against_walk};
25use crate::hybrid::SearchMode;
26use crate::profile::Profiler;
27use crate::walk::{WalkOptions, collect_files_with_options};
28
29/// Combined orchestrator for the ripvec retrieval pipeline.
30///
31/// Constructed via [`RipvecIndex::from_root`] which walks files,
32/// chunks them with ripvec's chunker, embeds with the static encoder,
33/// and builds the BM25 index.
34pub struct RipvecIndex {
35    chunks: Vec<CodeChunk>,
36    /// Row-major contiguous embedding matrix; row `i` is the
37    /// L2-normalized embedding of chunk `i`. Held as `Array2<f32>` so
38    /// cosine queries (dot product over normalized rows) dispatch to
39    /// BLAS `sgemv` via ndarray's `cpu-accelerate` feature instead of
40    /// pointer-chasing through `Vec<Vec<f32>>`. The change is a
41    /// ~150x theoretical lift on per-query dense scoring at 1M chunks
42    /// (memory-bandwidth-bound).
43    embeddings: ndarray::Array2<f32>,
44    bm25: Bm25Index,
45    /// Shared by `Arc` so [`Self::apply_diff`] can produce a new index
46    /// that reuses the same loaded model without cloning the ~32 MB
47    /// embedding table. The encoder is immutable after construction.
48    encoder: std::sync::Arc<StaticEncoder>,
49    file_mapping: HashMap<String, Vec<usize>>,
50    language_mapping: HashMap<String, Vec<usize>>,
51    pagerank_lookup: Option<std::sync::Arc<HashMap<String, f32>>>,
52    pagerank_alpha: f32,
53    corpus_class: CorpusClass,
54    /// Canonical root the index was built against. Used by
55    /// [`RipvecIndex::diff_against_filesystem`] to walk the same tree
56    /// for reconciliation.
57    root: PathBuf,
58    /// Walk filters captured at build time so reconciliation honors the
59    /// same `.gitignore`, extension whitelist, ignore-pattern set as
60    /// the original index.
61    walk_options: WalkOptions,
62    /// Per-file fingerprint table (mtime, size, inode, blake3) for
63    /// online change detection. Built during [`Self::from_root`] and
64    /// queried by [`Self::diff_against_filesystem`]. See
65    /// [`crate::encoder::ripvec::manifest`] for the algorithm.
66    manifest: Manifest,
67}
68
69/// Index-time classification of the corpus by file mix.
70///
71/// Drives the corpus-aware rerank gate: docs and mixed corpora get
72/// the L-12 cross-encoder fired (when the query is NL-shaped); pure
73/// code corpora skip it because the ms-marco-trained model is
74/// out-of-domain for code regardless of impl quality.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
76#[serde(rename_all = "lowercase")]
77pub enum CorpusClass {
78    /// Less than 30% of chunks are in prose files. Pure or near-pure
79    /// code corpora — rerank skipped.
80    Code,
81    /// Between 30% and 70% prose chunks. Mixed corpora — rerank fires
82    /// on NL queries to recover the prose-dominant relevance signal.
83    Mixed,
84    /// At least 70% prose chunks. Documentation, book sets, knowledge
85    /// bases — rerank fires by default.
86    Docs,
87}
88
89impl CorpusClass {
90    /// Classify a chunk set by the fraction of chunks from prose files.
91    /// Empty input is classified as `Code` (degenerate but defined).
92    #[must_use]
93    pub fn classify(chunks: &[CodeChunk]) -> Self {
94        if chunks.is_empty() {
95            return Self::Code;
96        }
97        let prose = chunks
98            .iter()
99            .filter(|c| crate::encoder::ripvec::ranking::is_prose_path(&c.file_path))
100            .count();
101        #[expect(
102            clippy::cast_precision_loss,
103            reason = "chunk count never exceeds f32 mantissa precision in practice"
104        )]
105        let frac = prose as f32 / chunks.len() as f32;
106        if frac >= 0.7 {
107            Self::Docs
108        } else if frac >= 0.3 {
109            Self::Mixed
110        } else {
111            Self::Code
112        }
113    }
114
115    /// Whether the cross-encoder rerank should run on this corpus for
116    /// a non-symbol NL query. Pure code corpora skip rerank; mixed
117    /// and docs corpora enable it.
118    #[must_use]
119    pub fn rerank_eligible(self) -> bool {
120        matches!(self, Self::Mixed | Self::Docs)
121    }
122}
123
124impl RipvecIndex {
125    /// Build a [`RipvecIndex`] by walking `root` and indexing every
126    /// supported file. Uses `encoder.embed_root` (ripvec's chunker +
127    /// model2vec encode) and builds a fresh BM25 index over the
128    /// resulting chunks.
129    ///
130    /// `pagerank_lookup` is the optional structural-prior map (file
131    /// path → normalized PageRank) used by the final ranking layer;
132    /// pass `None` to disable. `pagerank_alpha` is the corresponding
133    /// boost strength.
134    ///
135    /// # Errors
136    ///
137    /// Returns the underlying error if `embed_root` fails.
138    pub fn from_root(
139        root: &Path,
140        encoder: StaticEncoder,
141        cfg: &SearchConfig,
142        profiler: &Profiler,
143        pagerank_lookup: Option<HashMap<String, f32>>,
144        pagerank_alpha: f32,
145    ) -> crate::Result<Self> {
146        // Wrap once at construction. The per-query `apply_pagerank_layer`
147        // path clones the Arc (pointer bump), not the HashMap (10K+ String
148        // allocs on a 1M-chunk corpus).
149        let pagerank_lookup = pagerank_lookup.map(std::sync::Arc::new);
150        let (chunks, embeddings_vec) = encoder.embed_root(root, cfg, profiler)?;
151        // Convert Vec<Vec<f32>> -> Array2<f32> at the boundary. The
152        // upstream embed_root produces ragged-friendly Vec<Vec<>>; we
153        // pack into one contiguous row-major buffer so BLAS sgemv can
154        // do per-query cosine in one call. Cost is a single sequential
155        // memcpy pass (~1 GB at memory bandwidth = ~5 ms on a 1M-chunk
156        // corpus) — negligible against the 60 s build phase.
157        let hidden_dim = embeddings_vec.first().map_or(0, std::vec::Vec::len);
158        let n_chunks = embeddings_vec.len();
159        let mut flat: Vec<f32> = Vec::with_capacity(n_chunks * hidden_dim);
160        for row in embeddings_vec {
161            debug_assert_eq!(
162                row.len(),
163                hidden_dim,
164                "ragged embeddings: row of {} vs expected {hidden_dim}",
165                row.len()
166            );
167            flat.extend(row);
168        }
169        let embeddings = ndarray::Array2::from_shape_vec((n_chunks, hidden_dim), flat)
170            .map_err(|e| crate::Error::Other(anyhow::anyhow!("embeddings reshape: {e}")))?;
171        let bm25 = {
172            let _g = profiler.phase("bm25_build");
173            Bm25Index::build(&chunks)
174        };
175        let (file_mapping, language_mapping) = {
176            let _g = profiler.phase("mappings");
177            build_mappings(&chunks)
178        };
179        let corpus_class = CorpusClass::classify(&chunks);
180        // Capture walk options for future reconciles, and populate the
181        // manifest from the same file set the indexer consumed. We
182        // re-walk + re-read here because `embed_root` doesn't surface
183        // the per-file bytes back to us; the redundant read is paid
184        // once at index build time, not per query. On reconcile we
185        // only re-read files whose stat tuple changed.
186        let walk_options = cfg.walk_options();
187        let root_buf = root.to_path_buf();
188        let manifest = {
189            let _g = profiler.phase("manifest_build");
190            build_manifest(&root_buf, &walk_options)
191        };
192        Ok(Self {
193            chunks,
194            embeddings,
195            bm25,
196            encoder: std::sync::Arc::new(encoder),
197            file_mapping,
198            language_mapping,
199            pagerank_lookup,
200            pagerank_alpha,
201            corpus_class,
202            root: root_buf,
203            walk_options,
204            manifest,
205        })
206    }
207
208    /// Build a new index by incrementally applying `diff` against
209    /// `self`.
210    ///
211    /// **The selective-rebuild path that v3.1.0 punted on.** Re-embeds
212    /// only the dirty + new files, splices them into the existing
213    /// chunks/embeddings, drops deleted files' chunks, rebuilds BM25
214    /// and the per-file/per-language mappings from the new chunk set,
215    /// reclassifies the corpus, and refreshes the manifest entries
216    /// for the affected files.
217    ///
218    /// # Cost shape
219    ///
220    /// Roughly `O(|diff.dirty| + |diff.new|)` chunk + embed work plus
221    /// `O(|self.chunks|)` BM25 rebuild. On a 5000-chunk corpus with
222    /// one file changed: ~5-10 ms (embed one file) + ~50 ms (BM25
223    /// rebuild) = ~60 ms — vs. ~270 ms-1 s for a full
224    /// [`Self::from_root`] rebuild. The full-build cost is paid only
225    /// at cold start.
226    ///
227    /// # BM25
228    ///
229    /// BM25 is rebuilt from scratch over the new chunks vec rather
230    /// than incrementally updated. Inverted-postings incremental
231    /// update is correct but adds significant code; full rebuild at
232    /// our chunk counts is fast enough that the simpler path wins.
233    ///
234    /// # Errors
235    ///
236    /// Returns the underlying error if [`StaticEncoder::embed_paths`]
237    /// fails or if the embedding matrix shape is invalid.
238    pub fn apply_diff(&self, diff: &Diff, profiler: &Profiler) -> crate::Result<Self> {
239        use std::collections::HashSet;
240
241        // 1. Identify which existing chunk indices to drop. `file_mapping`
242        //    keys are the rel_paths the chunker wrote. Manifest paths are
243        //    absolute. Map manifest paths to rel_paths by stripping
244        //    `self.root` (the same operation `chunk_one_file` performs).
245        let rel_path_for = |p: &Path| -> String {
246            p.strip_prefix(&self.root)
247                .unwrap_or(p)
248                .display()
249                .to_string()
250        };
251        let mut removed_indices: HashSet<usize> = HashSet::new();
252        for path in diff
253            .deleted
254            .iter()
255            .chain(diff.dirty.iter())
256            .chain(diff.new.iter())
257        {
258            let rel = rel_path_for(path);
259            if let Some(indices) = self.file_mapping.get(&rel) {
260                removed_indices.extend(indices.iter().copied());
261            }
262        }
263
264        // 2. Build the kept chunks + embeddings from `self`. Cloning the
265        //    embedding rows is one allocation per kept chunk; for a 5k-
266        //    chunk corpus that's a single sequential pass over 5 MB.
267        let mut kept_chunks: Vec<CodeChunk> = Vec::with_capacity(self.chunks.len());
268        let mut kept_emb_rows: Vec<Vec<f32>> = Vec::with_capacity(self.chunks.len());
269        for (i, chunk) in self.chunks.iter().enumerate() {
270            if removed_indices.contains(&i) {
271                continue;
272            }
273            kept_chunks.push(chunk.clone());
274            kept_emb_rows.push(self.embeddings.row(i).to_vec());
275        }
276
277        // 3. Embed the dirty + new files. (Dirty files were already
278        //    dropped from `kept_chunks` above; their new chunks come in
279        //    here as fresh entries.)
280        let mut to_embed: Vec<std::path::PathBuf> = Vec::new();
281        to_embed.extend(diff.new.iter().cloned());
282        to_embed.extend(diff.dirty.iter().cloned());
283        let (new_chunks, new_embs) = if to_embed.is_empty() {
284            (Vec::new(), Vec::new())
285        } else {
286            let _g = profiler.phase("apply_diff_embed");
287            self.encoder.embed_paths(&self.root, &to_embed, profiler)?
288        };
289        kept_chunks.extend(new_chunks);
290        kept_emb_rows.extend(new_embs);
291
292        // 4. Re-pack embeddings into a contiguous Array2 so BLAS sgemv
293        //    still works at query time.
294        let n = kept_emb_rows.len();
295        let hidden_dim = kept_emb_rows
296            .first()
297            .map_or(self.embeddings.ncols(), Vec::len);
298        let mut flat: Vec<f32> = Vec::with_capacity(n * hidden_dim);
299        for row in kept_emb_rows {
300            flat.extend(row);
301        }
302        let embeddings = if n == 0 {
303            ndarray::Array2::<f32>::zeros((0, hidden_dim))
304        } else {
305            ndarray::Array2::from_shape_vec((n, hidden_dim), flat).map_err(|e| {
306                crate::Error::Other(anyhow::anyhow!("apply_diff embeddings reshape: {e}"))
307            })?
308        };
309
310        // 5. Rebuild BM25 from the new chunks (simpler than incremental
311        //    postings update; cheap at our chunk counts). Rebuild
312        //    mappings + corpus_class from the new chunks too.
313        let bm25 = {
314            let _g = profiler.phase("apply_diff_bm25");
315            Bm25Index::build(&kept_chunks)
316        };
317        let (file_mapping, language_mapping) = {
318            let _g = profiler.phase("apply_diff_mappings");
319            build_mappings(&kept_chunks)
320        };
321        let corpus_class = CorpusClass::classify(&kept_chunks);
322
323        // 6. Refresh manifest: drop deleted entries, refresh dirty
324        //    entries with new (mtime, size, ino, blake3), insert new
325        //    entries. blake3 requires the file bytes, so this re-reads
326        //    each changed file once. Negligible (~10 µs/file warm).
327        //
328        //    Also apply `diff.touched_clean`: these are files whose stat
329        //    tuple changed but whose content (blake3) is identical. The
330        //    `diff_against_filesystem` path clones `self.manifest` before
331        //    calling `diff_against_walk`, so the in-place stat-tuple
332        //    refresh inside `diff_against_walk` is discarded. Without this
333        //    step, every touched-but-unchanged file pays one blake3 read
334        //    per reconcile cycle instead of zero. Applying the entries here
335        //    — using the refreshed `FileEntry` already computed by
336        //    `diff_against_walk` — restores the "one blake3 then zero"
337        //    invariant on the new index.
338        let mut manifest = self.manifest.clone();
339        for path in &diff.deleted {
340            manifest.files.remove(path);
341        }
342        for path in diff.new.iter().chain(diff.dirty.iter()) {
343            if let Ok(entry) = FileEntry::from_path(path) {
344                manifest.insert(path.clone(), entry);
345            }
346        }
347        // Apply touched_clean refreshes: stat tuple already computed by
348        // diff_against_walk; no re-read or re-hash needed.
349        for (path, refreshed_entry) in &diff.touched_clean {
350            if let Some(entry_mut) = manifest.files.get_mut(path) {
351                entry_mut.mtime = refreshed_entry.mtime;
352                entry_mut.size = refreshed_entry.size;
353                entry_mut.ino = refreshed_entry.ino;
354                // blake3 is unchanged (that's the definition of touched_clean)
355                // but we overwrite defensively for consistency.
356                entry_mut.blake3 = refreshed_entry.blake3;
357            }
358        }
359
360        Ok(Self {
361            chunks: kept_chunks,
362            embeddings,
363            bm25,
364            encoder: std::sync::Arc::clone(&self.encoder),
365            file_mapping,
366            language_mapping,
367            pagerank_lookup: self.pagerank_lookup.clone(),
368            pagerank_alpha: self.pagerank_alpha,
369            corpus_class,
370            root: self.root.clone(),
371            walk_options: self.walk_options.clone(),
372            manifest,
373        })
374    }
375
376    /// Compare the manifest captured at build time against the current
377    /// filesystem state under [`Self::root`], using the same
378    /// [`WalkOptions`] used for the original index build.
379    ///
380    /// Returns a [`Diff`] enumerating dirty, new, and deleted files.
381    /// A zero-cost ([`Diff::is_empty`]) result means the index is
382    /// up-to-date and no rebuild is needed.
383    ///
384    /// # Cost
385    ///
386    /// Walk + per-file `stat()` for the cheap-path files (typically all
387    /// of them between successive queries). Blake3 verification is paid
388    /// only on the rare files where the stat tuple mismatches. On a
389    /// 200-file repo with no changes: sub-millisecond. On a 92k-file
390    /// repo with no changes: ~100-130 ms (the walk dominates).
391    ///
392    /// # Mutation
393    ///
394    /// This method takes `&self` and works on a clone of the manifest,
395    /// so the optimization of "refresh touched-but-unchanged stat
396    /// tuples" from [`diff_against_walk`] is discarded here. In
397    /// practice that means a file repeatedly touched without content
398    /// change pays one blake3 read per reconcile rather than zero —
399    /// negligible at our file sizes.
400    #[must_use]
401    pub fn diff_against_filesystem(&self) -> Diff {
402        let files = collect_files_with_options(&self.root, &self.walk_options);
403        let mut manifest = self.manifest.clone();
404        diff_against_walk(&mut manifest, &files)
405    }
406
407    /// Canonical root the index was built against.
408    #[must_use]
409    pub fn root(&self) -> &Path {
410        &self.root
411    }
412
413    /// Walk options captured at build time.
414    #[must_use]
415    pub fn walk_options(&self) -> &WalkOptions {
416        &self.walk_options
417    }
418
419    /// Manifest of tracked files (read-only access).
420    #[must_use]
421    pub fn manifest(&self) -> &Manifest {
422        &self.manifest
423    }
424
425    /// The index's corpus classification, computed at build time.
426    ///
427    /// Used by the MCP rerank gate to decide whether the L-12
428    /// cross-encoder fires on a given query.
429    #[must_use]
430    pub fn corpus_class(&self) -> CorpusClass {
431        self.corpus_class
432    }
433
434    /// Number of indexed chunks.
435    #[must_use]
436    pub fn len(&self) -> usize {
437        self.chunks.len()
438    }
439
440    /// Whether the index has zero chunks.
441    #[must_use]
442    pub fn is_empty(&self) -> bool {
443        self.chunks.is_empty()
444    }
445
446    /// Indexed chunks (read-only access).
447    #[must_use]
448    pub fn chunks(&self) -> &[CodeChunk] {
449        &self.chunks
450    }
451
452    /// Indexed embeddings (read-only access).
453    ///
454    /// `Array2<f32>` of shape `[n_chunks, hidden_dim]`, row-major. Row
455    /// `i` is the L2-normalized embedding of chunk `i`, so cosine
456    /// similarity reduces to a dot product. Callers that need their
457    /// own similarity arithmetic (`find_similar`, `find_duplicates`)
458    /// should use `embeddings.row(i)` for a single-row view or
459    /// `embeddings.dot(&query)` for a one-call BLAS GEMV.
460    #[must_use]
461    pub fn embeddings(&self) -> &ndarray::Array2<f32> {
462        &self.embeddings
463    }
464
465    /// Search the index and return ranked `(chunk_index, score)` pairs.
466    ///
467    /// `mode = SearchMode::Hybrid` (default) fuses semantic + BM25 via
468    /// RRF; `Semantic` and `Keyword` use one signal each.
469    ///
470    /// `filter_languages` and `filter_paths` build a selector mask
471    /// that restricts retrieval to chunks in the named files /
472    /// languages.
473    #[must_use]
474    pub fn search(
475        &self,
476        query: &str,
477        top_k: usize,
478        mode: SearchMode,
479        alpha: Option<f32>,
480        filter_languages: Option<&[String]>,
481        filter_paths: Option<&[String]>,
482    ) -> Vec<(usize, f32)> {
483        if self.is_empty() || query.trim().is_empty() {
484            return Vec::new();
485        }
486        let selector = self.build_selector(filter_languages, filter_paths);
487
488        let raw = match mode {
489            SearchMode::Keyword => search_bm25(query, &self.bm25, top_k, selector.as_deref()),
490            SearchMode::Semantic => {
491                let q_emb = self.encoder.encode_query(query);
492                search_semantic(&q_emb, &self.embeddings, top_k, selector.as_deref())
493            }
494            SearchMode::Hybrid => {
495                let q_emb = self.encoder.encode_query(query);
496                search_hybrid(
497                    query,
498                    &q_emb,
499                    &self.embeddings,
500                    &self.chunks,
501                    &self.bm25,
502                    top_k,
503                    alpha,
504                    selector.as_deref(),
505                )
506            }
507        };
508
509        self.apply_pagerank_layer(raw)
510    }
511
512    /// Build a selector mask from optional language/path filters.
513    /// Returns `None` when no filters are set (search runs over the
514    /// full corpus).
515    fn build_selector(
516        &self,
517        filter_languages: Option<&[String]>,
518        filter_paths: Option<&[String]>,
519    ) -> Option<Vec<usize>> {
520        let mut selector: Vec<usize> = Vec::new();
521        if let Some(langs) = filter_languages {
522            for lang in langs {
523                if let Some(ids) = self.language_mapping.get(lang) {
524                    selector.extend(ids.iter().copied());
525                }
526            }
527        }
528        if let Some(paths) = filter_paths {
529            for path in paths {
530                if let Some(ids) = self.file_mapping.get(path) {
531                    selector.extend(ids.iter().copied());
532                }
533            }
534        }
535        if selector.is_empty() {
536            None
537        } else {
538            selector.sort_unstable();
539            selector.dedup();
540            Some(selector)
541        }
542    }
543
544    /// Layer ripvec's PageRank boost on top of semble's ranked results.
545    ///
546    /// No-op when `pagerank_lookup` is `None` or the boost strength
547    /// is zero. Otherwise re-uses
548    /// [`crate::hybrid::boost_with_pagerank`] so the PageRank semantic
549    /// stays consistent with ripvec's other code paths.
550    fn apply_pagerank_layer(&self, mut results: Vec<(usize, f32)>) -> Vec<(usize, f32)> {
551        let Some(lookup) = &self.pagerank_lookup else {
552            return results;
553        };
554        if results.is_empty() || self.pagerank_alpha <= 0.0 {
555            return results;
556        }
557        // Uses the shared `ranking::PageRankBoost` layer for behavioral
558        // parity with the BERT CLI, MCP `search_code`, and LSP paths.
559        // All five callers now apply the same sigmoid-on-percentile
560        // curve.
561        // `lookup` is `Arc<HashMap<_,_>>`; cloning the Arc is a pointer
562        // bump, not a HashMap copy. The earlier `lookup.clone()` here
563        // cloned the entire map per query (~10K String allocations on
564        // a 1M-chunk corpus).
565        let layers: Vec<Box<dyn crate::ranking::RankingLayer>> = vec![Box::new(
566            crate::ranking::PageRankBoost::new(std::sync::Arc::clone(lookup), self.pagerank_alpha),
567        )];
568        crate::ranking::apply_chain(&mut results, &self.chunks, &layers);
569        results
570    }
571}
572
573impl crate::searchable::SearchableIndex for RipvecIndex {
574    fn chunks(&self) -> &[CodeChunk] {
575        RipvecIndex::chunks(self)
576    }
577
578    /// Trait-shape search: text-only, no engine-specific knobs.
579    ///
580    /// The trait surface is the LSP-callers' common ground. Filters
581    /// (language, path) and the alpha auto-detect override are not
582    /// surfaced through the trait because no LSP module uses them.
583    fn search(&self, query_text: &str, top_k: usize, mode: SearchMode) -> Vec<(usize, f32)> {
584        RipvecIndex::search(self, query_text, top_k, mode, None, None, None)
585    }
586
587    /// Use chunk `chunk_idx`'s own embedding as the query vector and
588    /// rank everything else by cosine similarity (semantic-only) or
589    /// blend with BM25 (hybrid). Falls back to text-only keyword
590    /// search when the chunk index is out of range.
591    ///
592    /// Mirrors the [`HybridIndex`] equivalent so `goto_definition`
593    /// and `goto_implementation` work identically across engines.
594    fn search_from_chunk(
595        &self,
596        chunk_idx: usize,
597        query_text: &str,
598        top_k: usize,
599        mode: SearchMode,
600    ) -> Vec<(usize, f32)> {
601        // RipvecIndex stores embeddings; if the source chunk is in
602        // range we can rank by similarity against its vector. Out of
603        // range or keyword-only mode: fall back to text search.
604        if chunk_idx >= self.embeddings().nrows() {
605            return RipvecIndex::search(
606                self,
607                query_text,
608                top_k,
609                SearchMode::Keyword,
610                None,
611                None,
612                None,
613            );
614        }
615        match mode {
616            SearchMode::Keyword => RipvecIndex::search(
617                self,
618                query_text,
619                top_k,
620                SearchMode::Keyword,
621                None,
622                None,
623                None,
624            ),
625            SearchMode::Semantic | SearchMode::Hybrid => {
626                // Cosine via dot product over L2-normalized rows.
627                // Parallel sgemv across row-shards to saturate
628                // aggregate memory bandwidth instead of the single-core
629                // sgemv ceiling.
630                let source = self.embeddings().row(chunk_idx);
631                let scores =
632                    crate::encoder::ripvec::hybrid::parallel_sgemv(self.embeddings(), &source);
633                let mut scored: Vec<(usize, f32)> = scores
634                    .iter()
635                    .enumerate()
636                    .filter(|(i, _)| *i != chunk_idx)
637                    .map(|(i, &s)| (i, s))
638                    .collect();
639                if scored.len() > top_k {
640                    scored.select_nth_unstable_by(top_k - 1, |a, b| {
641                        b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0))
642                    });
643                    scored.truncate(top_k);
644                }
645                scored.sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
646                scored
647            }
648        }
649    }
650
651    fn as_any(&self) -> &dyn std::any::Any {
652        self
653    }
654}
655
656/// Locate the chunk index for a given file path and 1-based line number.
657///
658/// Used by `find_similar` (ripvec-mcp) to resolve an `lsp_location` whose
659/// `start_line` may be the symbol-identifier line (as returned by
660/// `get_repo_map`'s `symbols[].lsp_location`) rather than the chunk's own
661/// `start_line` (the block-start, which may precede the identifier by the
662/// length of doc-comments, attributes, or decorators).
663///
664/// # Lookup strategy (I#50 three-step spec)
665///
666/// 1. **Exact start-line match**: return the first chunk whose
667///    `start_line == target_line_1based`. Cheap O(n) scan that covers the
668///    common case where the caller already has a chunk-start coordinate.
669///
670/// 2. **Range containment**: return the first chunk whose closed interval
671///    `[start_line, end_line]` contains `target_line_1based`. Covers the
672///    I#50 failure case where `get_repo_map.symbols[].lsp_location.start_line`
673///    is the identifier line (inside the chunk) rather than the block start.
674///
675/// 3. **Miss**: return `None`. The caller is responsible for returning empty
676///    results — `find_similar` must NOT propagate this as an internal error.
677///
678/// # Path matching
679///
680/// Matches on a strict suffix: a chunk at path `a/b/c.rs` matches a query
681/// for `b/c.rs` or `c.rs` (the suffix is separated by `/`). Absolute paths
682/// are compared directly. This mirrors the convention used throughout
683/// `find_similar_chunk_idx` in `ripvec-mcp`.
684///
685/// # Arguments
686///
687/// * `chunks` — the indexed chunk slice (from `RipvecIndex::chunks()`).
688/// * `file_path` — path to match against `chunk.file_path`.
689/// * `target_line_1based` — the 1-based line to locate, matching the
690///   1-based `lsp_location.start_line` → `target_line_1based` convention.
691#[must_use]
692pub fn find_chunk_containing_line(
693    chunks: &[CodeChunk],
694    file_path: &str,
695    target_line_1based: usize,
696) -> Option<usize> {
697    let path_matches = |chunk: &CodeChunk| -> bool {
698        let cp = &chunk.file_path;
699        cp == file_path
700            || (cp.len() > file_path.len()
701                && cp.ends_with(file_path)
702                && cp.as_bytes()[cp.len() - file_path.len() - 1] == b'/')
703    };
704
705    // Step 1: exact start_line match.
706    if let Some(idx) = chunks
707        .iter()
708        .position(|c| path_matches(c) && c.start_line == target_line_1based)
709    {
710        return Some(idx);
711    }
712
713    // Step 2: range containment — find the first chunk whose [start_line,
714    // end_line] interval contains the target line.
715    chunks.iter().position(|c| {
716        path_matches(c) && c.start_line <= target_line_1based && target_line_1based <= c.end_line
717    })
718}
719
720/// Build (file_path → chunk indices, language → chunk indices) mappings.
721/// Build the per-file manifest by walking `root` with `walk_options`
722/// and stat + read + blake3 each file. Used at index construction; on
723/// reconcile, [`RipvecIndex::diff_against_filesystem`] uses the cheap
724/// stat-tuple path and only re-reads files whose tuple mismatches the
725/// stored entry.
726///
727/// Files that can't be read or stat'd are silently skipped; they will
728/// re-appear in the diff as `new` if they become readable later, or
729/// as missing on the next reconcile.
730fn build_manifest(root: &Path, walk_options: &WalkOptions) -> Manifest {
731    let mut manifest = Manifest::new();
732    let files = collect_files_with_options(root, walk_options);
733    for path in files {
734        let (Ok(metadata), Ok(bytes)) = (std::fs::metadata(&path), std::fs::read(&path)) else {
735            continue;
736        };
737        let entry = FileEntry::from_bytes(&metadata, &bytes);
738        manifest.insert(path, entry);
739    }
740    manifest
741}
742
743fn build_mappings(
744    chunks: &[CodeChunk],
745) -> (HashMap<String, Vec<usize>>, HashMap<String, Vec<usize>>) {
746    let mut file_to_id: HashMap<String, Vec<usize>> = HashMap::new();
747    let mut lang_to_id: HashMap<String, Vec<usize>> = HashMap::new();
748    for (i, chunk) in chunks.iter().enumerate() {
749        file_to_id
750            .entry(chunk.file_path.clone())
751            .or_default()
752            .push(i);
753        // The semble port's chunker stores language inferentially (via
754        // extension); the per-chunk `language` field isn't populated on
755        // this path. The mapping is keyed on file extension as a proxy
756        // so `filter_languages: Some(&["rs"])` works.
757        if let Some(ext) = Path::new(&chunk.file_path)
758            .extension()
759            .and_then(|e| e.to_str())
760        {
761            lang_to_id.entry(ext.to_string()).or_default().push(i);
762        }
763    }
764    (file_to_id, lang_to_id)
765}
766
767#[cfg(test)]
768mod tests {
769    use super::*;
770
771    /// Test-only constructor that bypasses `from_root` to allow unit
772    /// tests to inject pre-built state (chunks, embeddings, mappings,
773    /// manifest) without requiring a real model download.
774    ///
775    /// For tests that call `apply_diff` with a non-empty `diff.new` or
776    /// `diff.dirty`, the caller must supply a real encoder because
777    /// `apply_diff` calls `encoder.embed_paths`.
778    #[allow(clippy::too_many_arguments)]
779    fn new_for_test(
780        chunks: Vec<crate::chunk::CodeChunk>,
781        embeddings: ndarray::Array2<f32>,
782        encoder: std::sync::Arc<StaticEncoder>,
783        file_mapping: HashMap<String, Vec<usize>>,
784        language_mapping: HashMap<String, Vec<usize>>,
785        manifest: Manifest,
786        root: std::path::PathBuf,
787        walk_options: WalkOptions,
788    ) -> RipvecIndex {
789        let bm25 = Bm25Index::build(&chunks);
790        let corpus_class = CorpusClass::classify(&chunks);
791        RipvecIndex {
792            chunks,
793            embeddings,
794            bm25,
795            encoder,
796            file_mapping,
797            language_mapping,
798            pagerank_lookup: None,
799            pagerank_alpha: 0.0,
800            corpus_class,
801            root,
802            walk_options,
803            manifest,
804        }
805    }
806
807    /// Compile-time check that `RipvecIndex` carries the right method
808    /// shape for the CLI to call.
809    #[test]
810    fn semble_index_search_signature_compiles() {
811        fn shape_check(
812            idx: &RipvecIndex,
813            query: &str,
814            top_k: usize,
815            mode: SearchMode,
816        ) -> Vec<(usize, f32)> {
817            idx.search(query, top_k, mode, None, None, None)
818        }
819        // Reference to keep type-check live across dead-code analysis.
820        let _ = shape_check;
821    }
822
823    /// `behavior:pagerank-no-op-when-graph-absent` — when constructed
824    /// without a PageRank lookup, the layer is a pure pass-through.
825    /// (Asserted via the `apply_pagerank_layer` early-return path.)
826    #[test]
827    fn pagerank_layer_no_op_when_graph_absent() {
828        // We can't easily build a RipvecIndex without a real encoder
829        // (which requires a model download). Instead, exercise the
830        // pass-through logic on a hand-built struct via the private
831        // method. The function returns its input unchanged when
832        // pagerank_lookup is None.
833        //
834        // Structural assertion: apply_pagerank_layer's first match
835        // statement returns the input directly when lookup is None;
836        // this is a single-branch invariant verified by inspection.
837        // Behavioural verification is part of P5.1's parity test.
838        let _ = "see apply_pagerank_layer docs";
839    }
840
841    /// Corner case: a file appears in `diff.new` (absent from manifest)
842    /// but `file_mapping` still holds stale chunk indices for it from a
843    /// prior partial reconcile. Without the R4.1 fix, `apply_diff` skips
844    /// clearing those stale chunks before re-embedding → duplicates.
845    ///
846    /// Gated `#[ignore]` because `apply_diff` calls `encoder.embed_paths`
847    /// for files in `diff.new`, which requires the Model2Vec weights.
848    /// Run once model is cached:
849    ///   `cargo test -p ripvec-core apply_diff_idempotent -- --ignored`
850    #[test]
851    #[ignore = "requires Model2Vec download (~32 MB on first run)"]
852    fn apply_diff_idempotent_when_new_file_already_has_chunks() {
853        use crate::encoder::ripvec::dense::{DEFAULT_MODEL_REPO, StaticEncoder};
854        use crate::profile::Profiler;
855        use std::fs;
856
857        let encoder = StaticEncoder::from_pretrained(DEFAULT_MODEL_REPO).expect("encoder load");
858        let encoder_arc = std::sync::Arc::new(encoder);
859
860        // Temporary corpus: one file (file_a.rs).
861        let tmp = tempfile::TempDir::new().unwrap();
862        let file_a = tmp.path().join("file_a.rs");
863        fs::write(
864            &file_a,
865            "pub fn alpha() -> u32 { 1 }\npub fn beta() -> u32 { 2 }\n",
866        )
867        .unwrap();
868
869        // Embed file_a.rs once to obtain its canonical chunks/embeddings.
870        let (real_chunks, real_embs) = encoder_arc
871            .embed_paths(tmp.path(), std::slice::from_ref(&file_a), &Profiler::noop())
872            .expect("embed_paths");
873        let n_real = real_chunks.len();
874        assert!(n_real > 0, "file_a.rs must produce at least one chunk");
875
876        let hidden_dim = real_embs[0].len();
877        let mut flat: Vec<f32> = Vec::with_capacity(n_real * hidden_dim);
878        for row in &real_embs {
879            flat.extend(row);
880        }
881        let embeddings = ndarray::Array2::from_shape_vec((n_real, hidden_dim), flat).unwrap();
882
883        // file_mapping holds stale indices pointing at file_a.rs chunks.
884        let rel_key = "file_a.rs".to_string();
885        let indices: Vec<usize> = (0..n_real).collect();
886        let file_mapping = HashMap::from([(rel_key, indices)]);
887
888        // Manifest is EMPTY: simulates a prior reconcile whose manifest
889        // update failed, so diff_against_filesystem classifies file_a.rs
890        // as "new" even though file_mapping still references its chunks.
891        let manifest = Manifest::new();
892
893        let index = new_for_test(
894            real_chunks,
895            embeddings,
896            std::sync::Arc::clone(&encoder_arc),
897            file_mapping,
898            HashMap::new(),
899            manifest,
900            tmp.path().to_path_buf(),
901            WalkOptions::default(),
902        );
903
904        let diff = index.diff_against_filesystem();
905        assert!(
906            diff.new.iter().any(|p| p.ends_with("file_a.rs")),
907            "file_a.rs must appear in diff.new when manifest is empty; got {:?}",
908            diff.new
909        );
910        assert!(diff.dirty.is_empty(), "no dirty expected");
911        assert!(diff.deleted.is_empty(), "no deleted expected");
912
913        // With the fix (diff.new also processed in removed_indices), stale
914        // chunks are dropped before re-embedding → chunk count equals
915        // one fresh-embed pass. Without the fix, old + new chunks both
916        // survive → count is doubled.
917        let updated = index
918            .apply_diff(&diff, &Profiler::noop())
919            .expect("apply_diff");
920
921        let file_a_count = updated
922            .chunks()
923            .iter()
924            .filter(|c| c.file_path.ends_with("file_a.rs"))
925            .count();
926
927        assert_eq!(
928            file_a_count, n_real,
929            "file_a.rs chunk count must equal one fresh-embed pass ({n_real}); \
930             got {file_a_count} — stale chunks from file_mapping not cleared"
931        );
932        assert_eq!(
933            updated.embeddings().nrows(),
934            updated.chunks().len(),
935            "embeddings row count must match chunk count"
936        );
937    }
938
939    /// Derived: applying an empty diff twice must yield identical chunk
940    /// counts — no accumulation from repeated no-op reconciles.
941    ///
942    /// Gated `#[ignore]` because building a real index requires the
943    /// Model2Vec encoder (~32 MB).
944    #[test]
945    #[ignore = "requires Model2Vec download (~32 MB on first run)"]
946    fn apply_diff_no_duplicate_chunks_after_two_passes() {
947        use crate::embed::SearchConfig;
948        use crate::encoder::ripvec::dense::{DEFAULT_MODEL_REPO, StaticEncoder};
949        use crate::profile::Profiler;
950        use std::fs;
951
952        let tmp = tempfile::TempDir::new().unwrap();
953        fs::write(
954            tmp.path().join("main.rs"),
955            "fn main() { println!(\"hello\"); }\n",
956        )
957        .unwrap();
958
959        let encoder = StaticEncoder::from_pretrained(DEFAULT_MODEL_REPO).expect("encoder load");
960        let cfg = SearchConfig {
961            batch_size: 32,
962            max_tokens: 512,
963            chunk: crate::chunk::ChunkConfig {
964                max_chunk_bytes: 4096,
965                window_size: 2048,
966                window_overlap: 512,
967            },
968            text_mode: false,
969            cascade_dim: None,
970            file_type: None,
971            exclude_extensions: Vec::new(),
972            include_extensions: Vec::new(),
973            ignore_patterns: Vec::new(),
974            corpus: crate::embed::Scope::All,
975            mode: crate::hybrid::SearchMode::Hybrid,
976        };
977        let index = RipvecIndex::from_root(tmp.path(), encoder, &cfg, &Profiler::noop(), None, 0.0)
978            .expect("from_root");
979
980        let original_count = index.chunks().len();
981
982        let diff1 = index.diff_against_filesystem();
983        assert!(diff1.is_empty(), "fresh index must yield empty diff");
984        let pass1 = index
985            .apply_diff(&diff1, &Profiler::noop())
986            .expect("apply_diff pass 1");
987        assert_eq!(
988            pass1.chunks().len(),
989            original_count,
990            "chunk count must be unchanged after empty-diff pass 1"
991        );
992
993        let diff2 = pass1.diff_against_filesystem();
994        assert!(
995            diff2.is_empty(),
996            "pass1 against unchanged FS must yield empty diff"
997        );
998        let pass2 = pass1
999            .apply_diff(&diff2, &Profiler::noop())
1000            .expect("apply_diff pass 2");
1001        assert_eq!(
1002            pass2.chunks().len(),
1003            original_count,
1004            "chunk count must be unchanged after empty-diff pass 2"
1005        );
1006    }
1007}