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/// Build (file_path → chunk indices, language → chunk indices) mappings.
657/// Build the per-file manifest by walking `root` with `walk_options`
658/// and stat + read + blake3 each file. Used at index construction; on
659/// reconcile, [`RipvecIndex::diff_against_filesystem`] uses the cheap
660/// stat-tuple path and only re-reads files whose tuple mismatches the
661/// stored entry.
662///
663/// Files that can't be read or stat'd are silently skipped; they will
664/// re-appear in the diff as `new` if they become readable later, or
665/// as missing on the next reconcile.
666fn build_manifest(root: &Path, walk_options: &WalkOptions) -> Manifest {
667 let mut manifest = Manifest::new();
668 let files = collect_files_with_options(root, walk_options);
669 for path in files {
670 let (Ok(metadata), Ok(bytes)) = (std::fs::metadata(&path), std::fs::read(&path)) else {
671 continue;
672 };
673 let entry = FileEntry::from_bytes(&metadata, &bytes);
674 manifest.insert(path, entry);
675 }
676 manifest
677}
678
679fn build_mappings(
680 chunks: &[CodeChunk],
681) -> (HashMap<String, Vec<usize>>, HashMap<String, Vec<usize>>) {
682 let mut file_to_id: HashMap<String, Vec<usize>> = HashMap::new();
683 let mut lang_to_id: HashMap<String, Vec<usize>> = HashMap::new();
684 for (i, chunk) in chunks.iter().enumerate() {
685 file_to_id
686 .entry(chunk.file_path.clone())
687 .or_default()
688 .push(i);
689 // The semble port's chunker stores language inferentially (via
690 // extension); the per-chunk `language` field isn't populated on
691 // this path. The mapping is keyed on file extension as a proxy
692 // so `filter_languages: Some(&["rs"])` works.
693 if let Some(ext) = Path::new(&chunk.file_path)
694 .extension()
695 .and_then(|e| e.to_str())
696 {
697 lang_to_id.entry(ext.to_string()).or_default().push(i);
698 }
699 }
700 (file_to_id, lang_to_id)
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706
707 /// Test-only constructor that bypasses `from_root` to allow unit
708 /// tests to inject pre-built state (chunks, embeddings, mappings,
709 /// manifest) without requiring a real model download.
710 ///
711 /// For tests that call `apply_diff` with a non-empty `diff.new` or
712 /// `diff.dirty`, the caller must supply a real encoder because
713 /// `apply_diff` calls `encoder.embed_paths`.
714 #[allow(clippy::too_many_arguments)]
715 fn new_for_test(
716 chunks: Vec<crate::chunk::CodeChunk>,
717 embeddings: ndarray::Array2<f32>,
718 encoder: std::sync::Arc<StaticEncoder>,
719 file_mapping: HashMap<String, Vec<usize>>,
720 language_mapping: HashMap<String, Vec<usize>>,
721 manifest: Manifest,
722 root: std::path::PathBuf,
723 walk_options: WalkOptions,
724 ) -> RipvecIndex {
725 let bm25 = Bm25Index::build(&chunks);
726 let corpus_class = CorpusClass::classify(&chunks);
727 RipvecIndex {
728 chunks,
729 embeddings,
730 bm25,
731 encoder,
732 file_mapping,
733 language_mapping,
734 pagerank_lookup: None,
735 pagerank_alpha: 0.0,
736 corpus_class,
737 root,
738 walk_options,
739 manifest,
740 }
741 }
742
743 /// Compile-time check that `RipvecIndex` carries the right method
744 /// shape for the CLI to call.
745 #[test]
746 fn semble_index_search_signature_compiles() {
747 fn shape_check(
748 idx: &RipvecIndex,
749 query: &str,
750 top_k: usize,
751 mode: SearchMode,
752 ) -> Vec<(usize, f32)> {
753 idx.search(query, top_k, mode, None, None, None)
754 }
755 // Reference to keep type-check live across dead-code analysis.
756 let _ = shape_check;
757 }
758
759 /// `behavior:pagerank-no-op-when-graph-absent` — when constructed
760 /// without a PageRank lookup, the layer is a pure pass-through.
761 /// (Asserted via the `apply_pagerank_layer` early-return path.)
762 #[test]
763 fn pagerank_layer_no_op_when_graph_absent() {
764 // We can't easily build a RipvecIndex without a real encoder
765 // (which requires a model download). Instead, exercise the
766 // pass-through logic on a hand-built struct via the private
767 // method. The function returns its input unchanged when
768 // pagerank_lookup is None.
769 //
770 // Structural assertion: apply_pagerank_layer's first match
771 // statement returns the input directly when lookup is None;
772 // this is a single-branch invariant verified by inspection.
773 // Behavioural verification is part of P5.1's parity test.
774 let _ = "see apply_pagerank_layer docs";
775 }
776
777 /// Corner case: a file appears in `diff.new` (absent from manifest)
778 /// but `file_mapping` still holds stale chunk indices for it from a
779 /// prior partial reconcile. Without the R4.1 fix, `apply_diff` skips
780 /// clearing those stale chunks before re-embedding → duplicates.
781 ///
782 /// Gated `#[ignore]` because `apply_diff` calls `encoder.embed_paths`
783 /// for files in `diff.new`, which requires the Model2Vec weights.
784 /// Run once model is cached:
785 /// `cargo test -p ripvec-core apply_diff_idempotent -- --ignored`
786 #[test]
787 #[ignore = "requires Model2Vec download (~32 MB on first run)"]
788 fn apply_diff_idempotent_when_new_file_already_has_chunks() {
789 use crate::encoder::ripvec::dense::{DEFAULT_MODEL_REPO, StaticEncoder};
790 use crate::profile::Profiler;
791 use std::fs;
792
793 let encoder = StaticEncoder::from_pretrained(DEFAULT_MODEL_REPO).expect("encoder load");
794 let encoder_arc = std::sync::Arc::new(encoder);
795
796 // Temporary corpus: one file (file_a.rs).
797 let tmp = tempfile::TempDir::new().unwrap();
798 let file_a = tmp.path().join("file_a.rs");
799 fs::write(
800 &file_a,
801 "pub fn alpha() -> u32 { 1 }\npub fn beta() -> u32 { 2 }\n",
802 )
803 .unwrap();
804
805 // Embed file_a.rs once to obtain its canonical chunks/embeddings.
806 let (real_chunks, real_embs) = encoder_arc
807 .embed_paths(tmp.path(), std::slice::from_ref(&file_a), &Profiler::noop())
808 .expect("embed_paths");
809 let n_real = real_chunks.len();
810 assert!(n_real > 0, "file_a.rs must produce at least one chunk");
811
812 let hidden_dim = real_embs[0].len();
813 let mut flat: Vec<f32> = Vec::with_capacity(n_real * hidden_dim);
814 for row in &real_embs {
815 flat.extend(row);
816 }
817 let embeddings = ndarray::Array2::from_shape_vec((n_real, hidden_dim), flat).unwrap();
818
819 // file_mapping holds stale indices pointing at file_a.rs chunks.
820 let rel_key = "file_a.rs".to_string();
821 let indices: Vec<usize> = (0..n_real).collect();
822 let file_mapping = HashMap::from([(rel_key, indices)]);
823
824 // Manifest is EMPTY: simulates a prior reconcile whose manifest
825 // update failed, so diff_against_filesystem classifies file_a.rs
826 // as "new" even though file_mapping still references its chunks.
827 let manifest = Manifest::new();
828
829 let index = new_for_test(
830 real_chunks,
831 embeddings,
832 std::sync::Arc::clone(&encoder_arc),
833 file_mapping,
834 HashMap::new(),
835 manifest,
836 tmp.path().to_path_buf(),
837 WalkOptions::default(),
838 );
839
840 let diff = index.diff_against_filesystem();
841 assert!(
842 diff.new.iter().any(|p| p.ends_with("file_a.rs")),
843 "file_a.rs must appear in diff.new when manifest is empty; got {:?}",
844 diff.new
845 );
846 assert!(diff.dirty.is_empty(), "no dirty expected");
847 assert!(diff.deleted.is_empty(), "no deleted expected");
848
849 // With the fix (diff.new also processed in removed_indices), stale
850 // chunks are dropped before re-embedding → chunk count equals
851 // one fresh-embed pass. Without the fix, old + new chunks both
852 // survive → count is doubled.
853 let updated = index
854 .apply_diff(&diff, &Profiler::noop())
855 .expect("apply_diff");
856
857 let file_a_count = updated
858 .chunks()
859 .iter()
860 .filter(|c| c.file_path.ends_with("file_a.rs"))
861 .count();
862
863 assert_eq!(
864 file_a_count, n_real,
865 "file_a.rs chunk count must equal one fresh-embed pass ({n_real}); \
866 got {file_a_count} — stale chunks from file_mapping not cleared"
867 );
868 assert_eq!(
869 updated.embeddings().nrows(),
870 updated.chunks().len(),
871 "embeddings row count must match chunk count"
872 );
873 }
874
875 /// Derived: applying an empty diff twice must yield identical chunk
876 /// counts — no accumulation from repeated no-op reconciles.
877 ///
878 /// Gated `#[ignore]` because building a real index requires the
879 /// Model2Vec encoder (~32 MB).
880 #[test]
881 #[ignore = "requires Model2Vec download (~32 MB on first run)"]
882 fn apply_diff_no_duplicate_chunks_after_two_passes() {
883 use crate::embed::SearchConfig;
884 use crate::encoder::ripvec::dense::{DEFAULT_MODEL_REPO, StaticEncoder};
885 use crate::profile::Profiler;
886 use std::fs;
887
888 let tmp = tempfile::TempDir::new().unwrap();
889 fs::write(
890 tmp.path().join("main.rs"),
891 "fn main() { println!(\"hello\"); }\n",
892 )
893 .unwrap();
894
895 let encoder = StaticEncoder::from_pretrained(DEFAULT_MODEL_REPO).expect("encoder load");
896 let cfg = SearchConfig {
897 batch_size: 32,
898 max_tokens: 512,
899 chunk: crate::chunk::ChunkConfig {
900 max_chunk_bytes: 4096,
901 window_size: 2048,
902 window_overlap: 512,
903 },
904 text_mode: false,
905 cascade_dim: None,
906 file_type: None,
907 exclude_extensions: Vec::new(),
908 include_extensions: Vec::new(),
909 ignore_patterns: Vec::new(),
910 scope: crate::embed::Scope::All,
911 mode: crate::hybrid::SearchMode::Hybrid,
912 };
913 let index = RipvecIndex::from_root(tmp.path(), encoder, &cfg, &Profiler::noop(), None, 0.0)
914 .expect("from_root");
915
916 let original_count = index.chunks().len();
917
918 let diff1 = index.diff_against_filesystem();
919 assert!(diff1.is_empty(), "fresh index must yield empty diff");
920 let pass1 = index
921 .apply_diff(&diff1, &Profiler::noop())
922 .expect("apply_diff pass 1");
923 assert_eq!(
924 pass1.chunks().len(),
925 original_count,
926 "chunk count must be unchanged after empty-diff pass 1"
927 );
928
929 let diff2 = pass1.diff_against_filesystem();
930 assert!(
931 diff2.is_empty(),
932 "pass1 against unchanged FS must yield empty diff"
933 );
934 let pass2 = pass1
935 .apply_diff(&diff2, &Profiler::noop())
936 .expect("apply_diff pass 2");
937 assert_eq!(
938 pass2.chunks().len(),
939 original_count,
940 "chunk count must be unchanged after empty-diff pass 2"
941 );
942 }
943}